From ff494c69ffcaec903c64f80cfb46234ad6c850ef Mon Sep 17 00:00:00 2001 From: m4air Date: Wed, 23 Sep 2026 06:12:11 -0700 Subject: [PATCH 01/43] Replace profile JSON persistence with a SQLite authority --- .../build-plugins/plain-node-entry-guard.ts | 3 +- config/electron-builder.config.cjs | 2 + config/scripts/build-orcad.mjs | 5 +- .../electron-vite-output-contract.test.ts | 31 +- config/tsconfig.cli.json | 48 + electron.vite.config.ts | 27 +- src/cli/handler-group-manifest.ts | 5 + src/cli/handlers/agent-hooks.test.ts | 290 +++++- src/cli/handlers/agent-hooks.ts | 109 ++- .../profile-state-recovery-admission.test.ts | 233 +++++ src/cli/handlers/profile-state.test.ts | 458 ++++++++++ src/cli/handlers/profile-state.ts | 147 +++ src/cli/index.ts | 3 +- src/cli/profile-state-location.ts | 13 + src/cli/runtime/launch.ts | 6 +- .../profile-state-recovery-launch.test.ts | 119 +++ .../runtime/profile-state-recovery-launch.ts | 71 ++ src/cli/specs/index.ts | 4 +- src/cli/specs/profile-state.ts | 27 + src/main/codex-accounts/fs-utils.ts | 2 +- src/main/daemon/daemon-launch-paths.ts | 5 +- .../daemon/daemon-pty-spawn-preparations.ts | 9 +- .../terminal-history-permission-repair.ts | 5 +- src/main/daemon/windows-conpty-warmup.test.ts | 8 + src/main/daemon/windows-conpty-warmup.ts | 3 + .../durable-file-write-syscall-proof.test.ts | 51 +- src/main/durable-file-write.ts | 21 +- src/main/index.ts | 45 +- .../ipc/orca-profile-project-transfer-args.ts | 25 + src/main/ipc/orca-profiles.test.ts | 92 +- src/main/ipc/orca-profiles.ts | 32 +- .../ipc/pty/host-env/fresh-spawn-routing.ts | 10 +- .../profile-active-transfer.test.ts | 229 +++++ .../orca-profiles/profile-active-transfer.ts | 34 + .../profile-cloud-auth-status.test.ts | 1 + .../orca-profiles/profile-index-store.test.ts | 54 ++ src/main/orca-profiles/profile-index-store.ts | 60 +- .../profile-legacy-state-import.ts | 35 + .../profile-project-move-intent.ts | 249 +++++ .../profile-project-state-file.ts | 148 ++- ...profile-project-transfer-migration.test.ts | 278 ++++++ .../profile-project-transfer-migration.ts | 26 + .../profile-project-transfer.test.ts | 406 ++++++++- .../orca-profiles/profile-project-transfer.ts | 65 +- .../orca-profiles/profile-storage-paths.ts | 33 +- src/main/orcad/orcad-entry.test.ts | 36 + src/main/orcad/orcad-entry.ts | 65 +- src/main/orcad/orcad-health.test.ts | 18 + src/main/orcad/orcad-health.ts | 11 +- src/main/orcad/orcad-launch-contract.test.ts | 4 + src/main/orcad/orcad-lifecycle.ts | 11 + .../orcad/orcad-profile-state-startup.test.ts | 77 ++ src/main/orcad/orcad-profile-state-startup.ts | 50 + .../orcad-profile-state-telemetry.test.ts | 46 + .../orcad/orcad-profile-state-telemetry.ts | 47 + src/main/orcad/orcad-push-startup.test.ts | 27 +- .../feature-interaction-recording.ts | 4 +- .../applying-settings/ui-state-update.ts | 7 +- .../loading-store/automation-persistence.ts | 44 +- .../loading-store/loaded-state-parsing.ts | 67 +- .../metadata-lineage-operations.ts | 11 +- .../primary-state-write-context.ts | 9 + .../primary-state-write-runtime.ts | 47 + .../loading-store/primary-state-write-sync.ts | 91 ++ .../loading-store/primary-state-writes.ts | 233 ++--- .../loading-store/profile-preferences.ts | 4 +- .../profile-state-authority-writes.ts | 59 ++ .../loading-store/profile-state-authority.ts | 80 ++ .../profile-state-checkpoints.test.ts | 230 +++++ .../profile-state-direct-flush.test.ts | 188 ++++ .../profile-state-selective-write.ts | 73 ++ .../profile-state-settings-writes.test.ts | 320 +++++++ .../profile-state-sqlite-authority.test.ts | 857 ++++++++++++++++++ .../profile-state-store-backups.test.ts | 215 +++++ .../profile-state-update-quit.test.ts | 235 +++++ .../loading-store/pty-binding-persistence.ts | 10 + .../secret-sentinel-substitution.test.ts | 67 +- .../loading-store/session-host-partitions.ts | 9 +- .../session-snapshot-operations.ts | 5 +- .../ssh-lease-recovery-operations.ts | 31 +- .../loading-store/ssh-profile-operations.ts | 12 +- .../state-serialization-secret-handling.ts | 141 ++- .../loading-store/store-runtime-state.ts | 8 + src/main/persistence/loading-store/store.ts | 160 +++- .../workspace-session-snapshot-publication.ts | 14 +- .../worktree-identity-metadata.ts | 11 +- .../loading-store/write-flush-barriers.ts | 41 +- .../loading-store/write-scheduling.ts | 14 +- .../profile-state-cutover-fixture.test.ts | 164 ++++ .../profile-state-cutover-fixture.ts | 291 ++++++ .../profile-state-cutover-soak.test.ts | 350 +++++++ .../profile-state-access-owner.ts | 239 +++++ .../profile-state-access-process.test.ts | 150 +++ .../profile-state-access.test.ts | 256 ++++++ .../profile-state/profile-state-access.ts | 99 ++ .../profile-state-active-location.ts | 49 + .../profile-state-authority-bootstrap.test.ts | 478 ++++++++++ .../profile-state-authority-bootstrap.ts | 143 +++ ...ile-state-automation-runs-equality.test.ts | 126 +++ ...profile-state-automation-runs-migration.ts | 46 + .../profile-state-automation-runs-model.ts | 55 ++ .../profile-state-automation-runs-payload.ts | 70 ++ .../profile-state-automation-runs-reader.ts | 128 +++ ...tate-automation-runs-serialization.test.ts | 65 ++ .../profile-state-automation-runs-storage.ts | 49 + ...rofile-state-automation-runs-validation.ts | 154 ++++ .../profile-state-automation-runs-writer.ts | 170 ++++ .../profile-state-automation-runs.ts | 44 + ...e-state-automation-storage-upgrade.test.ts | 380 ++++++++ .../profile-state/profile-state-backup-job.ts | 31 + .../profile-state-backup-path.test.ts | 76 ++ .../profile-state-backup-path.ts | 67 ++ .../profile-state-backup-rotation.test.ts | 228 +++++ .../profile-state-backup-rotation.ts | 128 +++ .../profile-state-backup-worker-entry.ts | 28 + .../profile-state-backup-worker.test.ts | 203 +++++ .../profile-state-backup-worker.ts | 64 ++ ...e-state-bootstrap-publication-race.test.ts | 91 ++ ...ofile-state-complete-domain-writes.test.ts | 121 +++ .../profile-state-crash-recovery.test.ts | 210 +++++ .../profile-state-database-errors.ts | 16 + ...rofile-state-database-export-crash.test.ts | 112 +++ .../profile-state-database-quarantine.ts | 79 ++ .../profile-state-database-recovery.test.ts | 275 ++++++ .../profile-state-database-recovery.ts | 112 +++ ...ile-state-database-rollback-export.test.ts | 157 ++++ .../profile-state-database-schema.ts | 23 + .../profile-state-database-snapshot.test.ts | 314 +++++++ .../profile-state-database-snapshot.ts | 114 +++ .../profile-state-database-validation.ts | 146 +++ .../profile-state-database.test.ts | 339 +++++++ .../profile-state/profile-state-database.ts | 251 +++++ .../profile-state-document-reader.ts | 78 ++ .../profile-state-document-validation.ts | 116 +++ .../profile-state-documents.test.ts | 370 ++++++++ .../profile-state/profile-state-documents.ts | 223 +++++ .../profile-state-domain-equality.test.ts | 128 +++ .../profile-state-domain-reader.test.ts | 122 +++ .../profile-state-domain-reader.ts | 110 +++ .../profile-state-domain-write-validation.ts | 72 ++ .../profile-state-domain-writes.test.ts | 585 ++++++++++++ .../profile-state-domain-writes.ts | 232 +++++ .../profile-state-export-path.ts | 36 + .../profile-state-fragment-validation.test.ts | 128 +++ .../profile-state-json-acceptance.ts | 153 ++++ ...-state-json-compatibility-recovery.test.ts | 218 +++++ .../profile-state/profile-state-migration.ts | 87 ++ .../profile-state-offline-settings.test.ts | 123 +++ .../profile-state-offline-settings.ts | 225 +++++ .../profile-state-parsed-snapshot.test.ts | 155 ++++ .../profile-state-read-concurrency.test.ts | 168 ++++ .../profile-state-read-snapshot.ts | 25 + .../profile-state-recovery-command.ts | 136 +++ ...le-state-recovery-crash-boundaries.test.ts | 378 ++++++++ .../profile-state-recovery-crash-process.ts | 170 ++++ .../profile-state-recovery-required.ts | 60 ++ .../profile-state/profile-state-recovery.ts | 85 ++ .../profile-state/profile-state-revision.ts | 31 + .../profile-state-sqlite-authority.ts | 337 +++++++ .../profile-state-startup-authority.test.ts | 289 ++++++ .../profile-state-startup-authority.ts | 62 ++ .../profile-state-startup-failure.test.ts | 60 ++ .../profile-state-startup-failure.ts | 82 ++ ...file-state-startup-recovery-dialog.test.ts | 64 ++ .../profile-state-startup-recovery-dialog.ts | 30 + .../profile-state-startup-secrets.test.ts | 184 ++++ .../profile-state-startup-snapshot.test.ts | 310 +++++++ .../profile-state-storage-classification.ts | 32 + .../profile-state-store-factory.test.ts | 413 +++++++++ .../profile-state-store-factory.ts | 126 +++ .../profile-state-write-transaction.test.ts | 44 + .../profile-state-write-transaction.ts | 21 + .../automation-definition-operations.ts | 3 + .../automation-run-operations.ts | 5 + src/main/protected-secret-persistence.test.ts | 31 + src/main/protected-secret-persistence.ts | 17 +- .../mobile-session-terminal-retirement.ts | 10 +- src/main/runtime/orca-runtime-get-status.ts | 16 +- src/main/sqlite/sync-database.ts | 26 +- src/main/ssh/orcad-remote-deploy-stop.ts | 68 ++ src/main/ssh/orcad-remote-deploy.test.ts | 85 +- src/main/ssh/orcad-remote-deploy.ts | 146 ++- src/main/ssh/orcad-remote-host-support.ts | 12 +- src/main/ssh/orcad-remote-launch.test.ts | 8 +- src/main/ssh/orcad-remote-launch.ts | 3 +- src/main/ssh/orcad-remote-process-control.ts | 28 +- src/main/ssh/orcad-remote-rollback.ts | 11 +- ...-remote-shell-commands.integration.test.ts | 312 ++++++- src/main/ssh/orcad-state-snapshot.test.ts | 2 + src/main/ssh/orcad-state-snapshot.ts | 54 +- ...rowser-process-user-agent-ordering.test.ts | 32 +- src/main/startup/cli-command-names.ts | 1 + .../configure-process-profile-state.test.ts | 46 + src/main/startup/configure-process.test.ts | 52 +- src/main/startup/configure-process.ts | 40 +- .../startup/desktop-startup-ordering.test.ts | 26 + .../headless-pty-hydration-ordering.test.ts | 4 +- .../startup/http1-compatibility-marker.ts | 56 +- .../http1-compatibility-profile-state.test.ts | 141 +++ .../http1-compatibility-profile-state.ts | 122 +++ src/main/startup/main-process-observers.ts | 10 + src/main/startup/main-process-preflight.ts | 18 + src/main/startup/main-process-quit.ts | 4 +- .../startup/main-process-ready-foundation.ts | 27 +- .../main-process-ready-identity-write.test.ts | 11 + src/main/startup/main-process-state.ts | 15 + .../startup/main-window-core-services.test.ts | 117 +++ src/main/startup/main-window-core-services.ts | 8 +- .../profile-state-recovery-preflight.test.ts | 269 ++++++ .../profile-state-recovery-preflight.ts | 76 ++ src/main/updater.quit-and-install.test.ts | 78 ++ src/main/updater.ts | 4 +- src/main/updater/updater-install-support.ts | 45 +- src/main/updater/updater-setup.ts | 4 +- src/main/updater/updater-state.ts | 2 + .../attach-main-window-services.test.ts | 35 +- .../window/attach-main-window-services.ts | 3 +- .../history-gc-profile-worktree-ids.test.ts | 152 +++- .../window/history-gc-profile-worktree-ids.ts | 107 ++- src/main/window/main-window-updater.ts | 3 + src/main/worker-thread-entry-path.ts | 5 +- src/shared/profile-state-recovery-command.ts | 70 ++ src/shared/profile-state-storage-paths.ts | 16 + .../profile-state-telemetry-schema.test.ts | 29 + src/shared/telemetry-daemon-event-schemas.ts | 13 + src/shared/telemetry-event-registry.ts | 2 + src/shared/telemetry-events.ts | 1 + src/shared/uuid-v4.test.ts | 25 + src/shared/uuid-v4.ts | 1 + ...ent-session-live-force-exit-resume.spec.ts | 63 +- tests/e2e/agent-session-quit-resume.spec.ts | 54 +- tests/e2e/finished-agent-ghost-resume.spec.ts | 49 +- .../headless-serve-desktop-activation.spec.ts | 10 +- .../completed-worker-retirement-fixture.ts | 14 +- tests/e2e/helpers/electron-launch-args.ts | 10 +- .../helpers/electron-launch-args.unit.test.ts | 16 +- .../e2e/helpers/electron-process-shutdown.ts | 21 +- tests/e2e/helpers/orca-restart.ts | 14 +- tests/e2e/helpers/persisted-profile-state.ts | 60 ++ .../persisted-profile-state.unit.test.ts | 71 ++ .../helpers/terminal-restart-persistence.ts | 186 ++++ ...y-worker-missing-terminal-recovery.spec.ts | 9 +- ...ion-legacy-worker-restart-recovery.spec.ts | 84 +- ...-client-hosted-browser-ghost-close.spec.ts | 94 +- ...ote-terminal-serve-restart-binding.spec.ts | 113 ++- ...rsisted-session-production-upgrade.spec.ts | 449 ++++++++- ...le-state-automatic-backup-recovery.spec.ts | 187 ++++ ...state-terminal-restart-persistence.spec.ts | 566 ++++++++++++ ...ettled-worker-tab-survives-restart.spec.ts | 26 +- ...ssh-docker-transport-drop-recovery.spec.ts | 13 +- ...inal-duplicate-pty-renderer-reveal.spec.ts | 62 +- .../e2e/terminal-restart-persistence.spec.ts | 186 +--- ...kspace-session-corrupt-tab-salvage.spec.ts | 46 +- 253 files changed, 23997 insertions(+), 1088 deletions(-) create mode 100644 src/cli/handlers/profile-state-recovery-admission.test.ts create mode 100644 src/cli/handlers/profile-state.test.ts create mode 100644 src/cli/handlers/profile-state.ts create mode 100644 src/cli/profile-state-location.ts create mode 100644 src/cli/runtime/profile-state-recovery-launch.test.ts create mode 100644 src/cli/runtime/profile-state-recovery-launch.ts create mode 100644 src/cli/specs/profile-state.ts create mode 100644 src/main/ipc/orca-profile-project-transfer-args.ts create mode 100644 src/main/orca-profiles/profile-active-transfer.test.ts create mode 100644 src/main/orca-profiles/profile-active-transfer.ts create mode 100644 src/main/orca-profiles/profile-legacy-state-import.ts create mode 100644 src/main/orca-profiles/profile-project-move-intent.ts create mode 100644 src/main/orca-profiles/profile-project-transfer-migration.test.ts create mode 100644 src/main/orca-profiles/profile-project-transfer-migration.ts create mode 100644 src/main/orcad/orcad-entry.test.ts create mode 100644 src/main/orcad/orcad-profile-state-startup.test.ts create mode 100644 src/main/orcad/orcad-profile-state-startup.ts create mode 100644 src/main/orcad/orcad-profile-state-telemetry.test.ts create mode 100644 src/main/orcad/orcad-profile-state-telemetry.ts create mode 100644 src/main/persistence/loading-store/primary-state-write-context.ts create mode 100644 src/main/persistence/loading-store/primary-state-write-runtime.ts create mode 100644 src/main/persistence/loading-store/primary-state-write-sync.ts create mode 100644 src/main/persistence/loading-store/profile-state-authority-writes.ts create mode 100644 src/main/persistence/loading-store/profile-state-authority.ts create mode 100644 src/main/persistence/loading-store/profile-state-checkpoints.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-direct-flush.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-selective-write.ts create mode 100644 src/main/persistence/loading-store/profile-state-settings-writes.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-store-backups.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-update-quit.test.ts create mode 100644 src/main/persistence/profile-state-cutover-fixture.test.ts create mode 100644 src/main/persistence/profile-state-cutover-fixture.ts create mode 100644 src/main/persistence/profile-state-cutover-soak.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-access-owner.ts create mode 100644 src/main/persistence/profile-state/profile-state-access-process.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-access.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-access.ts create mode 100644 src/main/persistence/profile-state/profile-state-active-location.ts create mode 100644 src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-authority-bootstrap.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-migration.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-model.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-payload.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-reader.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-storage.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-validation.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-writer.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-job.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-path.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-path.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-rotation.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-rotation.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-worker-entry.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-worker.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-worker.ts create mode 100644 src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-crash-recovery.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-errors.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-export-crash.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-quarantine.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-recovery.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-recovery.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-schema.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-snapshot.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-snapshot.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-validation.ts create mode 100644 src/main/persistence/profile-state/profile-state-database.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database.ts create mode 100644 src/main/persistence/profile-state/profile-state-document-reader.ts create mode 100644 src/main/persistence/profile-state/profile-state-document-validation.ts create mode 100644 src/main/persistence/profile-state/profile-state-documents.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-documents.ts create mode 100644 src/main/persistence/profile-state/profile-state-domain-equality.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-domain-reader.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-domain-reader.ts create mode 100644 src/main/persistence/profile-state/profile-state-domain-write-validation.ts create mode 100644 src/main/persistence/profile-state/profile-state-domain-writes.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-domain-writes.ts create mode 100644 src/main/persistence/profile-state/profile-state-export-path.ts create mode 100644 src/main/persistence/profile-state/profile-state-fragment-validation.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-json-acceptance.ts create mode 100644 src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-migration.ts create mode 100644 src/main/persistence/profile-state/profile-state-offline-settings.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-offline-settings.ts create mode 100644 src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-read-concurrency.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-read-snapshot.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-command.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-crash-process.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-required.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery.ts create mode 100644 src/main/persistence/profile-state/profile-state-revision.ts create mode 100644 src/main/persistence/profile-state/profile-state-sqlite-authority.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-authority.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-authority.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-failure.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-failure.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-secrets.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-storage-classification.ts create mode 100644 src/main/persistence/profile-state/profile-state-store-factory.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-store-factory.ts create mode 100644 src/main/persistence/profile-state/profile-state-write-transaction.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-write-transaction.ts create mode 100644 src/main/ssh/orcad-remote-deploy-stop.ts create mode 100644 src/main/startup/configure-process-profile-state.test.ts create mode 100644 src/main/startup/http1-compatibility-profile-state.test.ts create mode 100644 src/main/startup/http1-compatibility-profile-state.ts create mode 100644 src/main/startup/main-window-core-services.test.ts create mode 100644 src/main/startup/profile-state-recovery-preflight.test.ts create mode 100644 src/main/startup/profile-state-recovery-preflight.ts create mode 100644 src/shared/profile-state-recovery-command.ts create mode 100644 src/shared/profile-state-storage-paths.ts create mode 100644 src/shared/profile-state-telemetry-schema.test.ts create mode 100644 src/shared/uuid-v4.test.ts create mode 100644 src/shared/uuid-v4.ts create mode 100644 tests/e2e/helpers/persisted-profile-state.ts create mode 100644 tests/e2e/helpers/persisted-profile-state.unit.test.ts create mode 100644 tests/e2e/helpers/terminal-restart-persistence.ts create mode 100644 tests/e2e/profile-state-automatic-backup-recovery.spec.ts create mode 100644 tests/e2e/profile-state-terminal-restart-persistence.spec.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 7dc017b9c981..234fc9877ad6 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -41,7 +41,8 @@ const WORKER_THREAD_ENTRY_NAMES = [ 'session-scanner-worker-entry', 'main-thread-hang-watchdog-entry', 'port-scan-command-worker-entry', - 'usage-scan-worker-entry' + 'usage-scan-worker-entry', + 'profile-state-backup-worker-entry' ] as const export const GUARDED_ENTRY_NAMES = [ diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 84c31376833a..abf942960354 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -281,6 +281,8 @@ module.exports = { 'out/main/gemini/**', 'out/main/grok/**', 'out/main/hermes/**', + 'out/main/persistence/profile-state/**', + 'out/main/startup/http1-compatibility-marker.js', 'out/main/daemon-entry.js', 'out/main/session-scanner-service-entry.js', 'out/main/wsl-transcript-fs-process-entry.js', diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 99a53fa09815..a0489ba21ff9 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -295,7 +295,10 @@ async function smokeLoadWatcherChild() { resolve(failure) } child.on('message', (message) => { - if (message?.op === 'subscribe-started') { + // Wait until the subscribe lifecycle has sent its final acknowledgement. + // Disconnecting on subscribe-started races the subsequent subscribed or + // subscribe-failed message and makes the child report an expected EPIPE. + if (message?.op === 'subscribed' || message?.op === 'subscribe-failed') { child.disconnect() } }) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 285b58f2ac76..fe6108a6ff11 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -73,9 +73,7 @@ function failBootstrapWithBanner(options: { return processMock } -const electronBuilderConfig = createRequire(import.meta.url)('../electron-builder.config.cjs') as { - files: string[] -} +const electronBuilderConfig = createRequire(import.meta.url)('../electron-builder.config.cjs') describe('Electron Vite output contract', () => { it("minifies main and renderer with rolldown's in-process minifier", () => { @@ -104,6 +102,33 @@ describe('Electron Vite output contract', () => { expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js') }) + it('keeps offline profile-state CLI imports unpacked at stable paths', () => { + const input = electronViteConfig.main?.build?.rollupOptions?.input + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Expected named main-process inputs') + } + + for (const name of [ + 'persistence/profile-state/profile-state-access', + 'persistence/profile-state/profile-state-active-location', + 'persistence/profile-state/profile-state-backup-path', + 'persistence/profile-state/profile-state-database-recovery', + 'persistence/profile-state/profile-state-domain-reader', + 'persistence/profile-state/profile-state-export-path', + 'persistence/profile-state/profile-state-offline-settings', + 'persistence/profile-state/profile-state-recovery', + 'persistence/profile-state/profile-state-recovery-command', + 'persistence/profile-state/profile-state-storage-classification', + 'startup/http1-compatibility-marker' + ]) { + expect(input).toHaveProperty(name) + } + expect(electronBuilderConfig.asarUnpack).toContain('out/main/persistence/profile-state/**') + expect(electronBuilderConfig.asarUnpack).toContain( + 'out/main/startup/http1-compatibility-marker.js' + ) + }) + it('externalizes packaged dependencies but bundles self-contained main dependencies', () => { const external = electronViteConfig.main?.build?.rollupOptions?.external if (typeof external !== 'function') { diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f9bcf82c52dd..7e6d67e3e1b0 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -20,6 +20,54 @@ "../src/main/agent-hooks/managed-hook-script-refresh.ts", "../src/main/agent-hooks/posix-hook-command.ts", "../src/main/agent-hooks/runtime-home-hook-command.ts", + "../src/main/orca-profiles/profile-storage-paths.ts", + "../src/main/persistence/profile-state/profile-state-database.ts", + "../src/main/persistence/profile-state/profile-state-database-errors.ts", + "../src/main/persistence/profile-state/profile-state-database-validation.ts", + "../src/main/persistence/profile-state/profile-state-database-schema.ts", + "../src/main/persistence/profile-state/profile-state-documents.ts", + "../src/main/persistence/profile-state/profile-state-json-acceptance.ts", + "../src/main/persistence/profile-state/profile-state-revision.ts", + "../src/main/persistence/profile-state/profile-state-read-snapshot.ts", + "../src/main/persistence/profile-state/profile-state-sqlite-authority.ts", + "../src/main/persistence/loading-store/profile-state-authority.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-migration.ts", + "../src/main/persistence/profile-state/profile-state-document-reader.ts", + "../src/main/persistence/profile-state/profile-state-document-validation.ts", + "../src/main/persistence/profile-state/profile-state-domain-writes.ts", + "../src/main/persistence/profile-state/profile-state-write-transaction.ts", + "../src/main/persistence/profile-state/profile-state-domain-write-validation.ts", + "../src/main/persistence/profile-state/profile-state-domain-reader.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-model.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-payload.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-reader.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-storage.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-validation.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-writer.ts", + "../src/main/persistence/profile-state/profile-state-offline-settings.ts", + "../src/main/persistence/profile-state/profile-state-export-path.ts", + "../src/main/persistence/profile-state/profile-state-backup-path.ts", + "../src/main/persistence/profile-state/profile-state-backup-rotation.ts", + "../src/main/persistence/profile-state/profile-state-backup-job.ts", + "../src/main/persistence/profile-state/profile-state-backup-worker.ts", + "../src/main/persistence/profile-state/profile-state-backup-worker-entry.ts", + "../src/main/worker-thread-entry-path.ts", + "../src/main/persistence/profile-state/profile-state-database-snapshot.ts", + "../src/main/persistence/profile-state/profile-state-database-recovery.ts", + "../src/main/persistence/profile-state/profile-state-recovery-required.ts", + "../src/main/persistence/profile-state/profile-state-recovery.ts", + "../src/main/persistence/profile-state/profile-state-recovery-command.ts", + "../src/main/persistence/profile-state/profile-state-active-location.ts", + "../src/main/persistence/profile-state/profile-state-access.ts", + "../src/main/persistence/profile-state/profile-state-access-owner.ts", + "../src/main/persistence/profile-state/profile-state-database-quarantine.ts", + "../src/main/persistence/profile-state/profile-state-storage-classification.ts", + "../src/main/durable-file-write.ts", + "../src/shared/secure-file.ts", + "../src/main/sqlite/harden-database-files.ts", + "../src/main/startup/http1-compatibility-marker.ts", + "../src/main/startup/http1-compatibility-profile-state.ts", "../src/main/agent-hooks/windows-direct-cmd-hook-command.ts", "../src/main/agent-hooks/windows-powershell-hook-launcher.ts", "../src/main/amp/agent-status-plugin-source.ts", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 3cd58f4a25f5..db2a9530562d 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -246,6 +246,9 @@ export const electronViteConfig: UserConfig = { // corpora and read SQLite synchronously; a worker thread keeps that // off the main-process event loop. 'usage-scan-worker-entry': resolve('src/main/usage/usage-scan-worker-entry.ts'), + 'profile-state-backup-worker-entry': resolve( + 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts' + ), // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults // can't take down the main process (issue #7547). 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), @@ -263,7 +266,29 @@ export const electronViteConfig: UserConfig = { 'src/main/codex/managed-home-shell-preflight.ts' ), // Why: account import mutates the user's macOS Keychain from the CLI. - 'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts') + 'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts'), + // Why: the dev CLI's offline profile-state commands load these paths after + // electron-vite cleans out/main; keep them as stable sidecar entries. + ...Object.fromEntries( + [ + 'access', + 'active-location', + 'storage-classification', + 'offline-settings', + 'export-path', + 'backup-path', + 'database-recovery', + 'domain-reader', + 'recovery', + 'recovery-command' + ].map((module) => [ + `persistence/profile-state/profile-state-${module}`, + resolve(`src/main/persistence/profile-state/profile-state-${module}.ts`) + ]) + ), + 'startup/http1-compatibility-marker': resolve( + 'src/main/startup/http1-compatibility-marker.ts' + ) }, // Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers // consume these stable CommonJS paths. diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index bda6799a8bca..bf1f1e313d1e 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -183,6 +183,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ keys: ['agent hooks prepare-codex', 'agent hooks status', 'agent hooks off', 'agent hooks on'], load: async () => (await import('./handlers/agent-hooks.js')).AGENT_HOOK_HANDLERS }, + { + name: 'profile-state', + keys: ['profile state exports', 'profile state rollback'], + load: async () => (await import('./handlers/profile-state.js')).PROFILE_STATE_HANDLERS + }, { name: 'diagnostics', keys: ['diagnostics memory'], diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index 279a8900bec9..1d883a651453 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -1,9 +1,28 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import * as fs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultPersistedState } from '../../shared/constants' import type { PersistedState } from '../../shared/persisted-state-types' +import { + getOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile +} from '../../main/orca-profiles/profile-storage-paths' +import { openProfileStateDatabase } from '../../main/persistence/profile-state/profile-state-database' +import { + exportProfileStateJson, + hashProfileStateJson, + importProfileStateJson +} from '../../main/persistence/profile-state/profile-state-documents' +import { ProfileStateSqliteAuthority } from '../../main/persistence/profile-state/profile-state-sqlite-authority' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission, + type ProfileStateRuntimeAdmission +} from '../../main/persistence/profile-state/profile-state-access' + +vi.mock('node:fs', async (original) => ({ ...(await original()) })) const { applyAgentStatusHooksEnabledMock, @@ -74,6 +93,14 @@ function writeDataFile(userDataPath: string, state: PersistedState): void { writeFileSync(join(userDataPath, 'orca-data.json'), JSON.stringify(state, null, 2), 'utf-8') } +function writeActiveProfileIndex(userDataPath: string, profileId: string): void { + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }), + 'utf-8' + ) +} + async function runAgentHooksOff(userDataPath: string): Promise { getDefaultUserDataPathMock.mockReturnValue(userDataPath) await main(['agent', 'hooks', 'off', '--json'], userDataPath) @@ -84,7 +111,7 @@ describe('agent hooks CLI handler', () => { beforeEach(() => { userDataPath = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-cli-')) - applyAgentStatusHooksEnabledMock.mockReturnValue([]) + applyAgentStatusHooksEnabledMock.mockReset().mockReturnValue([]) callMock.mockReset() getCliStatusMock.mockClear() getManagedAgentHookStatusesMock.mockReturnValue([]) @@ -109,6 +136,98 @@ describe('agent hooks CLI handler', () => { expect(persisted.settings.agentStatusHooksEnabled).toBe(false) }) + it.each(['root-json', 'profile-json', 'sqlite'] as const)( + 'refuses offline %s mutation when startup wins after the stopped-status response', + async (backend) => { + const profileId = 'startup-race' + const directory = + backend === 'root-json' ? userDataPath : join(userDataPath, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + const dataFile = join(directory, 'orca-data.json') + const raw = JSON.stringify({ + settings: { agentStatusHooksEnabled: true }, + unknown: { retained: null } + }) + writeFileSync(dataFile, raw) + if (backend !== 'root-json') { + writeActiveProfileIndex(userDataPath, profileId) + } + const databaseFile = join(directory, 'profile-state.db') + if (backend === 'sqlite') { + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + importProfileStateJson(opened.db, raw, { + acceptedLegacyJsonHash: hashProfileStateJson(raw) + }) + } finally { + opened.db.close() + } + } + const stopped = await getCliStatusMock() + let runtime: ProfileStateRuntimeAdmission | undefined + getCliStatusMock.mockImplementationOnce(async () => { + runtime = acquireProfileStateRuntimeAdmission(userDataPath) + return stopped + }) + try { + await runAgentHooksOff(userDataPath) + expect(process.exitCode).toBe(1) + expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled() + expect(readFileSync(dataFile, 'utf8')).toBe(raw) + if (backend === 'sqlite') { + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + expect(JSON.parse(exportProfileStateJson(opened.db))).toEqual(JSON.parse(raw)) + } finally { + opened.db.close() + } + } + } finally { + runtime?.release() + } + process.exitCode = undefined + await runAgentHooksOff(userDataPath) + expect(process.exitCode).not.toBe(1) + expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledOnce() + } + ) + + it.each(['root-json', 'profile-json'] as const)( + 'excludes startup and other offline writers through %s publication', + async (backend) => { + const profileId = 'offline-first' + const directory = + backend === 'root-json' ? userDataPath : join(userDataPath, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + if (backend === 'profile-json') { + writeActiveProfileIndex(userDataPath, profileId) + } + const dataFile = join(directory, 'orca-data.json') + writeFileSync(dataFile, JSON.stringify({ settings: { agentStatusHooksEnabled: true } })) + const rename = fs.renameSync + let checkedPublication = false + vi.spyOn(fs, 'renameSync').mockImplementation((source, target) => { + if (target === dataFile) { + checkedPublication = true + expect(() => acquireProfileStateRuntimeAdmission(userDataPath)).toThrow() + expect(() => acquireProfileStateMaintenance(userDataPath)).toThrow() + } + return rename(source, target) + }) + await runAgentHooksOff(userDataPath) + expect(checkedPublication).toBe(true) + expect(process.exitCode).not.toBe(1) + const runtime = acquireProfileStateRuntimeAdmission(userDataPath) + try { + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.agentStatusHooksEnabled).toBe( + false + ) + } finally { + runtime.release() + } + } + ) + it('keeps missing new card style off when updating offline settings', async () => { const existing = getDefaultPersistedState(userDataPath) delete existing.settings.experimentalNewWorktreeCardStyle @@ -129,6 +248,31 @@ describe('agent hooks CLI handler', () => { expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) }) + it.each(['on', 'off', 'status', 'prepare-codex'])( + 'refuses explicit remote selection before local hook command %s', + async (command) => { + const state = getDefaultPersistedState(userDataPath) + writeDataFile(userDataPath, state) + const before = readFileSync(join(userDataPath, 'orca-data.json'), 'utf8') + getDefaultUserDataPathMock.mockReturnValue(userDataPath) + + for (const selector of ['environment', 'pairing-code']) { + process.exitCode = undefined + await main( + ['agent', 'hooks', command, `--${selector}`, 'unreachable-host', '--json'], + userDataPath + ) + + expect(process.exitCode).toBe(1) + expect(getCliStatusMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled() + expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled() + expect(readFileSync(join(userDataPath, 'orca-data.json'), 'utf8')).toBe(before) + } + } + ) + it('prepares managed Codex trust with the current hooks setting', async () => { const state = getDefaultPersistedState(userDataPath) state.settings.agentStatusHooksEnabled = false @@ -231,4 +375,146 @@ describe('agent hooks CLI handler', () => { timeoutMs: 1_000 }) }) + + it('updates an established SQLite profile without rewriting its JSON export', async () => { + const profileId = 'work-profile' + const profileDirectory = join(userDataPath, 'profiles', profileId) + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + const raw = JSON.stringify({ + settings: { + agentStatusHooksEnabled: true, + disabledTuiAgents: ['codex'], + opencodeSessionCookie: 'encrypted-ciphertext' + }, + unknownDomain: { preserved: true } + }) + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync(dataFile, raw, 'utf-8') + writeActiveProfileIndex(userDataPath, profileId) + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + importProfileStateJson(opened.db, raw, { + acceptedLegacyJsonHash: hashProfileStateJson(raw) + }) + } finally { + opened.db.close() + } + + await runAgentHooksOff(userDataPath) + + expect(readFileSync(dataFile, 'utf-8')).toBe(raw) + const readBack = openProfileStateDatabase(databaseFile, profileId) + try { + expect(JSON.parse(exportProfileStateJson(readBack.db))).toMatchObject({ + settings: { + agentStatusHooksEnabled: false, + opencodeSessionCookie: 'encrypted-ciphertext', + disabledTuiAgents: ['codex'] + }, + unknownDomain: { preserved: true } + }) + } finally { + readBack.db.close() + } + }) + + it.each(['update-failed', 'unreachable', 'status-failed'] as const)( + 'preserves a live SQLite writer when runtime contact is %s', + async (failure) => { + const profileId = 'live-profile' + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + writeActiveProfileIndex(userDataPath, profileId) + const authority = new ProfileStateSqliteAuthority( + getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId + ) + authority.writeSerializedState( + Buffer.from(JSON.stringify({ settings: { agentStatusHooksEnabled: true } })) + ) + if (failure === 'status-failed') { + getCliStatusMock.mockRejectedValueOnce(new Error('status transport unavailable')) + } else { + getCliStatusMock.mockResolvedValueOnce({ + id: 'test-status', + ok: true, + result: { + app: { running: true, pid: null }, + runtime: { + state: failure === 'unreachable' ? 'starting' : 'ready', + reachable: failure !== 'unreachable', + runtimeId: null + }, + graph: { state: 'ready' } + }, + _meta: { runtimeId: 'test' } + }) + callMock.mockRejectedValueOnce(new Error('settings request timed out')) + } + try { + await runAgentHooksOff(userDataPath) + + expect(process.exitCode).toBe(1) + expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled() + expect(() => + authority.writeSerializedDomains([ + { domain: 'ui', payload: '{"marker":"still-writable"}' } + ]) + ).not.toThrow() + const persisted = JSON.parse(authority.readSerializedState() ?? '{}') + expect(persisted).toMatchObject({ + settings: { agentStatusHooksEnabled: true }, + ui: { marker: 'still-writable' } + }) + } finally { + authority.close() + } + } + ) + + it('keeps a JSON-only active profile on the legacy path without creating SQLite', async () => { + const profileId = 'json-profile' + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + writeDataFile(profileDirectory, getDefaultPersistedState(userDataPath)) + writeActiveProfileIndex(userDataPath, profileId) + + await runAgentHooksOff(userDataPath) + + expect(existsSync(getOrcaProfileStateDatabaseFile(profileId, userDataPath))).toBe(false) + expect( + JSON.parse(readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf-8')).settings + .agentStatusHooksEnabled + ).toBe(false) + }) + + it('fails closed when a profile has corrupt SQLite alongside legacy JSON', async () => { + const profileId = 'corrupt-profile' + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + const state = getDefaultPersistedState(userDataPath) + writeDataFile(profileDirectory, state) + writeActiveProfileIndex(userDataPath, profileId) + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + writeFileSync(databaseFile, 'not sqlite', 'utf-8') + const before = readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf-8') + + await runAgentHooksOff(userDataPath) + + expect(process.exitCode).toBe(1) + expect(readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf-8')).toBe(before) + }) + + it('fails closed when a profile index is present but unreadable', async () => { + const legacy = getDefaultPersistedState(userDataPath) + writeDataFile(userDataPath, legacy) + writeFileSync(join(userDataPath, 'orca-profile-index.json'), '{ torn', 'utf-8') + const before = readFileSync(join(userDataPath, 'orca-data.json'), 'utf-8') + + await runAgentHooksOff(userDataPath) + + expect(process.exitCode).toBe(1) + expect(readFileSync(join(userDataPath, 'orca-data.json'), 'utf-8')).toBe(before) + }) }) diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 4fcfe64f9b7d..ed17728c64a7 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import type { CommandHandler } from '../dispatch' import { printResult } from '../format' +import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { RuntimeClientError, type RuntimeClient, @@ -16,6 +17,16 @@ import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' import type { PersistedState } from '../../shared/persisted-state-types' import { prepareManagedCodexHomeBeforeShellLaunch } from '../../main/codex/managed-home-shell-preflight' +import { + readAgentHookSettingsFromProfileState, + updateAgentHookSettingsFromProfileState, + type ProfileStateOfflineLocation +} from '../../main/persistence/profile-state/profile-state-offline-settings' +import { getActiveProfileStateLocation } from '../profile-state-location' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from '../../main/persistence/profile-state/profile-state-access' type AgentHookCommandResult = { enabled: boolean @@ -28,27 +39,11 @@ type AgentHookCommandResult = { const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000 function getDataPath(): string { - const userDataPath = getDefaultUserDataPath() - const indexPath = join(userDataPath, 'orca-profile-index.json') - for (const candidate of [indexPath, `${indexPath}.bak`]) { - try { - const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf-8')) - if (!isRecord(parsed) || !Array.isArray(parsed.profiles)) { - continue - } - const profileId = parsed.activeProfileId - if ( - typeof profileId === 'string' && - /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(profileId) && - parsed.profiles.some((profile) => isRecord(profile) && profile.id === profileId) - ) { - return join(userDataPath, 'profiles', profileId, 'orca-data.json') - } - } catch { - // Try the profile-index backup, then the legacy pre-profile path. - } - } - return join(userDataPath, 'orca-data.json') + return getProfileStateLocation()?.dataFile ?? join(getDefaultUserDataPath(), 'orca-data.json') +} + +function getProfileStateLocation(): ProfileStateOfflineLocation | undefined { + return getActiveProfileStateLocation() } function isRecord(value: unknown): value is Record { @@ -96,6 +91,22 @@ function readHookSettingsFromDisk(): Pick< GlobalSettings, 'agentStatusHooksEnabled' | 'disabledTuiAgents' > { + const admission = acquireProfileStateRuntimeAdmission(getDefaultUserDataPath()) + try { + return readAdmittedHookSettingsFromDisk() + } finally { + admission.release() + } +} + +function readAdmittedHookSettingsFromDisk(): Pick< + GlobalSettings, + 'agentStatusHooksEnabled' | 'disabledTuiAgents' +> { + const profileStateLocation = getProfileStateLocation() + if (profileStateLocation) { + return readAgentHookSettingsFromProfileState(profileStateLocation) + } const state = readPersistedState(getDataPath()) return { agentStatusHooksEnabled: state.settings?.agentStatusHooksEnabled !== false, @@ -127,6 +138,23 @@ function updateEnabledOnDisk(enabled: boolean): { settingsPath: string settings: Pick } { + // A stopped-status response cannot exclude first migration racing this JSON write. + const maintenance = acquireProfileStateMaintenance(getDefaultUserDataPath()) + try { + return updateAdmittedEnabledOnDisk(enabled) + } finally { + maintenance.release() + } +} + +function updateAdmittedEnabledOnDisk(enabled: boolean): { + settingsPath: string + settings: Pick +} { + const profileStateLocation = getProfileStateLocation() + if (profileStateLocation) { + return updateAgentHookSettingsFromProfileState(profileStateLocation, enabled) + } const dataPath = getDataPath() const state = readPersistedState(dataPath) state.settings = { @@ -145,20 +173,18 @@ function updateEnabledOnDisk(enabled: boolean): { } async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise { - try { - const status = await client.getCliStatus() - if (!status.result.runtime.reachable) { - return false + const status = await client.getCliStatus() + if (!status.result.runtime.reachable) { + if (status.result.app.running) { + throw new RuntimeClientError( + 'runtime_error', + 'Orca is running but unavailable. Retry when it responds, or stop Orca before changing agent hooks offline.' + ) } - await client.call( - 'settings.update', - { agentStatusHooksEnabled: enabled }, - { timeoutMs: 10_000 } - ) - return true - } catch { return false } + await client.call('settings.update', { agentStatusHooksEnabled: enabled }, { timeoutMs: 10_000 }) + return true } function localSuccess(result: TResult): RuntimeRpcSuccess { @@ -207,7 +233,8 @@ async function setAgentHooksEnabled( } export const AGENT_HOOK_HANDLERS: Record = { - 'agent hooks prepare-codex': async ({ client }) => { + 'agent hooks prepare-codex': async ({ client, flags }) => { + rejectRemoteHookSelection(flags) if (process.env.WSL_DISTRO_NAME?.trim()) { try { await client.call( @@ -231,7 +258,8 @@ export const AGENT_HOOK_HANDLERS: Record = { settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex') }) }, - 'agent hooks status': async ({ json }) => { + 'agent hooks status': async ({ json, flags }) => { + rejectRemoteHookSelection(flags) const { getManagedAgentHookStatuses } = await import('../../main/agent-hooks/managed-agent-hook-controls.js') const result: AgentHookCommandResult = { @@ -242,12 +270,21 @@ export const AGENT_HOOK_HANDLERS: Record = { } printResult(localSuccess(result), json, formatAgentHookCommandResult) }, - 'agent hooks off': async ({ client, json }) => { + 'agent hooks off': async ({ client, json, flags }) => { + rejectRemoteHookSelection(flags) const result = await setAgentHooksEnabled(client, false) printResult(localSuccess(result), json, formatAgentHookCommandResult) }, - 'agent hooks on': async ({ client, json }) => { + 'agent hooks on': async ({ client, json, flags }) => { + rejectRemoteHookSelection(flags) const result = await setAgentHooksEnabled(client, true) printResult(localSuccess(result), json, formatAgentHookCommandResult) } } + +function rejectRemoteHookSelection(flags: ReadonlyMap): void { + rejectRemoteSelectionFlags( + flags, + 'agent hooks; run this command on the machine whose hooks you want to manage.' + ) +} diff --git a/src/cli/handlers/profile-state-recovery-admission.test.ts b/src/cli/handlers/profile-state-recovery-admission.test.ts new file mode 100644 index 000000000000..c6ff1ec1add7 --- /dev/null +++ b/src/cli/handlers/profile-state-recovery-admission.test.ts @@ -0,0 +1,233 @@ +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as durableFileWrite from '../../main/durable-file-write' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from '../../main/persistence/profile-state/profile-state-access' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../../main/persistence/profile-state/profile-state-database' +import { + importProfileStateJson, + readProfileStateSnapshot +} from '../../main/persistence/profile-state/profile-state-documents' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from '../../main/persistence/profile-state/profile-state-backup-path' +import { writeProfileStateDatabaseSnapshotAsync } from '../../main/persistence/profile-state/profile-state-database-snapshot' +import { restoreProfileStateDatabaseBackup } from '../../main/persistence/profile-state/profile-state-database-recovery' +import { + assertNoRetainedProfileStateExports, + ProfileStateRecoveryRequiredError +} from '../../main/persistence/profile-state/profile-state-recovery-required' +import { RuntimeClient } from '../runtime-client' +import { PROFILE_STATE_HANDLERS } from './profile-state' + +const mocks = vi.hoisted(() => ({ root: vi.fn(), status: vi.fn() })) +vi.mock('../runtime-client', () => ({ + getDefaultUserDataPath: mocks.root, + RuntimeClient: class { + getCliStatus = mocks.status + }, + RuntimeClientError: class extends Error { + constructor( + readonly code: string, + message: string + ) { + super(message) + } + } +})) + +const roots: string[] = [] +const profileId = 'admission-recovery' +const backupState = { + settings: { + theme: 'restored', + httpProxyUrl: 'sealed:backup', + electronHttp1CompatibilityMode: true + }, + extension: { unknown: [null, '\ud800', 'backup'] }, + opaque: null +} +const liveState = { + settings: { theme: 'runtime-before-restore', httpProxyUrl: 'sealed:live' }, + extension: { unknown: [null, '\ud800', 'live'] }, + opaque: null +} + +beforeEach(() => { + mocks.status.mockReset().mockResolvedValue({ + result: { app: { running: false }, runtime: { reachable: false } } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +async function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-recovery-admission-')) + roots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const backupId = createProfileStateDatabaseBackupId() + const backupPath = profileStateDatabaseBackupPath(databasePath, backupId) + const source = openProfileStateDatabase(databasePath, profileId) + try { + importProfileStateJson(source.db, JSON.stringify(backupState)) + await writeProfileStateDatabaseSnapshotAsync(source.db, backupPath) + importProfileStateJson(source.db, JSON.stringify(liveState), { expectedRevision: 1 }) + } finally { + source.db.close() + } + mocks.root.mockReturnValue(root) + return { root, directory, databasePath, dataFile, backupId, backupPath } +} + +function rollback(profile: Awaited>): Promise { + const handler = PROFILE_STATE_HANDLERS['profile state rollback'] + if (handler === undefined) { + throw new Error('Profile rollback handler is missing') + } + return handler({ + flags: new Map([['backup', profile.backupId]]), + client: new RuntimeClient(profile.root), + cwd: profile.root, + json: true + }) +} + +function state(databasePath: string) { + const opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + return readProfileStateSnapshot(opened.db) + } finally { + opened.db.close() + } +} + +describe('offline recovery excludes runtime admission', () => { + it('refuses recovery before any mutation when a runtime has already entered', async () => { + const profile = await fixture() + const original = readFileSync(profile.databasePath) + const backup = readFileSync(profile.backupPath) + const admission = acquireProfileStateRuntimeAdmission(profile.root) + const runtime = openProfileStateDatabase(profile.databasePath, profileId) + try { + await expect(rollback(profile)).rejects.toThrow('in use') + expect(mocks.status).not.toHaveBeenCalled() + expect(JSON.parse(readProfileStateSnapshot(runtime.db).json)).toEqual(liveState) + expect(readFileSync(profile.databasePath)).toEqual(original) + expect(readFileSync(profile.backupPath)).toEqual(backup) + expect( + readdirSync(profile.directory).some((name) => name.startsWith('profile-state-corrupt')) + ).toBe(false) + } finally { + runtime.db.close() + admission.release() + } + }) + + it('blocks startup between the stopped census and restoration while preserving complete original and restored state', async () => { + const profile = await fixture() + const original = readFileSync(profile.databasePath) + const backup = readFileSync(profile.backupPath) + mocks.status.mockImplementation(async () => { + const stopped = { result: { app: { running: false }, runtime: { reachable: false } } } + expect(() => acquireProfileStateRuntimeAdmission(profile.root)).toThrow('in use') + expect(() => acquireProfileStateMaintenance(profile.root)).toThrow('in use') + return stopped + }) + + await rollback(profile) + + expect(mocks.status).toHaveBeenCalledOnce() + expect(JSON.parse(state(profile.databasePath).json)).toEqual(backupState) + expect(readFileSync(profile.backupPath)).toEqual(backup) + const quarantine = readdirSync(profile.directory).find((name) => + name.startsWith('profile-state-corrupt') + ) + expect(quarantine).toBeDefined() + if (quarantine === undefined) { + throw new Error('Recovery did not preserve a quarantine') + } + const quarantined = join(profile.directory, quarantine, 'profile-state.db') + expect(readFileSync(quarantined)).toEqual(original) + expect(JSON.parse(state(quarantined).json)).toEqual(liveState) + const admission = acquireProfileStateRuntimeAdmission(profile.root) + expect(JSON.parse(state(profile.databasePath).json)).toEqual(backupState) + admission.release() + }) + + it('keeps startup blocked after failed durable publication and permits an explicit successful retry', async () => { + const profile = await fixture() + const backup = readFileSync(profile.backupPath) + const rename = durableFileWrite.renameDurableSync + const failure = vi + .spyOn(durableFileWrite, 'renameDurableSync') + .mockImplementation((from, to) => { + if (to === profile.databasePath) { + throw new Error('injected recovery publication failure') + } + rename(from, to) + }) + await expect(rollback(profile)).rejects.toThrow('injected recovery publication failure') + const admission = acquireProfileStateRuntimeAdmission(profile.root) + try { + expect(() => + assertNoRetainedProfileStateExports({ + dataFile: profile.dataFile, + databaseFile: profile.databasePath, + profileId + }) + ).toThrow(ProfileStateRecoveryRequiredError) + } finally { + admission.release() + } + expect(readFileSync(profile.backupPath)).toEqual(backup) + const quarantine = readdirSync(profile.directory).find((name) => + name.startsWith('profile-state-corrupt') + ) + if (quarantine === undefined) { + throw new Error('Recovery did not preserve original state') + } + expect(JSON.parse(state(join(profile.directory, quarantine, 'profile-state.db')).json)).toEqual( + liveState + ) + + failure.mockRestore() + await rollback(profile) + + expect(JSON.parse(state(profile.databasePath).json)).toEqual(backupState) + acquireProfileStateRuntimeAdmission(profile.root).release() + }) + + it('rejects fabricated or released maintenance handles before replacing any database bytes', async () => { + const profile = await fixture() + const maintenance = acquireProfileStateMaintenance(profile.root) + const original = readFileSync(profile.databasePath) + const options = { ...profile, profileId } + expect(() => + restoreProfileStateDatabaseBackup({ ...options, maintenance: { ...maintenance } }) + ).toThrow('acquired') + maintenance.release() + expect(() => restoreProfileStateDatabaseBackup({ ...options, maintenance })).toThrow('released') + expect(readFileSync(profile.databasePath)).toEqual(original) + }) +}) diff --git a/src/cli/handlers/profile-state.test.ts b/src/cli/handlers/profile-state.test.ts new file mode 100644 index 000000000000..772c33501e42 --- /dev/null +++ b/src/cli/handlers/profile-state.test.ts @@ -0,0 +1,458 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as durableFileWrite from '../../main/durable-file-write' +import * as http1Marker from '../../main/startup/http1-compatibility-marker' +import { readPersistedHttp1CompatibilityMode } from '../../main/startup/http1-compatibility-profile-state' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile +} from '../../main/persistence/profile-state/profile-state-database' +import { + exportProfileStateJson, + importProfileStateJson +} from '../../main/persistence/profile-state/profile-state-documents' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from '../../main/persistence/profile-state/profile-state-backup-path' +import { writeProfileStateDatabaseSnapshotAsync } from '../../main/persistence/profile-state/profile-state-database-snapshot' +import { profileStateJsonExportPath } from '../../main/persistence/profile-state/profile-state-export-path' +import { main } from '../index' + +const { getCliStatusMock, getDefaultUserDataPathMock, runtimeClientConstructorMock } = vi.hoisted( + () => ({ + getCliStatusMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(), + runtimeClientConstructorMock: vi.fn() + }) +) + +vi.mock('../runtime-client', () => { + class RuntimeClientError extends Error { + readonly code: string + readonly data: unknown + + constructor(code: string, message: string, data?: unknown) { + super(message) + this.code = code + this.data = data + } + } + + class RuntimeClient { + getCliStatus = getCliStatusMock + + constructor( + _userDataPath?: string, + _requestTimeoutMs?: number, + remotePairingCode?: string | null, + environmentSelector?: string | null + ) { + runtimeClientConstructorMock(remotePairingCode, environmentSelector) + } + } + + return { + RuntimeClient, + RuntimeClientError, + getDefaultUserDataPath: getDefaultUserDataPathMock + } +}) + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() + runtimeClientConstructorMock.mockReset() + process.exitCode = 0 +}) + +function createProfile(): { + userDataPath: string + dataFile: string + databaseFile: string + exportPath: string +} { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-profile-state-cli-')) + temporaryDirectories.push(userDataPath) + const profileId = 'profile-cli-recovery' + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }), + 'utf8' + ) + const dataFile = join(profileDirectory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(profileDirectory) + const exportPath = profileStateJsonExportPath(dataFile, 1) + writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'old' } }), 'utf8') + writeFileSync( + exportPath, + JSON.stringify({ settings: { theme: 'recovered', electronHttp1CompatibilityMode: true } }), + 'utf8' + ) + writeFileSync(databaseFile, 'damaged sqlite primary', 'utf8') + writeFileSync(`${databaseFile}-wal`, 'damaged wal sidecar', 'utf8') + return { userDataPath, dataFile, databaseFile, exportPath } +} + +async function createDatabaseBackup( + profile: ReturnType, + profileId = 'profile-cli-recovery' +) { + const id = createProfileStateDatabaseBackupId() + const path = profileStateDatabaseBackupPath(profile.databaseFile, id) + const source = openProfileStateDatabase(join(profile.userDataPath, 'backup-source.db'), profileId) + try { + importProfileStateJson( + source.db, + JSON.stringify({ + settings: { + theme: 'sqlite-recovered', + electronHttp1CompatibilityMode: true, + httpProxyUrl: 'sealed:unchanged' + }, + extensionState: { retained: true } + }) + ) + await writeProfileStateDatabaseSnapshotAsync(source.db, path) + } finally { + source.db.close() + } + return { id, path } +} + +describe('profile-state CLI recovery', () => { + beforeEach(() => { + getCliStatusMock.mockResolvedValue({ + id: 'status', + ok: true, + result: { + app: { running: false, pid: null }, + runtime: { state: 'not_running', reachable: false, runtimeId: null }, + graph: { state: 'not_running' } + }, + _meta: { runtimeId: 'test' } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('restores the selected export through the offline CLI command', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + + await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath) + + expect(existsSync(profile.databaseFile)).toBe(false) + expect(readFileSync(profile.dataFile, 'utf8')).toBe( + JSON.stringify({ settings: { theme: 'recovered', electronHttp1CompatibilityMode: true } }) + ) + expect( + JSON.parse(readFileSync(join(profile.userDataPath, 'http1-compatibility.json'), 'utf8')) + ).toMatchObject({ + enabled: true, + profileId: 'profile-cli-recovery' + }) + const output = vi.mocked(console.log).mock.calls.at(-1)?.[0] + expect(String(output)).toContain('quarantineDirectory') + expect(getCliStatusMock).toHaveBeenCalledOnce() + }) + + it('keeps profile-state recovery local when remote selection is configured', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + vi.stubEnv('ORCA_PAIRING_CODE', 'remote-pairing-code') + vi.stubEnv('ORCA_ENVIRONMENT', 'stale-environment') + + await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath) + + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null) + expect(vi.mocked(console.log).mock.calls.at(-1)?.[0]).toContain('quarantineDirectory') + }) + + it.each([true, false])( + 'recovers an absent database and archives every export (legacy JSON present: %s)', + async (hasJson) => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + rmSync(profile.databaseFile) + rmSync(`${profile.databaseFile}-wal`) + if (!hasJson) { + rmSync(profile.dataFile) + } + const laterExport = profileStateJsonExportPath(profile.dataFile, 2) + writeFileSync(laterExport, JSON.stringify({ settings: { theme: 'later' } })) + const selectedBytes = readFileSync(profile.exportPath) + const laterBytes = readFileSync(laterExport) + + await main( + ['profile', 'state', 'rollback', '--revision', '1', '--json'], + profile.userDataPath + ) + + expect(process.exitCode).toBe(0) + expect(readFileSync(profile.dataFile)).toEqual(selectedBytes) + expect(existsSync(profile.exportPath)).toBe(false) + expect(existsSync(laterExport)).toBe(false) + const output: unknown = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])) + expect(output).toMatchObject({ ok: true, result: { removedDatabaseFiles: [] } }) + if ( + !output || + typeof output !== 'object' || + !('result' in output) || + !output.result || + typeof output.result !== 'object' || + !('quarantineDirectory' in output.result) || + typeof output.result.quarantineDirectory !== 'string' + ) { + throw new Error('Expected rollback archive directory') + } + const archive = output.result.quarantineDirectory + expect(readFileSync(join(archive, basename(profile.exportPath)))).toEqual(selectedBytes) + expect(readFileSync(join(archive, basename(laterExport)))).toEqual(laterBytes) + expect(existsSync(join(archive, basename(profile.dataFile)))).toBe(hasJson) + expect(readPersistedHttp1CompatibilityMode(profile.userDataPath)).toBe(true) + } + ) + + it('preserves all live recovery sources when archiving an export fails', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + const unavailableExport = profileStateJsonExportPath(profile.dataFile, 2) + mkdirSync(unavailableExport) + const original = readFileSync(profile.dataFile) + + await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath) + + expect(process.exitCode).toBe(1) + expect(readFileSync(profile.dataFile)).toEqual(original) + expect(existsSync(profile.exportPath)).toBe(true) + expect(existsSync(profile.databaseFile)).toBe(true) + expect(existsSync(`${profile.databaseFile}-wal`)).toBe(true) + }) + + it('falls back to restored settings when refreshing the marker fails', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, false, 'profile-cli-recovery') + const writeFileDurableSync = durableFileWrite.writeFileDurableSync + vi.spyOn(durableFileWrite, 'writeFileDurableSync').mockImplementation( + (tmp, target, contents) => { + if (target === join(profile.userDataPath, http1Marker.HTTP1_COMPATIBILITY_MARKER_FILE)) { + throw new Error('injected marker write failure') + } + return writeFileDurableSync(tmp, target, contents) + } + ) + + await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath) + + expect(existsSync(profile.databaseFile)).toBe(false) + expect( + http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery') + ).toBeNull() + expect(readPersistedHttp1CompatibilityMode(profile.userDataPath)).toBe(true) + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('quarantineDirectory') + }) + + it('preserves SQLite authority when the old marker cannot be invalidated', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + // A directory at the marker path makes non-recursive removal fail on every supported OS. + mkdirSync(join(profile.userDataPath, http1Marker.HTTP1_COMPATIBILITY_MARKER_FILE)) + + await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath) + + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary') + expect(existsSync(profile.exportPath)).toBe(true) + expect(readFileSync(profile.dataFile, 'utf8')).toBe( + JSON.stringify({ settings: { theme: 'old' } }) + ) + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).not.toContain( + 'quarantineDirectory' + ) + expect(process.exitCode).toBe(1) + }) + + it('does not invalidate the active setting for an invalid recovery export', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, true, 'profile-cli-recovery') + writeFileSync(profile.exportPath, 'invalid JSON') + + await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath) + + expect(existsSync(profile.databaseFile)).toBe(true) + expect( + http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery') + ).toBe(true) + expect(process.exitCode).toBe(1) + }) + + it('rejects an explicit remote selector instead of silently ignoring it', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + + await main( + ['profile', 'state', 'rollback', '--revision', '1', '--environment', 'remote', '--json'], + profile.userDataPath + ) + + expect(existsSync(profile.databaseFile)).toBe(true) + expect(vi.mocked(console.log).mock.calls.at(-1)?.[0]).toContain( + '`--environment` does not retarget profile-state recovery' + ) + }) + + it('refuses rollback while the runtime is reachable', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + getCliStatusMock.mockResolvedValueOnce({ + id: 'status', + ok: true, + result: { + app: { running: true, pid: 123 }, + runtime: { state: 'ready', reachable: true, runtimeId: 'desktop' }, + graph: { state: 'ready' } + }, + _meta: { runtimeId: 'test' } + }) + + await main(['profile', 'state', 'rollback', '--revision', '1'], profile.userDataPath) + + expect(existsSync(profile.databaseFile)).toBe(true) + expect(readFileSync(profile.dataFile, 'utf8')).toBe( + JSON.stringify({ settings: { theme: 'old' } }) + ) + expect(vi.mocked(console.error).mock.calls.at(-1)?.[0]).toContain('Stop Orca') + }) + + it('lists SQLite backups alongside JSON exports without opening the damaged primary', async () => { + const profile = createProfile() + const backup = await createDatabaseBackup(profile) + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + getCliStatusMock.mockClear() + + await main(['profile', 'state', 'exports', '--json'], profile.userDataPath) + + const output: unknown = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])) + expect(output).toMatchObject({ + ok: true, + result: { exportPaths: [profile.exportPath], backups: [{ id: backup.id, path: backup.path }] } + }) + expect(getCliStatusMock).not.toHaveBeenCalled() + }) + + it.each([true, false])( + 'restores SQLite backup authority with damaged database present=%s', + async (hasDatabase) => { + const profile = createProfile() + const backup = await createDatabaseBackup(profile) + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, false, 'profile-cli-recovery') + if (!hasDatabase) { + rmSync(profile.databaseFile) + rmSync(`${profile.databaseFile}-wal`) + rmSync(profile.dataFile) + } + + await main( + ['profile', 'state', 'rollback', '--backup', backup.id, '--json'], + profile.userDataPath + ) + + expect(process.exitCode).toBe(0) + expect(existsSync(profile.dataFile)).toBe(false) + expect(existsSync(backup.path)).toBe(true) + const restored = openProfileStateDatabaseReadOnly( + profile.databaseFile, + 'profile-cli-recovery' + ) + try { + expect(JSON.parse(exportProfileStateJson(restored.db))).toMatchObject({ + settings: { theme: 'sqlite-recovered', httpProxyUrl: 'sealed:unchanged' }, + extensionState: { retained: true } + }) + } finally { + restored.db.close() + } + expect( + http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery') + ).toBe(true) + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('"storage": "sqlite"') + } + ) + + it('rejects a backup belonging to another profile before invalidating the startup marker', async () => { + const profile = createProfile() + const backup = await createDatabaseBackup(profile, 'foreign-profile') + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, true, 'profile-cli-recovery') + + await main( + ['profile', 'state', 'rollback', '--backup', backup.id, '--json'], + profile.userDataPath + ) + + expect(process.exitCode).toBe(1) + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary') + expect( + http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery') + ).toBe(true) + }) + + it('requires an unambiguous retained backup selection', async () => { + const profile = createProfile() + const backup = await createDatabaseBackup(profile) + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + + await main( + ['profile', 'state', 'rollback', '--backup', backup.id, '--revision', '1', '--json'], + profile.userDataPath + ) + + expect(process.exitCode).toBe(1) + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary') + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('exactly one') + }) + + it('rejects escaping backup IDs without touching any recovery artifact', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + + await main( + ['profile', 'state', 'rollback', '--backup', '../../outside', '--json'], + profile.userDataPath + ) + + expect(process.exitCode).toBe(1) + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary') + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('backup is unavailable') + }) + + it('refuses database backup restoration while the app is running', async () => { + const profile = createProfile() + const backup = await createDatabaseBackup(profile) + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + getCliStatusMock.mockResolvedValueOnce({ + result: { app: { running: true }, runtime: { reachable: false } } + }) + + await main( + ['profile', 'state', 'rollback', '--backup', backup.id, '--json'], + profile.userDataPath + ) + + expect(process.exitCode).toBe(1) + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary') + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('Stop Orca') + }) +}) diff --git a/src/cli/handlers/profile-state.ts b/src/cli/handlers/profile-state.ts new file mode 100644 index 000000000000..3d8f5729416c --- /dev/null +++ b/src/cli/handlers/profile-state.ts @@ -0,0 +1,147 @@ +import type { CommandHandler } from '../dispatch' +import { printResult } from '../format' +import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' +import { + getDefaultUserDataPath, + RuntimeClientError, + type RuntimeClient, + type RuntimeRpcSuccess +} from '../runtime-client' +import { + getProfileStateExports, + rollbackProfileState +} from '../../main/persistence/profile-state/profile-state-recovery-command' +import { acquireProfileStateMaintenance } from '../../main/persistence/profile-state/profile-state-access' +import { + isProfileStateRecoveryCommandError, + type ProfileStateExportsResult, + type ProfileStateRollbackResult, + type ProfileStateRecoverySelector +} from '../../shared/profile-state-recovery-command' +import { + canLaunchProfileStateRecovery, + launchProfileStateRecovery +} from '../runtime/profile-state-recovery-launch' + +function localSuccess(result: TResult): RuntimeRpcSuccess { + return { + id: 'local', + ok: true, + result, + _meta: { runtimeId: 'local' } + } +} + +function formatExports(result: ProfileStateExportsResult): string { + return [ + `profileId: ${result.profileId}`, + `dataFile: ${result.dataFile}`, + `databaseFile: ${result.databaseFile}`, + 'JSON exports:', + ...(result.exportPaths.length > 0 ? result.exportPaths : ['(none)']), + 'SQLite backups:', + ...(result.backups.length > 0 + ? result.backups.map((backup) => `${backup.id}: ${backup.path}`) + : ['(none)']) + ].join('\n') +} + +function formatRollback(result: ProfileStateRollbackResult): string { + return [ + `profileId: ${result.profileId}`, + `revision: ${result.revision}`, + `storage: ${result.storage}`, + `restored: ${result.restoredPath}`, + `quarantine: ${result.quarantineDirectory}`, + `removedDatabaseFiles: ${result.removedDatabaseFiles.length}` + ].join('\n') +} + +function rejectProfileStateRemoteSelection(flags: ReadonlyMap): void { + rejectRemoteSelectionFlags( + flags, + "profile-state recovery; it operates on this machine's active profile." + ) +} + +async function requireStoppedRuntime(client: RuntimeClient): Promise { + const status = await client.getCliStatus() + if (status.result.runtime.reachable || status.result.app.running) { + throw new RuntimeClientError( + 'runtime_error', + 'Stop Orca before profile-state rollback so no process can write the SQLite database.' + ) + } +} + +function parseRevision(flags: Map): number { + const rawRevision = flags.get('revision') + if (typeof rawRevision !== 'string' || rawRevision.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Profile-state rollback requires --revision.') + } + const revision = Number(rawRevision) + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new RuntimeClientError( + 'invalid_argument', + `Invalid profile-state revision: ${rawRevision}` + ) + } + return revision +} + +export const PROFILE_STATE_HANDLERS: Record = { + 'profile state exports': async ({ flags, json }) => { + rejectProfileStateRemoteSelection(flags) + const result = translateRecoveryError(() => getProfileStateExports(getDefaultUserDataPath())) + printResult(localSuccess(result), json, formatExports) + }, + 'profile state rollback': async ({ client, flags, json }) => { + rejectProfileStateRemoteSelection(flags) + if (flags.has('revision') && flags.has('backup')) { + throw new RuntimeClientError( + 'invalid_argument', + 'Select exactly one of --revision or --backup.' + ) + } + const selector = parseSelector(flags) + const userDataPath = getDefaultUserDataPath() + let result: ProfileStateRollbackResult + if (canLaunchProfileStateRecovery()) { + await requireStoppedRuntime(client) + result = await launchProfileStateRecovery({ userDataPath, selector }) + } else { + const maintenance = acquireProfileStateMaintenance(userDataPath) + try { + await requireStoppedRuntime(client) + result = translateRecoveryError(() => + rollbackProfileState(userDataPath, selector, maintenance) + ) + } finally { + maintenance.release() + } + } + printResult(localSuccess(result), json, formatRollback) + } +} + +function parseSelector(flags: Map): ProfileStateRecoverySelector { + if (!flags.has('backup')) { + return { kind: 'json', revision: parseRevision(flags) } + } + const backupId = flags.get('backup') + if (typeof backupId !== 'string' || backupId.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Profile-state rollback requires --backup.') + } + return { kind: 'sqlite', backupId } +} + +function translateRecoveryError(operation: () => T): T { + try { + return operation() + } catch (error) { + if (isProfileStateRecoveryCommandError(error)) { + throw new RuntimeClientError(error.code, error.message) + } + throw error + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 33566dcf8374..b2e01c04e56f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -38,7 +38,8 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { commandPath[0] === 'serve' || commandPath[0] === 'agent' || commandPath[0] === 'vm' || - commandPath[0] === 'agent-context' + commandPath[0] === 'agent-context' || + commandPath[0] === 'profile' ) } diff --git a/src/cli/profile-state-location.ts b/src/cli/profile-state-location.ts new file mode 100644 index 000000000000..1e9325872a11 --- /dev/null +++ b/src/cli/profile-state-location.ts @@ -0,0 +1,13 @@ +import { getActiveProfileStateLocation as resolveActiveProfileStateLocation } from '../main/persistence/profile-state/profile-state-active-location' +import { RuntimeClientError, getDefaultUserDataPath } from './runtime-client' + +export function getActiveProfileStateLocation(userDataPath = getDefaultUserDataPath()) { + try { + return resolveActiveProfileStateLocation(userDataPath) + } catch (error) { + throw new RuntimeClientError( + 'runtime_error', + error instanceof Error ? error.message : String(error) + ) + } +} diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index a326ae333f58..ebf10c2aaa9b 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -255,7 +255,7 @@ function waitForRecipeJson(child: ReturnType): Promise 0) { return overrideExecutable diff --git a/src/cli/runtime/profile-state-recovery-launch.test.ts b/src/cli/runtime/profile-state-recovery-launch.test.ts new file mode 100644 index 000000000000..92c306618ad4 --- /dev/null +++ b/src/cli/runtime/profile-state-recovery-launch.test.ts @@ -0,0 +1,119 @@ +import { realpathSync } from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PROFILE_STATE_RECOVERY_FLAG, + PROFILE_STATE_RECOVERY_RESULT_PREFIX +} from '../../shared/profile-state-recovery-command' +import { + canLaunchProfileStateRecovery, + launchProfileStateRecovery +} from './profile-state-recovery-launch' + +const mocks = vi.hoisted(() => ({ run: vi.fn() })) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: mocks.run })) +vi.mock('./launch', () => ({ + resolveForegroundOrcaExecutable: () => '/packaged/Orca', + resolveAppRoot: () => '/application', + getExecutableAppArgs: () => ['/application'], + stripElectronRunAsNode: (env: NodeJS.ProcessEnv) => { + const clean = { ...env } + delete clean.ELECTRON_RUN_AS_NODE + return clean + } +})) + +const result = { + profileId: 'profile', + dataFile: '/root/orca-data.json', + databaseFile: '/root/profile-state.db', + exportPaths: [], + backups: [], + revision: 1, + quarantineDirectory: '/root/quarantine', + removedDatabaseFiles: [], + storage: 'json', + restoredPath: '/root/orca-data.json' +} +const request = { userDataPath: '.', selector: { kind: 'json', revision: 1 } } as const +beforeEach(() => { + mocks.run.mockReset().mockResolvedValue({ + code: 0, + signal: null, + timedOut: false, + stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: true, result })}\n`, + stderr: '' + }) +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('profile-state recovery launch', () => { + it('preserves direct participation only for plain Node without an explicit Electron executable', () => { + vi.stubEnv('ELECTRON_RUN_AS_NODE', undefined) + vi.stubEnv('ORCA_APP_EXECUTABLE', undefined) + expect(canLaunchProfileStateRecovery()).toBe(false) + vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') + expect(canLaunchProfileStateRecovery()).toBe(true) + vi.stubEnv('ELECTRON_RUN_AS_NODE', undefined) + vi.stubEnv('ORCA_APP_EXECUTABLE', '/explicit/Orca') + expect(canLaunchProfileStateRecovery()).toBe(true) + }) + + it('uses a foreground-safe serve request and binds the canonical recovery root', async () => { + vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') + vi.stubEnv('ORCA_USER_DATA_PATH', '/stale/root') + expect(await launchProfileStateRecovery(request)).toEqual(result) + expect(mocks.run).toHaveBeenCalledWith( + expect.objectContaining({ + program: '/packaged/Orca', + args: [ + '/application', + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ ...request, userDataPath: realpathSync('.') }) + ], + env: expect.objectContaining({ + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_USER_DATA_PATH: realpathSync('.') + }), + timeoutMs: null + }) + ) + expect(mocks.run.mock.calls[0][0].env).not.toHaveProperty('ELECTRON_RUN_AS_NODE') + }) + + it('preserves a structured refusal from the lock owner', async () => { + mocks.run.mockResolvedValue({ + code: 1, + stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: false, code: 'invalid_argument', message: 'Backup unavailable' })}` + }) + await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({ + code: 'invalid_argument', + message: 'Backup unavailable' + }) + }) + + it.each([ + { code: 1 }, + { signal: 'SIGKILL' }, + { timedOut: true }, + { outputTruncated: true }, + { stdout: '' }, + { stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{` }, + { stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{"ok":true,"result":{}}` }, + { + stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{}\n${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{}` + } + ])('rejects incomplete or ambiguous child results %j', async (override) => { + const original = await mocks.run() + mocks.run.mockResolvedValue({ ...original, ...override }) + await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({ + code: 'runtime_error' + }) + }) + + it('propagates launch failure without retrying another recovery path', async () => { + mocks.run.mockRejectedValue(new Error('Executable unavailable')) + await expect(launchProfileStateRecovery(request)).rejects.toThrow('Executable unavailable') + expect(mocks.run).toHaveBeenCalledOnce() + }) +}) diff --git a/src/cli/runtime/profile-state-recovery-launch.ts b/src/cli/runtime/profile-state-recovery-launch.ts new file mode 100644 index 000000000000..68d5bf0916ae --- /dev/null +++ b/src/cli/runtime/profile-state-recovery-launch.ts @@ -0,0 +1,71 @@ +import { realpathSync } from 'node:fs' +import { runProcess } from '../../shared/child-process/run-process' +import { + PROFILE_STATE_RECOVERY_FLAG, + PROFILE_STATE_RECOVERY_RESULT_PREFIX, + profileStateRecoveryResponseSchema, + type ProfileStateRecoveryRequest, + type ProfileStateRollbackResult +} from '../../shared/profile-state-recovery-command' +import { + getExecutableAppArgs, + resolveAppRoot, + resolveForegroundOrcaExecutable, + stripElectronRunAsNode +} from './launch' +import { RuntimeClientError } from './types' + +export function canLaunchProfileStateRecovery(): boolean { + return process.env.ELECTRON_RUN_AS_NODE === '1' || !!process.env.ORCA_APP_EXECUTABLE?.trim() +} + +export async function launchProfileStateRecovery( + request: ProfileStateRecoveryRequest +): Promise { + const executable = resolveForegroundOrcaExecutable() + const userDataPath = realpathSync(request.userDataPath) + const response = await runProcess({ + program: executable, + args: [ + ...getExecutableAppArgs(executable), + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ ...request, userDataPath }) + ], + cwd: resolveAppRoot(), + env: { + ...stripElectronRunAsNode(process.env), + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_USER_DATA_PATH: userDataPath + }, + // Recovery may copy large backups; the lock owner must finish or be explicitly terminated. + timeoutMs: null + }) + const lines = response.stdout + .split(/\r?\n/) + .filter((line) => line.startsWith(PROFILE_STATE_RECOVERY_RESULT_PREFIX)) + if (!response.outputTruncated && lines.length === 1) { + let parsed: unknown + try { + parsed = JSON.parse(lines[0].slice(PROFILE_STATE_RECOVERY_RESULT_PREFIX.length)) + } catch { + throw new RuntimeClientError( + 'runtime_error', + 'Orca recovery returned an invalid response. Inspect retained recovery artifacts before retrying.' + ) + } + const result = profileStateRecoveryResponseSchema.safeParse(parsed) + if (result.success) { + if (!result.data.ok) { + throw new RuntimeClientError(result.data.code, result.data.message) + } + if (response.code === 0 && !response.signal && !response.timedOut) { + return result.data.result + } + } + } + throw new RuntimeClientError( + 'runtime_error', + 'Orca recovery did not complete successfully. Inspect retained recovery artifacts before retrying.' + ) +} diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index ee9db22dc1a8..b817749f425c 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -18,6 +18,7 @@ import { VM_COMMAND_SPECS } from './vm' import { SKILL_COMMAND_SPECS } from './skills' import { ARTIFACT_COMMAND_SPECS } from './artifacts' import { SEARCH_COMMAND_SPECS } from './search' +import { PROFILE_STATE_COMMAND_SPECS } from './profile-state' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, @@ -38,5 +39,6 @@ export const COMMAND_SPECS: CommandSpec[] = [ ...VM_COMMAND_SPECS, ...EMULATOR_COMMAND_SPECS, ...SKILL_COMMAND_SPECS, - ...SEARCH_COMMAND_SPECS + ...SEARCH_COMMAND_SPECS, + ...PROFILE_STATE_COMMAND_SPECS ] diff --git a/src/cli/specs/profile-state.ts b/src/cli/specs/profile-state.ts new file mode 100644 index 000000000000..ae5aad105389 --- /dev/null +++ b/src/cli/specs/profile-state.ts @@ -0,0 +1,27 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const PROFILE_STATE_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['profile', 'state', 'exports'], + summary: 'List retained SQLite backups and JSON exports for profile-state recovery', + usage: 'orca profile state exports [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['profile', 'state', 'rollback'], + destructive: true, + summary: 'Restore a retained SQLite backup or JSON export after profile-state corruption', + usage: 'orca profile state rollback (--backup | --revision ) [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'revision', 'backup'], + notes: [ + 'Orca must be stopped. Recovery validates the selected artifact and archives the current database family, JSON, and retained recovery artifacts before replacing state.', + '--backup restores SQLite authority; --revision restores a JSON export for an older compatible runtime.' + ], + examples: [ + 'orca profile state exports', + 'orca profile state rollback --backup ', + 'orca profile state rollback --revision 1' + ] + } +] diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index e0610cc79c78..989f743fdcfe 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -168,7 +168,7 @@ function assertHardLinkPublicationSupported(sourcePath: string, targetPath: stri } } -function publishFileWithoutOverwrite(sourcePath: string, targetPath: string): boolean { +export function publishFileWithoutOverwrite(sourcePath: string, targetPath: string): boolean { try { linkSync(sourcePath, targetPath) return true diff --git a/src/main/daemon/daemon-launch-paths.ts b/src/main/daemon/daemon-launch-paths.ts index 049800daff45..532bc75c574d 100644 --- a/src/main/daemon/daemon-launch-paths.ts +++ b/src/main/daemon/daemon-launch-paths.ts @@ -60,7 +60,10 @@ export function probeDaemonSocket( socketPath: string, timeoutMs = DAEMON_SOCKET_PROBE_TIMEOUT_MS ): Promise { - const { promise, resolve } = Promise.withResolvers() + let resolve!: (alive: boolean) => void + const promise = new Promise((settle) => { + resolve = settle + }) if (process.platform !== 'win32' && !existsSync(socketPath)) { resolve(false) return promise diff --git a/src/main/daemon/daemon-pty-spawn-preparations.ts b/src/main/daemon/daemon-pty-spawn-preparations.ts index 633f0d1c7f57..af52051a4833 100644 --- a/src/main/daemon/daemon-pty-spawn-preparations.ts +++ b/src/main/daemon/daemon-pty-spawn-preparations.ts @@ -33,7 +33,14 @@ export class DaemonPtySpawnPreparations { clientId, requestId } - this.cancellationByPreparation.set(preparation, Promise.withResolvers()) + let resolveCancellation!: () => void + const cancellation = new Promise((resolve) => { + resolveCancellation = resolve + }) + this.cancellationByPreparation.set(preparation, { + promise: cancellation, + resolve: resolveCancellation + }) if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) { preparation.cancelTimer = setTimeout( () => this.cancelPreparation(preparation), diff --git a/src/main/daemon/terminal-history-permission-repair.ts b/src/main/daemon/terminal-history-permission-repair.ts index 7cb8414026eb..ffb4ea852cc3 100644 --- a/src/main/daemon/terminal-history-permission-repair.ts +++ b/src/main/daemon/terminal-history-permission-repair.ts @@ -116,7 +116,10 @@ export function scheduleTerminalHistoryPermissionRepair(basePath: string): Promi } scheduledBasePaths.delete(oldest.value) } - const { promise, resolve: settle } = Promise.withResolvers() + let settle!: (repaired: boolean) => void + const promise = new Promise((resolve) => { + settle = resolve + }) const timer = setTimeout(() => { repairTerminalHistoryPermissions(key).then(settle, () => settle(false)) }, REPAIR_START_DELAY_MS) diff --git a/src/main/daemon/windows-conpty-warmup.test.ts b/src/main/daemon/windows-conpty-warmup.test.ts index 1ab19b0c97ed..a10d054926cb 100644 --- a/src/main/daemon/windows-conpty-warmup.test.ts +++ b/src/main/daemon/windows-conpty-warmup.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type * as pty from 'node-pty' import { warmWindowsConptyOnce } from './windows-conpty-warmup' +import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job' + +vi.mock('../windows/windows-pty-job', () => ({ + assignHostProcessToKillOnCloseJob: vi.fn(() => true) +})) function setPlatform(platform: NodeJS.Platform): () => void { const original = process.platform @@ -17,6 +22,7 @@ afterEach(() => { restorePlatform?.() restorePlatform = null vi.restoreAllMocks() + vi.clearAllMocks() }) function makeFakePty(): { proc: pty.IPty; fireExit: () => void } { @@ -41,6 +47,7 @@ describe('warmWindowsConptyOnce', () => { await flushImmediates() expect(spawnPty).not.toHaveBeenCalled() + expect(assignHostProcessToKillOnCloseJob).not.toHaveBeenCalled() }) it('spawns a short-lived cmd.exe with the bundled ConPTY on Windows', async () => { @@ -52,6 +59,7 @@ describe('warmWindowsConptyOnce', () => { await flushImmediates() expect(spawnPty).toHaveBeenCalledTimes(1) + expect(assignHostProcessToKillOnCloseJob).toHaveBeenCalledBefore(vi.mocked(spawnPty)) const [file, args, options] = vi.mocked(spawnPty).mock.calls[0] expect(String(file).toLowerCase()).toContain('cmd') expect(args).toEqual(['/c', 'exit']) diff --git a/src/main/daemon/windows-conpty-warmup.ts b/src/main/daemon/windows-conpty-warmup.ts index aea1c50b2dbb..6f39197261f5 100644 --- a/src/main/daemon/windows-conpty-warmup.ts +++ b/src/main/daemon/windows-conpty-warmup.ts @@ -1,5 +1,6 @@ import os from 'node:os' import * as pty from 'node-pty' +import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job' const WARMUP_KILL_TIMEOUT_MS = 10_000 @@ -17,6 +18,8 @@ export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): v // real spawn arriving first simply does the warming itself. setImmediate(() => { try { + // Warm-up children must die with the daemon, even before its first real terminal. + assignHostProcessToKillOnCloseJob() const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], { name: 'xterm-256color', cols: 2, diff --git a/src/main/durable-file-write-syscall-proof.test.ts b/src/main/durable-file-write-syscall-proof.test.ts index bf18d6c00928..34563b0196ad 100644 --- a/src/main/durable-file-write-syscall-proof.test.ts +++ b/src/main/durable-file-write-syscall-proof.test.ts @@ -1,6 +1,15 @@ // Empirical proof that the durable write fsyncs the file, and the directory where the platform // allows it. Counted at the module boundary rather than inferred from reading the implementation. -import { closeSync, fsyncSync, mkdtempSync, openSync, readFileSync, rmSync } from 'node:fs' +import { + closeSync, + existsSync, + fsyncSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import type * as NodeFs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -9,23 +18,57 @@ import { expect, it, vi } from 'vitest' /** Why the rename is recorded too: an fsync moved after the rename still fsyncs a file, so a * fsync-only log reads identically for the correct and the broken order. The rename is the boundary * the ordering is defined against, so it has to appear in the same sequence. */ -const syscalls: ('fsync:file' | 'fsync:directory' | 'rename')[] = [] +const syscalls: ('fsync:file' | 'fsync:directory' | 'rename' | 'link')[] = [] vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { ...actual, fsyncSync: (fd: number) => { + actual.fsyncSync(fd) syscalls.push(actual.fstatSync(fd).isDirectory() ? 'fsync:directory' : 'fsync:file') - return actual.fsyncSync(fd) }, renameSync: (from: NodeFs.PathLike, to: NodeFs.PathLike) => { + actual.renameSync(from, to) syscalls.push('rename') - return actual.renameSync(from, to) + }, + linkSync: (from: NodeFs.PathLike, to: NodeFs.PathLike) => { + actual.linkSync(from, to) + syscalls.push('link') } } }) +it('publishes a new file durably and cannot replace an existing destination', async () => { + const { publishFileDurableSync } = await import('./durable-file-write') + const dir = mkdtempSync(join(tmpdir(), 'orca-publish-fsync-')) + try { + const supported = directoryFsyncSupported(dir) + const staged = join(dir, 'staged') + const target = join(dir, 'target') + writeFileSync(staged, 'first') + const fd = openSync(staged, 'r+') + try { + fsyncSync(fd) + } finally { + closeSync(fd) + } + syscalls.length = 0 + expect(publishFileDurableSync(staged, target)).toBe(true) + expect(syscalls).toEqual(supported ? ['link', 'fsync:directory'] : ['link']) + expect(existsSync(staged)).toBe(false) + expect(readFileSync(target, 'utf8')).toBe('first') + writeFileSync(staged, 'second') + syscalls.length = 0 + expect(publishFileDurableSync(staged, target)).toBe(false) + expect(readFileSync(target, 'utf8')).toBe('first') + expect(readFileSync(staged, 'utf8')).toBe('second') + expect(syscalls).toEqual([]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + /** Windows cannot open a directory for fsync, and some filesystems reject it; probe rather than * assume, so the expectation tracks the real platform instead of a hardcoded OS list. */ function directoryFsyncSupported(directory: string): boolean { diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index 83b0eabc5ed7..d186be1ae575 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -6,7 +6,7 @@ import { closeSync, fsyncSync, openSync, rmSync, writeFileSync } from 'node:fs' import { copyFile, open, readdir, rename, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' -import { renameFileWithWindowsRetry } from './codex-accounts/fs-utils' +import { publishFileWithoutOverwrite, renameFileWithWindowsRetry } from './codex-accounts/fs-utils' /** * fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a @@ -43,6 +43,22 @@ function syncDirectorySync(directory: string): void { } } +/** Rename an already-fsynced file and make the containing directory durable. */ +export function renameDurableSync(tmpPath: string, finalPath: string): void { + renameFileWithWindowsRetry(tmpPath, finalPath) + syncDirectorySync(dirname(finalPath)) +} + +/** Publish an already-fsynced file without replacing a concurrently created destination. */ +export function publishFileDurableSync(tmpPath: string, finalPath: string): boolean { + if (!publishFileWithoutOverwrite(tmpPath, finalPath)) { + return false + } + syncDirectorySync(dirname(finalPath)) + rmSync(tmpPath) + return true +} + /** * Rename and then fsync the containing directory. For callers that already fsynced the temp file * themselves and need the rename made durable. @@ -202,9 +218,8 @@ export function writeFileDurableSync( } finally { closeSync(fd) } - renameFileWithWindowsRetry(tmpPath, finalPath) + renameDurableSync(tmpPath, finalPath) renamed = true - syncDirectorySync(dirname(finalPath)) } finally { if (!renamed) { rmSync(tmpPath, { force: true }) diff --git a/src/main/index.ts b/src/main/index.ts index 522e59b908f6..9c710cfc79d0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, type BrowserWindow } from 'electron' +import { app, clipboard, dialog, type BrowserWindow } from 'electron' import { parseSkillShareId } from '../shared/skill-share-link' import { createMacAppActivationHandler } from './window/macos-app-activation' import { @@ -13,6 +13,12 @@ import { initializeMainProcessReady } from './startup/main-process-ready' import { installMainProcessQuitHandlers } from './startup/main-process-quit' import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock' import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files' +import { + formatProfileStateStartupFailure, + profileStateStartupFailureClass +} from './persistence/profile-state/profile-state-startup-failure' +import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' +import { presentProfileStateStartupRecoveryDialog } from './persistence/profile-state/profile-state-startup-recovery-dialog' function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { return openMainWindowController(options) @@ -107,9 +113,38 @@ if (preflightReady) { registerMainProcessIpcHandlers() installMainProcessQuitHandlers() void app.whenReady().then(async () => { - await initializeMainProcessReady({ - openMainWindow, - handleMacAppActivation - }) + try { + await initializeMainProcessReady({ + openMainWindow, + handleMacAppActivation + }) + } catch (error) { + const message = formatProfileStateStartupFailure(error) + if (message === undefined) { + throw error + } + const failureClass = profileStateStartupFailureClass(error) + if (failureClass !== undefined) { + recordDurableCrashBreadcrumb('profile_state_startup_failed', { + failure_class: failureClass + }) + } + console.error(`[profile-state] ${message}`) + if (!state.isServeMode && process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + try { + await presentProfileStateStartupRecoveryDialog({ + message, + ...(failureClass === 'recovery-required' + ? { recoveryCommand: 'orca profile state exports' } + : {}), + showMessageBox: (options) => dialog.showMessageBox(options), + copyToClipboard: (text) => clipboard.writeText(text) + }) + } catch (dialogError) { + console.warn('[profile-state] Recovery dialog failed; exiting safely:', dialogError) + } + } + app.exit(1) + } }) } diff --git a/src/main/ipc/orca-profile-project-transfer-args.ts b/src/main/ipc/orca-profile-project-transfer-args.ts new file mode 100644 index 000000000000..c2882baf6c4f --- /dev/null +++ b/src/main/ipc/orca-profile-project-transfer-args.ts @@ -0,0 +1,25 @@ +import type { TransferOrcaProfileProjectArgs } from '../../shared/orca-profiles' + +export function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs { + if ( + typeof args !== 'object' || + args === null || + !('sourceProfileId' in args) || + typeof args.sourceProfileId !== 'string' || + !('targetProfileId' in args) || + typeof args.targetProfileId !== 'string' || + !('repoId' in args) || + typeof args.repoId !== 'string' || + !('mode' in args) || + (args.mode !== 'move' && args.mode !== 'copy') + ) { + throw new Error('invalid_orca_profile_project_transfer') + } + const sourceProfileId = args.sourceProfileId.trim() + const targetProfileId = args.targetProfileId.trim() + const repoId = args.repoId.trim() + if (!sourceProfileId || !targetProfileId || !repoId) { + throw new Error('invalid_orca_profile_project_transfer') + } + return { sourceProfileId, targetProfileId, repoId, mode: args.mode } +} diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index 215b559327d8..8d4166add326 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ProfileStoragePaths from '../orca-profiles/profile-storage-paths' const { handlers, @@ -11,7 +12,8 @@ const { getOrcaProfileListStateMock, seedNewOrcaProfileTelemetryConsentMock, setActiveOrcaProfileMock, - transferOrcaProfileProjectMock + transferOrcaProfileProjectMock, + hasOrcaProfileStateDatabaseMock } = vi.hoisted(() => ({ handlers: new Map unknown>(), appExitMock: vi.fn(), @@ -23,7 +25,8 @@ const { getOrcaProfileListStateMock: vi.fn(), seedNewOrcaProfileTelemetryConsentMock: vi.fn(), setActiveOrcaProfileMock: vi.fn(), - transferOrcaProfileProjectMock: vi.fn() + transferOrcaProfileProjectMock: vi.fn(), + hasOrcaProfileStateDatabaseMock: vi.fn() })) vi.mock('electron', () => ({ @@ -66,6 +69,11 @@ vi.mock('../orca-profiles/profile-project-transfer', () => ({ transferOrcaProfileProject: transferOrcaProfileProjectMock })) +vi.mock('../orca-profiles/profile-storage-paths', async (importOriginal) => ({ + ...(await importOriginal()), + hasOrcaProfileStateDatabase: hasOrcaProfileStateDatabaseMock +})) + import { registerOrcaProfileHandlers } from './orca-profiles' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' @@ -87,6 +95,7 @@ describe('registerOrcaProfileHandlers', () => { seedNewOrcaProfileTelemetryConsentMock.mockReset() setActiveOrcaProfileMock.mockReset() transferOrcaProfileProjectMock.mockReset() + hasOrcaProfileStateDatabaseMock.mockReset().mockReturnValue(false) }) afterEach(() => { @@ -353,4 +362,83 @@ describe('registerOrcaProfileHandlers', () => { expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled() }) + + it('freezes a newly migrated source after transfer failure and reopens its current profile', async () => { + const store = makeStoreMock() + const onBeforeRelaunch = vi.fn() + getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'personal', profiles: [] }) + transferOrcaProfileProjectMock.mockImplementation(() => { + hasOrcaProfileStateDatabaseMock.mockReturnValue(true) + throw new Error('source commit interrupted') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. + registerOrcaProfileHandlers(store as never, { onBeforeRelaunch }) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:transferProject')?.(null, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }) + ) + ).rejects.toThrow('source commit interrupted') + + expect(store.flushPendingOrThrowAsync).toHaveBeenCalledBefore(transferOrcaProfileProjectMock) + expect(store.freezeWrites).toHaveBeenCalledOnce() + expect(store.freezeWrites).toHaveBeenCalledBefore(onBeforeRelaunch) + expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(150) + expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer') + expect(appQuitMock).toHaveBeenCalledOnce() + }) + + it('keeps an active JSON source writable after validation fails without a migration', async () => { + const store = makeStoreMock() + const onBeforeRelaunch = vi.fn() + getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'personal', profiles: [] }) + transferOrcaProfileProjectMock.mockImplementation(() => { + throw new Error('unknown_source_repo') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. + registerOrcaProfileHandlers(store as never, { onBeforeRelaunch }) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:transferProject')?.(null, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }) + ) + ).rejects.toThrow('unknown_source_repo') + + expect(store.freezeWrites).not.toHaveBeenCalled() + expect(onBeforeRelaunch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(150) + expect(relaunchAppMock).not.toHaveBeenCalled() + }) + + it.each([ + null, + {}, + { sourceProfileId: 4 }, + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'invalid' + } + ])('rejects malformed transfer arguments before disk work: %j', async (args) => { + const store = makeStoreMock() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. + registerOrcaProfileHandlers(store as never) + await expect( + Promise.resolve(handlers.get('orcaProfiles:transferProject')?.(null, args)) + ).rejects.toThrow('invalid_orca_profile_project_transfer') + expect(store.flushPendingOrThrowAsync).not.toHaveBeenCalled() + expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index 480c6f350f9b..3014b8221a2e 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -33,6 +33,7 @@ import { import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope' import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer' +import { transferActiveProfileProject } from '../orca-profiles/profile-active-transfer' import { findOrcaProfileProjectsByPath } from '../orca-profiles/profile-project-presence' import { flushActiveProfileBeforeFileMutation } from '../orca-profiles/profile-persistence-deadline' import { normalizeExecutionHostId } from '../../shared/execution-host' @@ -47,6 +48,7 @@ import { import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers' import { onOrcaCloudSessionInvalidated } from '../orca-profiles/profile-cloud-session-invalidation' import { broadcastOrcaProfileAuthStatusChanged } from './orca-profile-auth-status-broadcast' +import { transferProjectArgsFromUnknown } from './orca-profile-project-transfer-args' type RegisterOrcaProfileHandlersOptions = { onBeforeRelaunch?: () => void | Promise @@ -69,26 +71,6 @@ function profileIdFromArgs(args: unknown): string { return profileId } -function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs { - if (!args || typeof args !== 'object') { - throw new Error('invalid_orca_profile_project_transfer') - } - const candidate = args as TransferOrcaProfileProjectArgs - const sourceProfileId = candidate.sourceProfileId?.trim() - const targetProfileId = candidate.targetProfileId?.trim() - const repoId = candidate.repoId?.trim() - const mode = candidate.mode - if (!sourceProfileId || !targetProfileId || !repoId || (mode !== 'move' && mode !== 'copy')) { - throw new Error('invalid_orca_profile_project_transfer') - } - return { - sourceProfileId, - targetProfileId, - repoId, - mode - } -} - function findProjectsByPathArgsFromUnknown(args: unknown): FindOrcaProfileProjectsByPathArgs { if (!args || typeof args !== 'object') { throw new Error('invalid_orca_profile_project_path') @@ -242,7 +224,15 @@ export function registerOrcaProfileHandlers( // Why: transfer before any relaunch side effect so a duplicate-target // or validation failure cannot strand the app in a quitting state. await flushActiveProfileBeforeFileMutation(store) - const result = transferOrcaProfileProject(args, getProfileUserDataPath()) + const result = await transferActiveProfileProject( + args, + getProfileUserDataPath(), + store, + async () => { + await runBeforeProfileRelaunch(options.onBeforeRelaunch) + scheduleProfileRelaunch('profile-transfer') + } + ) if (result.status === 'transferred') { store.freezeWrites() await runBeforeProfileRelaunch(options.onBeforeRelaunch) diff --git a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts index 64034d544c2f..e03c29e79bc8 100644 --- a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts +++ b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts @@ -49,8 +49,14 @@ export function beginPtySpawnForWorktree( } } catch (error) { // Why: worktree ID and cwd can be different roots; release earlier admissions before rejecting. - finishes.toReversed().forEach((finish) => finish()) + for (let index = finishes.length - 1; index >= 0; index -= 1) { + finishes[index]!() + } throw error } - return () => finishes.toReversed().forEach((finish) => finish()) + return () => { + for (let index = finishes.length - 1; index >= 0; index -= 1) { + finishes[index]!() + } + } } diff --git a/src/main/orca-profiles/profile-active-transfer.test.ts b/src/main/orca-profiles/profile-active-transfer.test.ts new file mode 100644 index 000000000000..ecb5562bf02f --- /dev/null +++ b/src/main/orca-profiles/profile-active-transfer.test.ts @@ -0,0 +1,229 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import * as stateFiles from './profile-project-state-file' +import * as moveIntents from './profile-project-move-intent' +import { transferActiveProfileProject } from './profile-active-transfer' +import { transferOrcaProfileProject } from './profile-project-transfer' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { isEncryptionAvailable: () => false }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +const { Store } = await import('../persistence/loading-store/store') +const stores: InstanceType[] = [] +let directory: string +const args = { + sourceProfileId: 'source', + targetProfileId: 'target', + repoId: 'repo-1', + mode: 'move' +} as const + +function snapshot(profileId: string) { + return stateFiles.readProfileStateWithRevision(profileId, directory) +} + +function openStore() { + const profileDirectory = join(directory, 'profiles', 'source') + const store = new Store({ + dataFile: join(profileDirectory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority( + join(profileDirectory, 'profile-state.db'), + 'source' + ) + }) + stores.push(store) + store.flushOrThrow() + return store +} + +function interruptSourceCommit() { + const originalWrite = stateFiles.writeProfileState + return vi.spyOn(stateFiles, 'writeProfileState').mockImplementation((profileId, ...rest) => { + if (profileId === 'source') { + throw new Error('source commit interrupted') + } + return originalWrite(profileId, ...rest) + }) +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-active-profile-transfer-')) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(() => {}) + writeFileSync( + join(directory, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: 'source', + profiles: ['source', 'target'].map((id) => ({ + id, + name: id, + avatar: { kind: 'initials', initials: id[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + })) + }) + ) + for (const profileId of ['source', 'target']) { + const profileDirectory = join(directory, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + const db = openProfileStateDatabase(join(profileDirectory, 'profile-state.db'), profileId).db + try { + importProfileStateJson( + db, + JSON.stringify({ + ...getDefaultPersistedState('/home/test'), + repos: + profileId === 'source' + ? [{ id: 'repo-1', path: '/projects/folder', kind: 'folder', addedAt: 1 }] + : [] + }) + ) + } finally { + db.close() + } + } +}) + +afterEach(() => { + for (const store of stores.splice(0)) { + store.freezeWrites() + } + vi.restoreAllMocks() + rmSync(directory, { recursive: true, force: true }) +}) + +describe('active profile transfer recovery', () => { + it.each(['source commit', 'intent cleanup'] as const)( + 'fences an existing SQLite Store after interrupted %s until recovery and reopen', + async (failure) => { + const store = openStore() + const before = snapshot('source') + const interrupted = + failure === 'source commit' + ? interruptSourceCommit() + : vi.spyOn(moveIntents, 'removeProfileProjectMoveIntent').mockImplementation(() => { + throw new Error('intent cleanup interrupted') + }) + const reopen = vi.fn(async () => { + const retained = snapshot('source') + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + expect(snapshot('source')).toEqual(retained) + }) + + await expect(transferActiveProfileProject(args, directory, store, reopen)).rejects.toThrow( + `${failure} interrupted` + ) + expect(reopen).toHaveBeenCalledOnce() + expect(snapshot('source').revision).toBe( + (before.revision ?? 0) + (failure === 'source commit' ? 0 : 1) + ) + expect(snapshot('target').state.repos).toHaveLength(1) + interrupted.mockRestore() + + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1) + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(0) + expect(snapshot('source').state.repos).toHaveLength(0) + expect(snapshot('target').state.repos).toHaveLength(1) + const reloaded = openStore() + reloaded.updateSettings({ theme: 'light' }) + reloaded.flushOrThrow() + expect(snapshot('source').state.settings.theme).toBe('light') + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(0) + } + ) + + it('leaves an unchanged SQLite Store writable after validation fails', async () => { + const store = openStore() + const before = snapshot('source') + const reopen = vi.fn(async () => {}) + await expect( + transferActiveProfileProject({ ...args, repoId: 'missing' }, directory, store, reopen) + ).rejects.toThrow('unknown_source_repo') + expect(reopen).not.toHaveBeenCalled() + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + expect(snapshot('source').revision).toBe((before.revision ?? 0) + 1) + expect(snapshot('source').state.settings.theme).toBe('light') + }) + + it('keeps writes fenced when reopening after a partial transfer fails', async () => { + const store = openStore() + const before = snapshot('source') + const interrupted = interruptSourceCommit() + await expect( + transferActiveProfileProject(args, directory, store, async () => { + throw new Error('reopen failed') + }) + ).rejects.toThrow('reopen failed') + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + expect(snapshot('source')).toEqual(before) + interrupted.mockRestore() + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1) + }) + + it('reopens before an outstanding move can change the active Store behind its revision', async () => { + const store = openStore() + const interrupted = interruptSourceCommit() + expect(() => transferOrcaProfileProject(args, directory)).toThrow('source commit interrupted') + interrupted.mockRestore() + const reopen = vi.fn(async () => { + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1) + }) + await expect(transferActiveProfileProject(args, directory, store, reopen)).rejects.toThrow( + 'active_source_orca_profile_move_requires_recovery' + ) + const recovered = snapshot('source') + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + expect(snapshot('source')).toEqual(recovered) + expect(reopen).toHaveBeenCalledOnce() + }) + + it('keeps the Store frozen when an unreadable intent cannot identify its participants', async () => { + const store = openStore() + const before = snapshot('source') + const intentDirectory = join(directory, 'profile-move-intents') + mkdirSync(intentDirectory) + writeFileSync(join(intentDirectory, '11111111-1111-4111-8111-111111111111.json'), '{') + const reopen = vi.fn(async () => { + moveIntents.recoverPendingProfileProjectMoves(directory) + }) + await expect(transferActiveProfileProject(args, directory, store, reopen)).rejects.toThrow( + 'Profile move intent is unreadable' + ) + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + expect(snapshot('source')).toEqual(before) + expect(reopen).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orca-profiles/profile-active-transfer.ts b/src/main/orca-profiles/profile-active-transfer.ts new file mode 100644 index 000000000000..87496fca10cb --- /dev/null +++ b/src/main/orca-profiles/profile-active-transfer.ts @@ -0,0 +1,34 @@ +import type { + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult +} from '../../shared/orca-profiles' +import type { Store } from '../persistence/loading-store/store' +import { transferOrcaProfileProject } from './profile-project-transfer' +import { hasOrcaProfileStateDatabase } from './profile-storage-paths' +import { profileHasPendingProjectMove } from './profile-project-move-intent' + +/** The caller flushes its active Store before this synchronous disk mutation begins. */ +export async function transferActiveProfileProject( + args: TransferOrcaProfileProjectArgs, + userDataPath: string, + store: Pick, + reopenSource: () => Promise +): Promise { + const hadDatabase = hasOrcaProfileStateDatabase(args.sourceProfileId, userDataPath) + try { + if (profileHasPendingProjectMove(args.sourceProfileId, userDataPath)) { + throw new Error('active_source_orca_profile_move_requires_recovery') + } + return transferOrcaProfileProject(args, userDataPath) + } catch (error) { + if ( + (!hadDatabase && hasOrcaProfileStateDatabase(args.sourceProfileId, userDataPath)) || + profileHasPendingProjectMove(args.sourceProfileId, userDataPath) + ) { + // Further writes would invalidate a retained move's recovery revision. + store.freezeWrites() + await reopenSource() + } + throw error + } +} diff --git a/src/main/orca-profiles/profile-cloud-auth-status.test.ts b/src/main/orca-profiles/profile-cloud-auth-status.test.ts index 7a28783e9a97..3db3ccb92e95 100644 --- a/src/main/orca-profiles/profile-cloud-auth-status.test.ts +++ b/src/main/orca-profiles/profile-cloud-auth-status.test.ts @@ -38,6 +38,7 @@ function activeProfile(linked: boolean): ActiveOrcaProfileState { profile, index: { schemaVersion: 1, activeProfileId: profile.id, profiles: [profile] }, dataFile: '', + stateDatabaseFile: '', profileDirectory: '' } } diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts index 1d02c4d7dcc0..09f9ea5b3a01 100644 --- a/src/main/orca-profiles/profile-index-store.test.ts +++ b/src/main/orca-profiles/profile-index-store.test.ts @@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync } from import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' import { createDefaultLocalOrcaProfile, DEFAULT_LOCAL_ORCA_PROFILE_ID, @@ -66,6 +67,9 @@ describe('profile index store', () => { expect(activeProfile.dataFile).toBe( join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') ) + expect(activeProfile.stateDatabaseFile).toBe( + join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'profile-state.db') + ) expect(readJson(activeProfile.dataFile)).toEqual(legacyState) expect(readJson(`${activeProfile.dataFile}.bak.0`)).toEqual(legacyBackup) expect( @@ -115,9 +119,59 @@ describe('profile index store', () => { expect(activeProfile.profile.id).toBe(profileId) expect(activeProfile.dataFile).toBe(join(profileDirectory, 'orca-data.json')) + expect(activeProfile.stateDatabaseFile).toBe(join(profileDirectory, 'profile-state.db')) expect(readJson(activeProfile.dataFile)).toEqual(profileData) }) + it('does not copy legacy JSON into a database-only default profile', async () => { + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ settings: { theme: 'legacy' } }), + 'utf-8' + ) + const profileDirectory = join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + mkdirSync(profileDirectory, { recursive: true }) + const database = openProfileStateDatabase( + join(profileDirectory, 'profile-state.db'), + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + database.db.close() + + const { ensureActiveOrcaProfile } = await loadProfileIndexStore() + const activeProfile = ensureActiveOrcaProfile() + + expect(activeProfile.stateDatabaseFile).toBe(join(profileDirectory, 'profile-state.db')) + expect(existsSync(activeProfile.dataFile)).toBe(false) + expect(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')).toContain('legacy') + }) + + it.each([ + 'orca-data.json.sqlite-export.1.json', + 'profile-state.db.backup.1789999999999-00000000-0000-4000-8000-000000000000.db', + 'profile-state.db-wal', + 'profile-state.db-shm', + 'profile-state.db-journal' + ])('does not seed a stale mirror when %s exists without the database', async (artifact) => { + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ settings: { theme: 'legacy' } }), + 'utf-8' + ) + const profileDirectory = join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(profileDirectory, artifact), + JSON.stringify({ settings: { theme: 'migrated' } }), + 'utf-8' + ) + + const { ensureActiveOrcaProfile } = await loadProfileIndexStore() + const activeProfile = ensureActiveOrcaProfile() + + expect(existsSync(activeProfile.dataFile)).toBe(false) + expect(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')).toContain('legacy') + }) + it('creates an empty local profile without copying legacy state into it', async () => { writeFileSync( join(testState.dir, 'orca-data.json'), diff --git a/src/main/orca-profiles/profile-index-store.ts b/src/main/orca-profiles/profile-index-store.ts index 7897e299a210..9ddc52e1dae1 100644 --- a/src/main/orca-profiles/profile-index-store.ts +++ b/src/main/orca-profiles/profile-index-store.ts @@ -22,23 +22,24 @@ import { type OrcaProfileSummary } from '../../shared/orca-profiles' import { - getOrcaProfileBrowserSessionMetaFile, getOrcaProfileDataFile, getOrcaProfileDirectory, getOrcaProfileIndexPath, - getProfileUserDataPath, - LEGACY_BACKUP_COUNT, - legacyBackupPath, - legacyBrowserSessionMetaPath, - legacyDataFilePath, - profileBackupPath + getOrcaProfileStateDatabaseFile, + hasOrcaProfileStateDatabase, + getProfileUserDataPath } from './profile-storage-paths' +import { copyLegacyStateToProfile } from './profile-legacy-state-import' +import { profileStateJsonExportPaths } from '../persistence/profile-state/profile-state-export-path' +import { profileStateDatabaseBackups } from '../persistence/profile-state/profile-state-backup-path' export { getOrcaProfileBrowserSessionMetaFile, getOrcaProfileDataFile, getOrcaProfileDirectory, getOrcaProfileIndexPath, + getOrcaProfileStateDatabaseFile, + hasOrcaProfileStateDatabase, getOrcaProfilesDirectory, initOrcaProfilePaths } from './profile-storage-paths' @@ -47,6 +48,7 @@ export type ActiveOrcaProfileState = { index: OrcaProfileIndex profile: OrcaProfileSummary dataFile: string + stateDatabaseFile: string profileDirectory: string } @@ -136,30 +138,6 @@ export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): v bestEffortFsyncDirectorySync(dirname(indexPath)) } -function copyIfPresent(source: string, target: string): void { - if (!existsSync(source) || existsSync(target)) { - return - } - mkdirSync(dirname(target), { recursive: true }) - // Why: tmp+rename so a crash mid-copy cannot leave a truncated target that - // the exists() guard above would then treat as a completed migration. - const tmpTarget = `${target}.tmp` - copyFileSync(source, tmpTarget) - renameSync(tmpTarget, target) -} - -function copyLegacyStateToProfile(userDataPath: string, profileId: string): void { - const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath) - copyIfPresent(legacyDataFilePath(userDataPath), profileDataFile) - copyIfPresent( - legacyBrowserSessionMetaPath(userDataPath), - getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath) - ) - for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) { - copyIfPresent(legacyBackupPath(userDataPath, i), profileBackupPath(profileDataFile, i)) - } -} - // Why: a brand-new profile has no data file, which the telemetry cohort // migration reads as a fresh install and defaults to opted-in. Copying the // active profile's consent block keeps an opted-out user opted out (and keeps @@ -230,7 +208,22 @@ export function ensureActiveOrcaProfile( const profileDirectory = getOrcaProfileDirectory(activeProfile.id, userDataPath) mkdirSync(profileDirectory, { recursive: true }) - if (activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID) { + const profileDatabaseFile = getOrcaProfileStateDatabaseFile(activeProfile.id, userDataPath) + const profileDataFile = getOrcaProfileDataFile(activeProfile.id, userDataPath) + let hasRetainedProfileStateExport = false + try { + hasRetainedProfileStateExport = + profileStateJsonExportPaths(profileDataFile).length > 0 || + profileStateDatabaseBackups(profileDatabaseFile).length > 0 + } catch { + // An unreadable profile directory must never trigger a fallback copy of legacy state. + hasRetainedProfileStateExport = true + } + if ( + activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID && + !hasOrcaProfileStateDatabase(activeProfile.id, userDataPath) && + !hasRetainedProfileStateExport + ) { copyLegacyStateToProfile(userDataPath, activeProfile.id) } @@ -241,7 +234,8 @@ export function ensureActiveOrcaProfile( return { index, profile: activeProfile, - dataFile: getOrcaProfileDataFile(activeProfile.id, userDataPath), + dataFile: profileDataFile, + stateDatabaseFile: profileDatabaseFile, profileDirectory } } diff --git a/src/main/orca-profiles/profile-legacy-state-import.ts b/src/main/orca-profiles/profile-legacy-state-import.ts new file mode 100644 index 000000000000..03500f74e267 --- /dev/null +++ b/src/main/orca-profiles/profile-legacy-state-import.ts @@ -0,0 +1,35 @@ +import { copyFileSync, existsSync, mkdirSync, renameSync } from 'node:fs' +import { dirname } from 'node:path' +import { + getOrcaProfileBrowserSessionMetaFile, + getOrcaProfileDataFile, + LEGACY_BACKUP_COUNT, + legacyBackupPath, + legacyBrowserSessionMetaPath, + legacyDataFilePath, + profileBackupPath +} from './profile-storage-paths' + +function copyIfPresent(source: string, target: string): void { + if (!existsSync(source) || existsSync(target)) { + return + } + mkdirSync(dirname(target), { recursive: true }) + // Why: tmp+rename so a crash mid-copy cannot leave a truncated target that + // the exists() guard above would then treat as a completed migration. + const tmpTarget = `${target}.tmp` + copyFileSync(source, tmpTarget) + renameSync(tmpTarget, target) +} + +export function copyLegacyStateToProfile(userDataPath: string, profileId: string): void { + const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath) + copyIfPresent(legacyDataFilePath(userDataPath), profileDataFile) + copyIfPresent( + legacyBrowserSessionMetaPath(userDataPath), + getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath) + ) + for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) { + copyIfPresent(legacyBackupPath(userDataPath, i), profileBackupPath(profileDataFile, i)) + } +} diff --git a/src/main/orca-profiles/profile-project-move-intent.ts b/src/main/orca-profiles/profile-project-move-intent.ts new file mode 100644 index 000000000000..910ac0b97dca --- /dev/null +++ b/src/main/orca-profiles/profile-project-move-intent.ts @@ -0,0 +1,249 @@ +import { randomUUID } from 'node:crypto' +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { basename, join } from 'node:path' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../shared/secure-file' +import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { + readProfileStateWithRevision, + writeSerializedProfileState, + type ReadProfileStateResult +} from './profile-project-state-file' +import { getOrcaProfileMoveIntentDirectory } from './profile-storage-paths' + +const PROFILE_MOVE_INTENT_VERSION = 1 +const INTENT_FILE_PATTERN = /^[0-9a-f-]{36}\.json$/ +const PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ + +export type ProfileProjectMoveIntent = { + version: typeof PROFILE_MOVE_INTENT_VERSION + id: string + sourceProfileId: string + targetProfileId: string + expectedSourceRevision: number + expectedTargetRevision: number + sourceBeforeHash: string + targetBeforeHash: string + sourceAfterHash: string + targetAfterHash: string + sourceAfterJson: string + targetAfterJson: string +} + +export function createProfileProjectMoveIntent(args: { + sourceProfileId: string + targetProfileId: string + source: ReadProfileStateResult + target: ReadProfileStateResult + sourceAfterJson: string + targetAfterJson: string +}): ProfileProjectMoveIntent { + const sourceBeforeJson = requireSerializedSnapshot(args.source, 'source') + const targetBeforeJson = requireSerializedSnapshot(args.target, 'target') + const expectedSourceRevision = requireRevision(args.source, 'source') + const expectedTargetRevision = requireRevision(args.target, 'target') + return { + version: PROFILE_MOVE_INTENT_VERSION, + id: randomUUID(), + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + expectedSourceRevision, + expectedTargetRevision, + sourceBeforeHash: hashProfileStateJson(sourceBeforeJson), + targetBeforeHash: hashProfileStateJson(targetBeforeJson), + sourceAfterHash: hashProfileStateJson(args.sourceAfterJson), + targetAfterHash: hashProfileStateJson(args.targetAfterJson), + sourceAfterJson: args.sourceAfterJson, + targetAfterJson: args.targetAfterJson + } +} + +export function persistProfileProjectMoveIntent( + userDataPath: string, + intent: ProfileProjectMoveIntent +): void { + validateIntent(intent) + const directory = getOrcaProfileMoveIntentDirectory(userDataPath) + mkdirSync(directory, { recursive: true, mode: 0o700 }) + const path = profileProjectMoveIntentPath(userDataPath, intent.id) + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp` + writeFileSync(temporaryPath, JSON.stringify(intent), { encoding: 'utf8', mode: 0o600 }) + fsyncFileSync(temporaryPath) + renameSync(temporaryPath, path) + bestEffortFsyncDirectorySync(directory) +} + +export function removeProfileProjectMoveIntent(userDataPath: string, intentId: string): void { + rmSync(profileProjectMoveIntentPath(userDataPath, intentId), { force: true }) + bestEffortFsyncDirectorySync(getOrcaProfileMoveIntentDirectory(userDataPath)) +} + +export function recoverPendingProfileProjectMoves(userDataPath: string): number { + const intents = readPendingProfileProjectMoveIntents(userDataPath) + for (const intent of intents) { + recoverProfileProjectMoveIntent(userDataPath, intent) + } + return intents.length +} + +export function profileHasPendingProjectMove(profileId: string, userDataPath: string): boolean { + try { + return readPendingProfileProjectMoveIntents(userDataPath).some( + (intent) => intent.sourceProfileId === profileId || intent.targetProfileId === profileId + ) + } catch { + // An unreadable intent cannot rule this profile out as a participant. + return true + } +} + +function readPendingProfileProjectMoveIntents(userDataPath: string): ProfileProjectMoveIntent[] { + const directory = getOrcaProfileMoveIntentDirectory(userDataPath) + return existsSync(directory) + ? readdirSync(directory) + .filter((file) => INTENT_FILE_PATTERN.test(file)) + .map((file) => readProfileProjectMoveIntent(join(directory, file))) + : [] +} + +function recoverProfileProjectMoveIntent( + userDataPath: string, + intent: ProfileProjectMoveIntent +): void { + const source = readProfileStateWithRevision(intent.sourceProfileId, userDataPath) + const target = readProfileStateWithRevision(intent.targetProfileId, userDataPath) + if (source.revision === undefined || target.revision === undefined) { + throw new Error(`Profile move ${intent.id} no longer has two SQLite participants`) + } + + const sourceBefore = matches(source, intent.expectedSourceRevision, intent.sourceBeforeHash) + const targetBefore = matches(target, intent.expectedTargetRevision, intent.targetBeforeHash) + const sourceAfter = matches(source, intent.expectedSourceRevision + 1, intent.sourceAfterHash) + const targetAfter = matches(target, intent.expectedTargetRevision + 1, intent.targetAfterHash) + + if (sourceAfter && targetAfter) { + removeProfileProjectMoveIntent(userDataPath, intent.id) + return + } + if (sourceBefore && targetBefore) { + removeProfileProjectMoveIntent(userDataPath, intent.id) + return + } + if (sourceBefore && targetAfter) { + writeSerializedProfileState(intent.sourceProfileId, userDataPath, intent.sourceAfterJson, { + expectedRevision: intent.expectedSourceRevision + }) + removeProfileProjectMoveIntent(userDataPath, intent.id) + return + } + if (sourceBefore && !targetAfter && !targetBefore) { + throw new Error(`Profile move ${intent.id} has an unrecognized target state`) + } + if (targetAfter && !sourceAfter) { + // A source revision that moved independently means the intent can no longer + // be replayed safely. Leave the journal for an operator or a later repair. + throw new Error(`Profile move ${intent.id} conflicts with a source profile write`) + } + if (sourceAfter && targetBefore) { + throw new Error(`Profile move ${intent.id} has a source commit without its target commit`) + } + throw new Error(`Profile move ${intent.id} has an unrecognized participant state`) +} + +function matches(snapshot: ReadProfileStateResult, revision: number, hash: string): boolean { + return ( + snapshot.revision === revision && + snapshot.serialized !== undefined && + hashProfileStateJson(snapshot.serialized) === hash + ) +} + +function requireSerializedSnapshot(snapshot: ReadProfileStateResult, participant: string): string { + if (snapshot.serialized === undefined) { + throw new Error(`SQLite profile move requires a serialized ${participant} snapshot`) + } + return snapshot.serialized +} + +function requireRevision(snapshot: ReadProfileStateResult, participant: string): number { + if (snapshot.revision === undefined) { + throw new Error(`SQLite profile move requires a ${participant} revision`) + } + return snapshot.revision +} + +function profileProjectMoveIntentPath(userDataPath: string, intentId: string): string { + if (!/^[0-9a-f-]{36}$/.test(intentId)) { + throw new Error('Invalid profile move intent ID') + } + return join(getOrcaProfileMoveIntentDirectory(userDataPath), `${intentId}.json`) +} + +function readProfileProjectMoveIntent(path: string): ProfileProjectMoveIntent { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error( + `Profile move intent is unreadable: ${path}: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (!isRecord(parsed)) { + throw new Error(`Profile move intent is malformed: ${path}`) + } + validateIntent(parsed) + if (basename(path) !== `${parsed.id}.json`) { + throw new Error(`Profile move intent ID does not match its file: ${path}`) + } + return parsed +} + +function validateIntent(value: unknown): asserts value is ProfileProjectMoveIntent { + if (!isRecord(value)) { + throw new Error('Profile move intent is malformed') + } + const intent = value + const expectedSourceRevision = intent.expectedSourceRevision + const expectedTargetRevision = intent.expectedTargetRevision + if ( + intent.version !== PROFILE_MOVE_INTENT_VERSION || + typeof intent.id !== 'string' || + !/^[0-9a-f-]{36}$/.test(intent.id) || + typeof intent.sourceProfileId !== 'string' || + typeof intent.targetProfileId !== 'string' || + !PROFILE_ID_PATTERN.test(intent.sourceProfileId) || + !PROFILE_ID_PATTERN.test(intent.targetProfileId) || + intent.sourceProfileId === intent.targetProfileId || + !Number.isSafeInteger(expectedSourceRevision) || + !Number.isSafeInteger(expectedTargetRevision) || + typeof expectedSourceRevision !== 'number' || + typeof expectedTargetRevision !== 'number' || + expectedSourceRevision < 0 || + expectedTargetRevision < 0 || + !isHash(intent.sourceBeforeHash) || + !isHash(intent.targetBeforeHash) || + !isHash(intent.sourceAfterHash) || + !isHash(intent.targetAfterHash) || + typeof intent.sourceAfterJson !== 'string' || + typeof intent.targetAfterJson !== 'string' || + hashProfileStateJson(intent.sourceAfterJson) !== intent.sourceAfterHash || + hashProfileStateJson(intent.targetAfterJson) !== intent.targetAfterHash + ) { + throw new Error('Profile move intent is malformed') + } +} + +function isHash(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index d4f291a09e4e..366c5bd2ee66 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -15,29 +15,110 @@ import type { Repo } from '../../shared/repo-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { SparsePreset } from '../../shared/worktree/create-types' import type { RetiredNameRegistry } from '../../shared/worktree/retired-name-registry' -import { getOrcaProfileDataFile } from './profile-index-store' +import { getOrcaProfileDataFile, getOrcaProfileStateDatabaseFile } from './profile-index-store' +import { + importProfileStateJson, + profileStateJsonMatchesAcceptance, + readProfileStateRevision, + readProfileStateSnapshot +} from '../persistence/profile-state/profile-state-documents' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../persistence/profile-state/profile-state-database' +import { assertNoRetainedProfileStateExports } from '../persistence/profile-state/profile-state-recovery-required' +import { hasProfileStateDatabaseFiles } from '../persistence/profile-state/profile-state-storage-classification' export type TransferProfileState = PersistedState -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) +export type ReadProfileStateResult = { + state: TransferProfileState + /** SQLite profile revision observed with the state snapshot; absent for legacy JSON. */ + revision?: number + /** Exact compact JSON projection observed with the state snapshot. */ + serialized?: string } -function arrayOrEmpty(value: unknown): T[] { - return Array.isArray(value) ? value : [] +/** A profile must have one unambiguous transfer source. */ +export class AmbiguousProfileStateStorageError extends Error { + readonly code = 'ambiguous_profile_state_storage' as const + + constructor(profileId: string, message?: string) { + super(message ?? `Profile ${profileId} has both SQLite and legacy JSON state`) + this.name = 'AmbiguousProfileStateStorageError' + } } -function recordOrEmpty(value: unknown): Record { - return isRecord(value) ? value : {} +export type ProfileStateStorage = 'json' | 'sqlite' + +export function profileStateStorage(profileId: string, userDataPath: string): ProfileStateStorage { + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + const hasJson = existsSync(dataFile) + const hasDatabase = hasProfileStateDatabaseFiles(databaseFile) + if (hasJson && hasDatabase) { + assertAcceptedLegacyJsonMirror(profileId, dataFile, databaseFile) + return 'sqlite' + } + if (!hasDatabase) { + assertNoRetainedProfileStateExports({ dataFile, databaseFile, profileId }) + } + return hasDatabase ? 'sqlite' : 'json' } -export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { +/** + * A migrated profile may retain its JSON export during the rollback window. + * Select SQLite only when its acceptance marker still names the exact export; + * any edit, missing marker, or corrupt database remains fail-closed. + */ +function assertAcceptedLegacyJsonMirror( + profileId: string, + dataFile: string, + databaseFile: string +): void { + const rawJson = readFileSync(dataFile, 'utf-8') + const opened = openProfileStateDatabaseReadOnly(databaseFile, profileId) + try { + if (!profileStateJsonMatchesAcceptance(opened.db, rawJson)) { + throw new AmbiguousProfileStateStorageError(profileId) + } + } finally { + opened.db.close() + } +} + +/** Read one profile state and retain the SQLite revision that fenced that snapshot. */ +export function readProfileStateWithRevision( + profileId: string, + userDataPath: string +): ReadProfileStateResult { + const storage = profileStateStorage(profileId, userDataPath) + if (storage === 'json') { + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + const serialized = existsSync(dataFile) ? readFileSync(dataFile, 'utf-8') : undefined + return { state: parseProfileState(serialized), ...(serialized ? { serialized } : {}) } + } + + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + const opened = openProfileStateDatabaseReadOnly(databaseFile, profileId) + try { + const snapshot = readProfileStateSnapshot(opened.db) + return { + state: parseProfileState(snapshot.json), + revision: snapshot.revision, + serialized: snapshot.json + } + } finally { + opened.db.close() + } +} + +function parseProfileState(rawJson: string | undefined): TransferProfileState { const defaults = getDefaultPersistedState(homedir()) - const dataFile = getOrcaProfileDataFile(profileId, userDataPath) - if (!existsSync(dataFile)) { + if (rawJson === undefined) { return structuredClone(defaults) } - const parsed: Partial = JSON.parse(readFileSync(dataFile, 'utf-8')) + const parsed: Partial = JSON.parse(rawJson) return rebuildRepoBackedProjectState({ ...defaults, ...parsed, @@ -91,15 +172,56 @@ export function readProfileState(profileId: string, userDataPath: string): Trans }) } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function arrayOrEmpty(value: unknown): T[] { + return Array.isArray(value) ? value : [] +} + +function recordOrEmpty(value: unknown): Record { + return isRecord(value) ? value : {} +} + +export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { + return readProfileStateWithRevision(profileId, userDataPath).state +} + export function writeProfileState( profileId: string, userDataPath: string, - state: TransferProfileState + state: TransferProfileState, + options: { expectedRevision?: number } = {} +): void { + writeSerializedProfileState(profileId, userDataPath, JSON.stringify(state), options) +} + +/** Write an already validated JSON projection while preserving its exact bytes in SQLite. */ +export function writeSerializedProfileState( + profileId: string, + userDataPath: string, + serialized: string, + options: { expectedRevision?: number } = {} ): void { + const storage = profileStateStorage(profileId, userDataPath) + if (storage === 'sqlite') { + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + importProfileStateJson(opened.db, serialized, { + expectedRevision: options.expectedRevision ?? readProfileStateRevision(opened.db) + }) + } finally { + opened.db.close() + } + return + } + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) mkdirSync(dirname(dataFile), { recursive: true }) const tmpPath = `${dataFile}.${process.pid}.${randomUUID()}.tmp` - writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8') + writeFileSync(tmpPath, serialized, 'utf-8') renameSync(tmpPath, dataFile) } diff --git a/src/main/orca-profiles/profile-project-transfer-migration.test.ts b/src/main/orca-profiles/profile-project-transfer-migration.test.ts new file mode 100644 index 000000000000..9c2e18228e9e --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer-migration.test.ts @@ -0,0 +1,278 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' +import type { Repo } from '../../shared/repo-types' +import * as profileStateDocuments from '../persistence/profile-state/profile-state-documents' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from '../persistence/profile-state/profile-state-backup-path' +import { transferOrcaProfileProject } from './profile-project-transfer' +import { readProfileStateWithRevision } from './profile-project-state-file' +import { recoverPendingProfileProjectMoves } from './profile-project-move-intent' +import * as profileProjectStateFile from './profile-project-state-file' + +vi.mock('../persistence/loading-store/store', () => { + throw new Error('Profile transfers must not load inactive Stores') +}) + +const repo: Repo = { + id: 'repo-1', + path: '/projects/folder', + displayName: 'Folder', + badgeColor: 'neutral', + addedAt: 1, + kind: 'folder', + connectionId: null +} +let directory: string + +function paths(profileId: string): { dataFile: string; databaseFile: string } { + return { + dataFile: join(directory, 'profiles', profileId, 'orca-data.json'), + databaseFile: join(directory, 'profiles', profileId, 'profile-state.db') + } +} + +function writeState(profileId: string, sqlite: boolean, repos: Repo[] = []): string { + const defaults = getDefaultPersistedState('/home/test') + const source = JSON.stringify( + { + ...defaults, + repos, + futureDomain: { profileId }, + settings: { ...defaults.settings, opencodeSessionCookie: 'enc:v1:sealed-inactive-secret' } + }, + null, + 2 + ) + const location = paths(profileId) + mkdirSync(join(location.dataFile, '..'), { recursive: true }) + if (sqlite) { + const opened = openProfileStateDatabase(location.databaseFile, profileId) + try { + profileStateDocuments.importProfileStateJson(opened.db, source) + } finally { + opened.db.close() + } + } else { + writeFileSync(location.dataFile, source) + } + return source +} + +function transfer(mode: 'copy' | 'move' = 'move') { + return transferOrcaProfileProject( + { + sourceProfileId: 'source', + targetProfileId: 'target', + repoId: repo.id, + mode + }, + directory + ) +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-profile-transfer-migration-')) + writeFileSync( + join(directory, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: 'source', + profiles: ['source', 'target'].map((id) => ({ + id, + name: id, + avatar: { kind: 'initials', initials: id[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + })) + }) + ) +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(directory, { recursive: true, force: true }) +}) + +describe('profile transfer migration', () => { + it.each(['copy', 'move'] as const)( + '%s adopts an unopened JSON target without loading Store', + (mode) => { + writeState('source', true, [repo]) + const targetJson = writeState('target', false) + expect(transfer(mode)).toMatchObject({ status: 'transferred', mode }) + + const target = readProfileStateWithRevision('target', directory) + expect(target.revision).toBe(2) + expect(target.state.repos).toHaveLength(1) + expect(target.state.repos[0]).toMatchObject({ kind: 'folder', path: repo.path }) + expect(target.state.settings.opencodeSessionCookie).toBe('enc:v1:sealed-inactive-secret') + expect(JSON.parse(target.serialized ?? '{}').futureDomain).toEqual({ profileId: 'target' }) + expect(readFileSync(paths('target').dataFile, 'utf8')).toBe(targetJson) + expect(existsSync(`${paths('target').dataFile}.sqlite-export.1.json`)).toBe(true) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength( + mode === 'move' ? 0 : 1 + ) + } + ) + + it('initializes a target with no saved JSON before its move intent is created', () => { + writeState('source', true, [repo]) + expect(transfer()).toMatchObject({ status: 'transferred' }) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + expect(existsSync(paths('target').dataFile)).toBe(false) + expect(existsSync(paths('target').databaseFile)).toBe(true) + }) + + it('migrates a JSON source before moving into SQLite and retains its exact rollback bytes', () => { + const sourceJson = writeState('source', false, [repo]) + writeState('target', true) + expect(transfer()).toMatchObject({ status: 'transferred' }) + expect(readProfileStateWithRevision('source', directory)).toMatchObject({ + revision: 2, + state: { repos: [] } + }) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + expect(readFileSync(paths('source').dataFile, 'utf8')).toBe(sourceJson) + }) + + it('copies from JSON into SQLite without migrating or changing the source', () => { + const sourceJson = writeState('source', false, [repo]) + writeState('target', true) + expect(transfer('copy')).toMatchObject({ status: 'transferred' }) + expect(readFileSync(paths('source').dataFile, 'utf8')).toBe(sourceJson) + expect(existsSync(paths('source').databaseFile)).toBe(false) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + }) + + it('keeps JSON-only transfers compatible on runtimes without SQLite', () => { + writeState('source', false, [repo]) + writeState('target', false) + const getBuiltin = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((name) => + name === 'node:sqlite' ? undefined : getBuiltin(name) + ) + expect(transfer()).toMatchObject({ status: 'transferred' }) + expect(existsSync(paths('source').databaseFile)).toBe(false) + expect(existsSync(paths('target').databaseFile)).toBe(false) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(0) + }) + + it('refuses mixed storage on a runtime without SQLite before migrating or editing JSON', () => { + const sourceJson = writeState('source', false, [repo]) + writeState('target', true) + const getBuiltin = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((name) => + name === 'node:sqlite' ? undefined : getBuiltin(name) + ) + expect(() => transfer()).toThrow('Unable to open profile state database') + expect(readFileSync(paths('source').dataFile, 'utf8')).toBe(sourceJson) + expect(existsSync(paths('source').databaseFile)).toBe(false) + }) + + it('refuses to adopt stale target JSON when a retained database backup proves missing authority', () => { + writeState('source', true, [repo]) + writeState('target', false) + const backupPath = profileStateDatabaseBackupPath( + paths('target').databaseFile, + createProfileStateDatabaseBackupId(1) + ) + writeFileSync(backupPath, 'retained recovery evidence') + expect(() => transfer()).toThrowError( + expect.objectContaining({ + code: 'profile-state-recovery-required', + backupPaths: [backupPath] + }) + ) + expect(existsSync(paths('target').databaseFile)).toBe(false) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + }) + + it('does not migrate a duplicate target', () => { + writeState('source', true, [repo]) + writeState('target', false, [repo]) + expect(transfer()).toMatchObject({ status: 'duplicate-target' }) + expect(existsSync(paths('target').databaseFile)).toBe(false) + }) + + it('leaves both participants untouched when the import fails before publication', () => { + writeState('source', true, [repo]) + const targetJson = writeState('target', false) + vi.spyOn(profileStateDocuments, 'importProfileStateJson').mockImplementation(() => { + throw new Error('import failure') + }) + expect(() => transfer()).toThrow('import failure') + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + expect(readFileSync(paths('target').dataFile, 'utf8')).toBe(targetJson) + expect(existsSync(paths('target').databaseFile)).toBe(false) + expect(readdirSync(join(paths('target').dataFile, '..'))).toEqual(['orca-data.json']) + }) + + it('rejects a JSON edit during migration before publishing SQLite', () => { + writeState('source', true, [repo]) + writeState('target', false) + const originalImport = profileStateDocuments.importProfileStateJson + vi.spyOn(profileStateDocuments, 'importProfileStateJson').mockImplementation((...args) => { + const revision = originalImport(...args) + writeFileSync(paths('target').dataFile, '{"settings":{"theme":"light"}}') + return revision + }) + expect(() => transfer()).toThrow('JSON changed while importing') + expect(existsSync(paths('target').databaseFile)).toBe(false) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + }) + + it('keeps a valid migrated participant after export failure without moving the project', () => { + writeState('source', true, [repo]) + const targetJson = writeState('target', false) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'writeJsonExport').mockImplementation(() => { + throw new Error('export failure') + }) + expect(() => transfer()).toThrow('export failure') + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(0) + expect(readFileSync(paths('target').dataFile, 'utf8')).toBe(targetJson) + }) + + it.each([false, true])( + 'replays an interrupted move after migrating JSON source=%s', + (sourceJson) => { + writeState('source', !sourceJson, [repo]) + writeState('target', sourceJson) + const originalWrite = profileProjectStateFile.writeProfileState + const write = vi + .spyOn(profileProjectStateFile, 'writeProfileState') + .mockImplementation((profileId, ...rest) => { + if (profileId === 'source') { + throw new Error('source commit interrupted') + } + return originalWrite(profileId, ...rest) + }) + expect(() => transfer()).toThrow('source commit interrupted') + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + write.mockRestore() + expect(recoverPendingProfileProjectMoves(directory)).toBe(1) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(0) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + expect(recoverPendingProfileProjectMoves(directory)).toBe(0) + } + ) +}) diff --git a/src/main/orca-profiles/profile-project-transfer-migration.ts b/src/main/orca-profiles/profile-project-transfer-migration.ts new file mode 100644 index 000000000000..759940ffe659 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer-migration.ts @@ -0,0 +1,26 @@ +import { migrateProfileStateToSqlite } from '../persistence/profile-state/profile-state-migration' +import { getOrcaProfileDataFile, getOrcaProfileStateDatabaseFile } from './profile-storage-paths' +import { + readProfileStateWithRevision, + type ReadProfileStateResult +} from './profile-project-state-file' + +/** Adopt inactive storage without running Store's active-profile listeners or secret transforms. */ +export function migrateProfileProjectTransferParticipant( + profileId: string, + userDataPath: string, + snapshot: ReadProfileStateResult +): ReadProfileStateResult { + const migrated = migrateProfileStateToSqlite({ + dataFile: getOrcaProfileDataFile(profileId, userDataPath), + databaseFile: getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId, + expectedLegacyJson: snapshot.serialized, + serializedState: snapshot.serialized ?? '{}' + }) + try { + return readProfileStateWithRevision(profileId, userDataPath) + } finally { + migrated.authority.close() + } +} diff --git a/src/main/orca-profiles/profile-project-transfer.test.ts b/src/main/orca-profiles/profile-project-transfer.test.ts index fc40cd03bb67..4a037d8edff2 100644 --- a/src/main/orca-profiles/profile-project-transfer.test.ts +++ b/src/main/orca-profiles/profile-project-transfer.test.ts @@ -1,4 +1,12 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' @@ -11,6 +19,17 @@ import type { PersistedState } from '../../shared/persisted-state-types' import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { SshTarget } from '../../shared/ssh-types' +import { + exportProfileStateJson, + hashProfileStateJson, + importProfileStateJson +} from '../persistence/profile-state/profile-state-documents' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { + createProfileProjectMoveIntent, + persistProfileProjectMoveIntent, + recoverPendingProfileProjectMoves +} from './profile-project-move-intent' const testState = { dir: '' } @@ -50,12 +69,49 @@ function profileDataPath(profileId: string): string { return join(testState.dir, 'profiles', profileId, 'orca-data.json') } +function profileDatabasePath(profileId: string): string { + return join(testState.dir, 'profiles', profileId, 'profile-state.db') +} + function writeProfileState(profileId: string, state: PersistedState): void { const dataFile = profileDataPath(profileId) mkdirSync(join(dataFile, '..'), { recursive: true }) writeFileSync(dataFile, JSON.stringify(state, null, 2), 'utf-8') } +function writeProfileStateDatabase(profileId: string, state: PersistedState): void { + const databasePath = profileDatabasePath(profileId) + mkdirSync(join(databasePath, '..'), { recursive: true }) + const opened = openProfileStateDatabase(databasePath, profileId) + try { + importProfileStateJson(opened.db, JSON.stringify(state)) + } finally { + opened.db.close() + } +} + +function writeProfileStateDatabaseWithAcceptedLegacyJson(profileId: string, rawJson: string): void { + const databasePath = profileDatabasePath(profileId) + mkdirSync(join(databasePath, '..'), { recursive: true }) + const opened = openProfileStateDatabase(databasePath, profileId) + try { + importProfileStateJson(opened.db, rawJson, { + acceptedLegacyJsonHash: hashProfileStateJson(rawJson) + }) + } finally { + opened.db.close() + } +} + +function readProfileStateDatabase(profileId: string): PersistedState { + const opened = openProfileStateDatabase(profileDatabasePath(profileId), profileId) + try { + return JSON.parse(exportProfileStateJson(opened.db)) + } finally { + opened.db.close() + } +} + function readProfileState(profileId: string): PersistedState { return JSON.parse(readFileSync(profileDataPath(profileId), 'utf-8')) as PersistedState } @@ -324,4 +380,352 @@ describe('profile project transfer', () => { }) expect(readProfileState('work').repos.map((repo) => repo.id)).toEqual(['repo-existing']) }) + + it('transfers between SQLite-backed profiles without creating legacy JSON or touching sidecars', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileStateDatabase('personal', sourceState) + writeProfileStateDatabase('work', makeState()) + const sidecarPath = join(testState.dir, 'profiles', 'work', 'browser-session-meta.json') + writeFileSync(sidecarPath, '{"preserve":true}', 'utf-8') + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('work').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + expect(existsSync(profileDataPath('personal'))).toBe(false) + expect(existsSync(profileDataPath('work'))).toBe(false) + expect(readFileSync(sidecarPath, 'utf-8')).toBe('{"preserve":true}') + }) + + it('keeps the legacy JSON backend when neither profile has a database', async () => { + writeProfileState('personal', makeState({ repos: [makeRepo()] })) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileState('work').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + expect(existsSync(profileDatabasePath('personal'))).toBe(false) + expect(existsSync(profileDatabasePath('work'))).toBe(false) + }) + + it('fails closed when a profile has both database and legacy JSON state', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileState('personal', sourceState) + writeProfileStateDatabase('personal', sourceState) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + expect(() => + transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + ).toThrowError(expect.objectContaining({ code: 'ambiguous_profile_state_storage' })) + }) + + it('uses SQLite when the retained legacy JSON is the accepted migration export', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileState('personal', sourceState) + const sourceJson = readFileSync(profileDataPath('personal'), 'utf-8') + writeProfileStateDatabaseWithAcceptedLegacyJson('personal', sourceJson) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('work').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + expect(readFileSync(profileDataPath('personal'), 'utf-8')).toBe(sourceJson) + expect(readProfileStateDatabase('personal').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + }) + + it('rejects a missing SQLite database when a retained export proves JSON is stale', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileState('personal', sourceState) + const sourceJson = readFileSync(profileDataPath('personal'), 'utf-8') + writeProfileStateDatabaseWithAcceptedLegacyJson('personal', sourceJson) + rmSync(profileDatabasePath('personal')) + writeFileSync(`${profileDataPath('personal')}.sqlite-export.1.json`, sourceJson) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + expect(() => + transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + ).toThrowError(expect.objectContaining({ code: 'profile-state-recovery-required' })) + }) + + it.each(['-wal', '-shm', '-journal'])( + 'refuses a transfer from an orphaned %s', + async (suffix) => { + writeProfileState('personal', makeState({ repos: [makeRepo()] })) + writeProfileState('work', makeState()) + const sourceJson = readFileSync(profileDataPath('personal'), 'utf8') + const targetJson = readFileSync(profileDataPath('work'), 'utf8') + const sidecar = `${profileDatabasePath('personal')}${suffix}` + writeFileSync(sidecar, 'orphaned recovery evidence') + + const { transferOrcaProfileProject } = await loadTransferModule() + expect(() => + transferOrcaProfileProject( + { sourceProfileId: 'personal', targetProfileId: 'work', repoId: 'repo-1', mode: 'move' }, + testState.dir + ) + ).toThrow() + expect(readFileSync(profileDataPath('personal'), 'utf8')).toBe(sourceJson) + expect(readFileSync(profileDataPath('work'), 'utf8')).toBe(targetJson) + expect(readFileSync(sidecar, 'utf8')).toBe('orphaned recovery evidence') + expect(existsSync(profileDatabasePath('personal'))).toBe(false) + expect(existsSync(profileDatabasePath('work'))).toBe(false) + } + ) + + it.each(['copy', 'move'] as const)( + '%s transfers between SQLite-only profiles while retaining rollback exports', + async (mode) => { + const sourceState = makeState({ repos: [makeRepo()] }) + const targetState = makeState() + writeProfileStateDatabase('personal', sourceState) + writeProfileStateDatabase('work', targetState) + const sourceExport = JSON.stringify(sourceState) + const targetExport = JSON.stringify(targetState) + const sourceExportPath = `${profileDataPath('personal')}.sqlite-export.1.json` + const targetExportPath = `${profileDataPath('work')}.sqlite-export.1.json` + writeFileSync(sourceExportPath, sourceExport) + writeFileSync(targetExportPath, targetExport) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('personal').repos).toHaveLength(mode === 'move' ? 0 : 1) + expect(readProfileStateDatabase('work').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + expect(existsSync(profileDataPath('personal'))).toBe(false) + expect(existsSync(profileDataPath('work'))).toBe(false) + expect(readFileSync(sourceExportPath, 'utf8')).toBe(sourceExport) + expect(readFileSync(targetExportPath, 'utf8')).toBe(targetExport) + } + ) + + it('fences a SQLite transfer write against the revision that was read', async () => { + writeProfileStateDatabase('work', makeState()) + + await loadTransferModule() + const stateFile = await import('./profile-project-state-file') + const observed = stateFile.readProfileStateWithRevision('work', testState.dir) + expect(observed.revision).toBeGreaterThan(0) + + const opened = openProfileStateDatabase(profileDatabasePath('work'), 'work') + try { + importProfileStateJson( + opened.db, + JSON.stringify(makeState({ settings: { ...makeState().settings, theme: 'dark' } })) + ) + } finally { + opened.db.close() + } + + expect(() => + stateFile.writeProfileState('work', testState.dir, makeState(), { + expectedRevision: observed.revision + }) + ).toThrowError(expect.objectContaining({ code: 'profile-state-revision-conflict' })) + expect(readProfileStateDatabase('work').settings.theme).toBe('dark') + }) + + it('moves between SQLite-backed profiles through a durable cross-profile intent', async () => { + writeProfileStateDatabase('personal', makeState({ repos: [makeRepo()] })) + writeProfileStateDatabase('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }, + testState.dir + ) + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('personal').repos).toHaveLength(0) + expect(readProfileStateDatabase('work').repos).toHaveLength(1) + expect( + readdirSync(join(testState.dir, 'profile-move-intents')).filter((file) => + file.endsWith('.json') + ) + ).toEqual([]) + }) + + it('moves between rollback-window profiles while retaining their JSON exports', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileState('personal', sourceState) + writeProfileState('work', makeState()) + const sourceJson = readFileSync(profileDataPath('personal'), 'utf-8') + const targetJson = readFileSync(profileDataPath('work'), 'utf-8') + writeProfileStateDatabaseWithAcceptedLegacyJson('personal', sourceJson) + writeProfileStateDatabaseWithAcceptedLegacyJson('work', targetJson) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('personal').repos).toHaveLength(0) + expect(readProfileStateDatabase('work').repos).toHaveLength(1) + expect(readFileSync(profileDataPath('personal'), 'utf-8')).toBe(sourceJson) + expect(readFileSync(profileDataPath('work'), 'utf-8')).toBe(targetJson) + }) + + it('migrates a legacy JSON target before moving a SQLite-backed project', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileStateDatabase('personal', sourceState) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + expect( + transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }, + testState.dir + ) + ).toMatchObject({ status: 'transferred', mode: 'move' }) + expect(readProfileStateDatabase('personal').repos).toHaveLength(0) + expect(readProfileStateDatabase('work').repos).toHaveLength(1) + expect(readProfileState('work').repos).toHaveLength(0) + }) + + it.each([undefined, 'prepared', 'target-committed'])( + 'replays a move after the target commit with legacy phase=%s', + async (phase) => { + writeProfileStateDatabase('personal', makeState({ repos: [makeRepo()] })) + writeProfileStateDatabase('work', makeState()) + const stateFile = await import('./profile-project-state-file') + const source = stateFile.readProfileStateWithRevision('personal', testState.dir) + const target = stateFile.readProfileStateWithRevision('work', testState.dir) + const sourceAfter = makeState() + const targetAfter = makeState({ repos: [makeRepo()] }) + const intent = createProfileProjectMoveIntent({ + sourceProfileId: 'personal', + targetProfileId: 'work', + source, + target, + sourceAfterJson: JSON.stringify(sourceAfter), + targetAfterJson: JSON.stringify(targetAfter) + }) + const historicalIntent = phase === undefined ? intent : { ...intent, phase } + persistProfileProjectMoveIntent(testState.dir, historicalIntent) + stateFile.writeProfileState('work', testState.dir, targetAfter, { + expectedRevision: target.revision + }) + + expect(recoverPendingProfileProjectMoves(testState.dir)).toBe(1) + expect(recoverPendingProfileProjectMoves(testState.dir)).toBe(0) + expect(readProfileStateDatabase('personal').repos).toHaveLength(0) + expect(readProfileStateDatabase('work').repos).toHaveLength(1) + expect( + readdirSync(join(testState.dir, 'profile-move-intents')).filter((file) => + file.endsWith('.json') + ) + ).toEqual([]) + } + ) + + it('refuses a move intent whose after-state bytes no longer match their hashes', async () => { + writeProfileStateDatabase('personal', makeState({ repos: [makeRepo()] })) + writeProfileStateDatabase('work', makeState()) + const stateFile = await import('./profile-project-state-file') + const source = stateFile.readProfileStateWithRevision('personal', testState.dir) + const target = stateFile.readProfileStateWithRevision('work', testState.dir) + const intent = createProfileProjectMoveIntent({ + sourceProfileId: 'personal', + targetProfileId: 'work', + source, + target, + sourceAfterJson: JSON.stringify(makeState()), + targetAfterJson: JSON.stringify(makeState({ repos: [makeRepo()] })) + }) + persistProfileProjectMoveIntent(testState.dir, intent) + stateFile.writeProfileState('work', testState.dir, makeState({ repos: [makeRepo()] }), { + expectedRevision: target.revision + }) + const intentPath = join(testState.dir, 'profile-move-intents', `${intent.id}.json`) + const tampered = JSON.parse(readFileSync(intentPath, 'utf8')) + tampered.sourceAfterJson = JSON.stringify( + makeState({ settings: { ...makeState().settings, theme: 'light' } }) + ) + writeFileSync(intentPath, JSON.stringify(tampered), 'utf8') + + expect(() => recoverPendingProfileProjectMoves(testState.dir)).toThrow(/intent is malformed/) + expect(readProfileStateDatabase('personal').repos).toHaveLength(1) + expect(existsSync(intentPath)).toBe(true) + }) }) diff --git a/src/main/orca-profiles/profile-project-transfer.ts b/src/main/orca-profiles/profile-project-transfer.ts index 75022d3ad656..f0cbb085ea0a 100644 --- a/src/main/orca-profiles/profile-project-transfer.ts +++ b/src/main/orca-profiles/profile-project-transfer.ts @@ -3,7 +3,7 @@ import type { TransferOrcaProfileProjectResult } from '../../shared/orca-profiles' import { getOrcaProfileListState } from './profile-index-store' -import { readProfileState, writeProfileState } from './profile-project-state-file' +import { readProfileStateWithRevision, writeProfileState } from './profile-project-state-file' import { removeSourceRepo } from './profile-project-source-removal' import { applyPayloadToTarget, @@ -11,6 +11,14 @@ import { createTransferPayload } from './profile-project-transfer-payload' import { repoPhysicalKey } from './profile-project-worktree-identity' +import { migrateProfileProjectTransferParticipant } from './profile-project-transfer-migration' +import { + createProfileProjectMoveIntent, + persistProfileProjectMoveIntent, + recoverPendingProfileProjectMoves, + removeProfileProjectMoveIntent, + type ProfileProjectMoveIntent +} from './profile-project-move-intent' function assertKnownProfiles(args: TransferOrcaProfileProjectArgs, userDataPath: string): void { const profiles = getOrcaProfileListState(userDataPath).profiles @@ -30,9 +38,12 @@ export function transferOrcaProfileProject( args: TransferOrcaProfileProjectArgs, userDataPath: string ): TransferOrcaProfileProjectResult { + recoverPendingProfileProjectMoves(userDataPath) assertKnownProfiles(args, userDataPath) - const sourceState = readProfileState(args.sourceProfileId, userDataPath) - const targetState = readProfileState(args.targetProfileId, userDataPath) + let sourceSnapshot = readProfileStateWithRevision(args.sourceProfileId, userDataPath) + let targetSnapshot = readProfileStateWithRevision(args.targetProfileId, userDataPath) + const sourceState = sourceSnapshot.state + const targetState = targetSnapshot.state const sourceRepo = sourceState.repos.find((repo) => repo.id === args.repoId) if (!sourceRepo) { throw new Error('unknown_source_repo') @@ -50,6 +61,25 @@ export function transferOrcaProfileProject( } } + let moveIntent: ProfileProjectMoveIntent | undefined + if (sourceSnapshot.revision !== undefined && targetSnapshot.revision === undefined) { + targetSnapshot = migrateProfileProjectTransferParticipant( + args.targetProfileId, + userDataPath, + targetSnapshot + ) + } else if ( + args.mode === 'move' && + sourceSnapshot.revision === undefined && + targetSnapshot.revision !== undefined + ) { + sourceSnapshot = migrateProfileProjectTransferParticipant( + args.sourceProfileId, + userDataPath, + sourceSnapshot + ) + } + const targetRepo = createTargetRepo(sourceRepo, targetState, args.mode === 'copy') const payload = createTransferPayload({ sourceState, @@ -57,13 +87,36 @@ export function transferOrcaProfileProject( targetRepo, includeSessions: args.mode === 'move' }) - writeProfileState(args.targetProfileId, userDataPath, applyPayloadToTarget(targetState, payload)) - if (args.mode === 'move') { + const targetAfterState = applyPayloadToTarget(targetState, payload) + const sourceAfterState = + args.mode === 'move' ? removeSourceRepo(sourceState, sourceRepo.id) : undefined + if (sourceAfterState !== undefined && sourceSnapshot.revision !== undefined) { + moveIntent = createProfileProjectMoveIntent({ + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + source: sourceSnapshot, + target: targetSnapshot, + sourceAfterJson: JSON.stringify(sourceAfterState), + targetAfterJson: JSON.stringify(targetAfterState) + }) + persistProfileProjectMoveIntent(userDataPath, moveIntent) + } + writeProfileState( + args.targetProfileId, + userDataPath, + targetAfterState, + targetSnapshot.revision === undefined ? {} : { expectedRevision: targetSnapshot.revision } + ) + if (sourceAfterState !== undefined) { writeProfileState( args.sourceProfileId, userDataPath, - removeSourceRepo(sourceState, sourceRepo.id) + sourceAfterState, + sourceSnapshot.revision === undefined ? {} : { expectedRevision: sourceSnapshot.revision } ) + if (moveIntent) { + removeProfileProjectMoveIntent(userDataPath, moveIntent.id) + } } return { status: 'transferred', diff --git a/src/main/orca-profiles/profile-storage-paths.ts b/src/main/orca-profiles/profile-storage-paths.ts index 81dc990b0e1c..1e8de1a367e6 100644 --- a/src/main/orca-profiles/profile-storage-paths.ts +++ b/src/main/orca-profiles/profile-storage-paths.ts @@ -1,12 +1,17 @@ import { getAppEnvironment } from '../../shared/app-environment' +import { + getOrcaProfileDataFile as getSharedOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile as getSharedOrcaProfileStateDatabaseFile +} from '../../shared/profile-state-storage-paths' +import { hasProfileStateDatabaseFiles } from '../persistence/profile-state/profile-state-storage-classification' import { join } from 'node:path' const LEGACY_DATA_FILE_NAME = 'orca-data.json' const LEGACY_BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' const PROFILE_INDEX_FILE_NAME = 'orca-profile-index.json' -const PROFILE_DATA_FILE_NAME = 'orca-data.json' const PROFILE_BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' const PROFILE_DIRECTORY_NAME = 'profiles' +const PROFILE_MOVE_INTENT_DIRECTORY_NAME = 'profile-move-intents' export const LEGACY_BACKUP_COUNT = 5 @@ -31,6 +36,11 @@ export function getOrcaProfilesDirectory(userDataPath = getProfileUserDataPath() return join(userDataPath, PROFILE_DIRECTORY_NAME) } +/** Durable cross-profile move intents live outside either profile database. */ +export function getOrcaProfileMoveIntentDirectory(userDataPath = getProfileUserDataPath()): string { + return join(userDataPath, PROFILE_MOVE_INTENT_DIRECTORY_NAME) +} + export function getOrcaProfileDirectory( profileId: string, userDataPath = getProfileUserDataPath() @@ -42,7 +52,26 @@ export function getOrcaProfileDataFile( profileId: string, userDataPath = getProfileUserDataPath() ): string { - return join(getOrcaProfileDirectory(profileId, userDataPath), PROFILE_DATA_FILE_NAME) + return getSharedOrcaProfileDataFile(profileId, userDataPath) +} + +/** + * Return the future profile-state database path without changing the legacy + * JSON path used by the current Store and its sidecars. + */ +export function getOrcaProfileStateDatabaseFile( + profileId: string, + userDataPath = getProfileUserDataPath() +): string { + return getSharedOrcaProfileStateDatabaseFile(profileId, userDataPath) +} + +export function hasOrcaProfileStateDatabase( + profileId: string, + userDataPath = getProfileUserDataPath() +): boolean { + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + return hasProfileStateDatabaseFiles(databaseFile) } export function getOrcaProfileBrowserSessionMetaFile( diff --git a/src/main/orcad/orcad-entry.test.ts b/src/main/orcad/orcad-entry.test.ts new file mode 100644 index 000000000000..3cd4e9838499 --- /dev/null +++ b/src/main/orcad/orcad-entry.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import { flushOrcadProfileStoreForShutdown } from './orcad-lifecycle' + +describe('orcad profile-state shutdown', () => { + it('flushes durably before closing the profile store', async () => { + const events: string[] = [] + const store = { + flushPendingOrThrowAsync: vi.fn(async () => { + events.push('flush') + }), + freezeWrites: vi.fn(() => { + events.push('freeze') + }) + } + + await flushOrcadProfileStoreForShutdown(store) + + expect(store.flushPendingOrThrowAsync).toHaveBeenCalledOnce() + expect(store.freezeWrites).toHaveBeenCalledOnce() + expect(events).toEqual(['flush', 'freeze']) + }) + + it('closes the profile store even when the durable flush fails', async () => { + const flushError = new Error('profile flush failed') + const freezeWrites = vi.fn() + const store = { + flushPendingOrThrowAsync: vi.fn(async () => { + throw flushError + }), + freezeWrites + } + + await expect(flushOrcadProfileStoreForShutdown(store)).rejects.toBe(flushError) + expect(freezeWrites).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index edc4426a2a93..0553883e5c27 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -24,8 +24,12 @@ import { resolveOrcadBindHost } from './orcad-bind-address' import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock' -import { startOrcadWithLifecycle } from './orcad-lifecycle' +import { flushOrcadProfileStoreForShutdown, startOrcadWithLifecycle } from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' +import { + acquireProfileStateRuntimeAdmission, + ProfileStateAccessError +} from '../persistence/profile-state/profile-state-access' import { changedAiVaultSearchSettings, type AiVaultSearchSettings @@ -111,6 +115,8 @@ export type OrcadHandle = { export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() const userDataPath = resolveUserDataPath() + // Process lifetime covers workers even when a failed shutdown cannot finish their teardown. + acquireProfileStateRuntimeAdmission(userDataPath) // Why before anything else touches the root: the profile index, the store and the daemon // runtime dir all live under it, and two orcads sharing them corrupt state silently. This // is also the last point at which refusing costs nothing. @@ -145,10 +151,7 @@ async function startOrcadRuntime( const { getAppEnvironment } = await import('../../shared/app-environment') const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint') const { ServeReadinessPublisher } = await import('../server/serve-readiness') - const { Store } = await import('../persistence/loading-store/store') - const { ensureActiveOrcaProfile, initOrcaProfilePaths } = - await import('../orca-profiles/profile-index-store') - const { initSshHostKeyStoreFile } = await import('../ssh/ssh-host-key-store') + const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup') const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init') const { collectOrcadHealth } = await import('./orcad-health') @@ -162,6 +165,9 @@ async function startOrcadRuntime( await import('../runtime/agent-status-observed-pane-identity') let rpc: InstanceType | null = null + let profileStoreForShutdown: + | { flushPendingOrThrowAsync(): Promise; freezeWrites(): void } + | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} registerCleanup(async () => { @@ -169,13 +175,21 @@ async function startOrcadRuntime( await rpc?.stop() } finally { try { - // Why disconnect and not shut down: the daemon must outlive this process, or an - // orcad restart goes back to killing every running terminal. - await stopOrcadDaemon() + // Stop accepting RPC writes before the final persistence barrier. A SQLite-backed + // orcad has no JSON mirror to absorb a debounced write after SIGTERM. + if (profileStoreForShutdown) { + await flushOrcadProfileStoreForShutdown(profileStoreForShutdown) + } } finally { - uninstallObservedStatusIdentity() - uninstallHookStatusRepublish() - agentHookServer.stop() + try { + // Why disconnect and not shut down: the daemon must outlive this process, or an + // orcad restart goes back to killing every running terminal. + await stopOrcadDaemon() + } finally { + uninstallObservedStatusIdentity() + uninstallHookStatusRepublish() + agentHookServer.stop() + } } } }) @@ -183,8 +197,8 @@ async function startOrcadRuntime( const { resolvePushGatewayOrigin } = await import('../runtime/push/push-gateway-origin') const runtimeUserDataPath = getAppEnvironment().getPath('userData') - initOrcaProfilePaths() - const profile = ensureActiveOrcaProfile(runtimeUserDataPath) + const { store: profileStore, authority: profileStateAuthority } = + createOrcadProfileStateStartup(runtimeUserDataPath) const observedPaneIdentities = new AgentStatusObservedPaneIdentities() const observedStatusCapture = new AgentStatusObservedPaneIdentityCapture(observedPaneIdentities) // Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable` @@ -192,15 +206,14 @@ async function startOrcadRuntime( // a server that pairs and lists nothing looks healthy and is not. // Why: orcad IS the runtime authority — loading as 'desktop' would classify its // own runtime-scheduled automations as ambiguous mirrors and orphan them. - const store = new Store({ dataFile: profile.dataFile, storageAuthority: 'runtime' }) + profileStoreForShutdown = profileStore // Why: every SSH connect consults this sidecar. Left unbound it reports nothing trusted, // which is safe but silently discards accept records on every launch. - initSshHostKeyStoreFile(profile.dataFile) uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => observedStatusCapture.observe(enriched) ) - if (isAgentStatusHooksEnabled(store.getSettings())) { + if (isAgentStatusHooksEnabled(profileStore.getSettings())) { await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) } @@ -213,7 +226,7 @@ async function startOrcadRuntime( // constructed, and the deps hook is only ever called later, from an RPC. let sessionSearch: { apply(settings: AiVaultSearchSettings): void; dispose(): void } | null = null - const runtime = new OrcaRuntimeService(store, undefined, { + const runtime = new OrcaRuntimeService(profileStore, undefined, { // Why lazy: a daemon swap replaces the provider after construction, so an eager // reference would freeze the pre-daemon one. getLocalProvider: () => getLocalPtyProvider(), @@ -252,7 +265,7 @@ async function startOrcadRuntime( reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), buildAgentHookPtyEnv: () => - isAgentStatusHooksEnabled(store.getSettings()) ? agentHookServer.buildPtyEnv() : {}, + isAgentStatusHooksEnabled(profileStore.getSettings()) ? agentHookServer.buildPtyEnv() : {}, // Why the dedupe here and not in the instance: `apply` closes and reconstructs // unconditionally, so an unchanged value would restart a healthy index. applySessionSearchSettings: (before, after) => { @@ -266,7 +279,7 @@ async function startOrcadRuntime( const { installOrcadSessionSearchService } = await import('./orcad-session-search') sessionSearch = await installOrcadSessionSearchService({ userDataPath: runtimeUserDataPath, - getSettings: () => store.getSettings() + getSettings: () => profileStore.getSettings() }) getAppEnvironment().onWillQuit(() => sessionSearch?.dispose()) @@ -284,7 +297,13 @@ async function startOrcadRuntime( // Codex-home and Claude-auth preparation are left unset: both are desktop account // flows. A launch that needs one fails with its own message rather than silently // spawning an unauthenticated agent. - await registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store) + await registerHeadlessPtyRuntime( + runtime, + undefined, + () => profileStore.getSettings(), + undefined, + profileStore + ) // Why: same post-registration reconciliation `--serve` performs. Skipping it leaves // restored orchestration rows claiming an authority this host never took over. @@ -356,7 +375,7 @@ async function startOrcadRuntime( // Why in the readiness payload: this is the one message a supervisor and a deploy // transaction both read, and a green orcad with a dead daemon is exactly the // looks-healthy-but-useless state they must not activate. - health: await collectOrcadHealth(getAppEnvironment().getVersion()) + health: await collectOrcadHealth(getAppEnvironment().getVersion(), profileStateAuthority) } await new ServeReadinessPublisher().publish(readiness, { @@ -382,7 +401,9 @@ export const ORCAD_EXIT_CONFIGURATION = 78 export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 export function resolveOrcadExitCode(error: unknown): number { - return error instanceof OrcadInstanceLockError || error instanceof OrcadBindAddressError + return error instanceof OrcadInstanceLockError || + error instanceof OrcadBindAddressError || + error instanceof ProfileStateAccessError ? ORCAD_EXIT_CONFIGURATION : ORCAD_EXIT_FAILED } diff --git a/src/main/orcad/orcad-health.test.ts b/src/main/orcad/orcad-health.test.ts index c77f18477f06..70e02b37e878 100644 --- a/src/main/orcad/orcad-health.test.ts +++ b/src/main/orcad/orcad-health.test.ts @@ -134,6 +134,24 @@ describe('collectOrcadHealth', () => { expect(health.platform).toBe(process.platform) expect(health.terminalDaemon.state).toBe('live') }) + + it('includes bounded profile-state authority metadata when supplied', async () => { + const health = await collectOrcadHealth('1.2.3', { + backend: 'sqlite', + classification: 'sqlite-only', + authority_mode: 'sqlite-established', + runtime: 'orcad', + migrated: false + }) + + expect(health.profileStateAuthority).toEqual({ + backend: 'sqlite', + classification: 'sqlite-only', + authority_mode: 'sqlite-established', + runtime: 'orcad', + migrated: false + }) + }) }) describe('computeOrcadBuildHash', () => { diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index d9a567531ac4..0c3a3525171f 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -17,6 +17,7 @@ import { getDaemonEndpointFacts, readDaemonPidRecord } from '../daemon/daemon-init' +import type { OrcadProfileStateAuthoritySelection } from './orcad-profile-state-telemetry' /** * How much a green self-test actually proves. @@ -64,6 +65,8 @@ export type OrcadHealth = { arch: string pid: number terminalDaemon: TerminalDaemonHealth + /** The low-cardinality profile-state authority selected during startup, when available. */ + profileStateAuthority?: OrcadProfileStateAuthoritySelection } /** @@ -146,7 +149,10 @@ export async function collectTerminalDaemonHealth(): Promise { +export async function collectOrcadHealth( + buildVersion: string, + profileStateAuthority?: OrcadProfileStateAuthoritySelection +): Promise { return { buildHash: computeOrcadBuildHash(), buildVersion, @@ -155,6 +161,7 @@ export async function collectOrcadHealth(buildVersion: string): Promise { it('accepts --bind and leaves it unset when absent', () => { @@ -38,6 +39,9 @@ describe('resolveOrcadExitCode', () => { resolveOrcadExitCode(new OrcadInstanceLockError('orcad_instance_lock_held', 'held')) ).toBe(ORCAD_EXIT_CONFIGURATION) expect(resolveOrcadExitCode(new OrcadBindAddressError('bad'))).toBe(ORCAD_EXIT_CONFIGURATION) + expect(resolveOrcadExitCode(new ProfileStateAccessError('recovery interrupted'))).toBe( + ORCAD_EXIT_CONFIGURATION + ) expect(resolveOrcadExitCode(new Error('port in use'))).toBe(ORCAD_EXIT_FAILED) expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED) }) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index 913a21c48741..fa2727410de7 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -33,3 +33,14 @@ export async function startOrcadWithLifecycle( throw error } } + +export async function flushOrcadProfileStoreForShutdown(store: { + flushPendingOrThrowAsync(): Promise + freezeWrites(): void +}): Promise { + try { + await store.flushPendingOrThrowAsync() + } finally { + store.freezeWrites() + } +} diff --git a/src/main/orcad/orcad-profile-state-startup.test.ts b/src/main/orcad/orcad-profile-state-startup.test.ts new file mode 100644 index 000000000000..e9bc212d08a3 --- /dev/null +++ b/src/main/orcad/orcad-profile-state-startup.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest' + +const { + createProfileStateStoreForStartupMock, + orcadProfileStateAuthorityModeMock, + emitMock, + ensureActiveOrcaProfileMock, + initOrcaProfilePathsMock, + initSshHostKeyStoreFileMock +} = vi.hoisted(() => ({ + createProfileStateStoreForStartupMock: vi.fn(), + orcadProfileStateAuthorityModeMock: vi.fn(), + emitMock: vi.fn(), + ensureActiveOrcaProfileMock: vi.fn(), + initOrcaProfilePathsMock: vi.fn(), + initSshHostKeyStoreFileMock: vi.fn() +})) + +vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({ + createProfileStateStoreForStartup: createProfileStateStoreForStartupMock, + orcadProfileStateAuthorityMode: orcadProfileStateAuthorityModeMock +})) +vi.mock('../orca-profiles/profile-index-store', () => ({ + ensureActiveOrcaProfile: ensureActiveOrcaProfileMock, + initOrcaProfilePaths: initOrcaProfilePathsMock +})) +vi.mock('../ssh/ssh-host-key-store', () => ({ + initSshHostKeyStoreFile: initSshHostKeyStoreFileMock +})) +vi.mock('./orcad-profile-state-telemetry', () => ({ + emitOrcadProfileStateAuthoritySelected: emitMock +})) + +const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup') + +describe('orcad profile-state startup', () => { + it('selects the capable authority once and publishes bounded metadata', () => { + const store = { getSettings: vi.fn() } + ensureActiveOrcaProfileMock.mockReturnValue({ + dataFile: '/tmp/profile/orca-data.json', + stateDatabaseFile: '/tmp/profile/profile-state.db', + profile: { id: 'profile-1' } + }) + orcadProfileStateAuthorityModeMock.mockReturnValue('sqlite-candidate') + createProfileStateStoreForStartupMock.mockReturnValue({ + store, + authority: { readSerializedState: vi.fn() }, + backend: 'sqlite', + classification: 'json-only', + migrated: true + }) + + const result = createOrcadProfileStateStartup('/tmp/user-data') + + expect(initOrcaProfilePathsMock).toHaveBeenCalledOnce() + expect(ensureActiveOrcaProfileMock).toHaveBeenCalledWith('/tmp/user-data') + expect(initSshHostKeyStoreFileMock).toHaveBeenCalledWith('/tmp/profile/orca-data.json') + + expect(createProfileStateStoreForStartupMock).toHaveBeenCalledWith({ + dataFile: '/tmp/profile/orca-data.json', + databaseFile: '/tmp/profile/profile-state.db', + profileId: 'profile-1', + runtime: 'orcad', + authorityMode: 'sqlite-candidate', + storageAuthority: 'runtime' + }) + expect(result.store).toBe(store) + expect(result.authority).toEqual({ + backend: 'sqlite', + classification: 'json-only', + authority_mode: 'sqlite-candidate', + runtime: 'orcad', + migrated: true + }) + expect(emitMock).toHaveBeenCalledWith(result.authority) + }) +}) diff --git a/src/main/orcad/orcad-profile-state-startup.ts b/src/main/orcad/orcad-profile-state-startup.ts new file mode 100644 index 000000000000..db5c45013498 --- /dev/null +++ b/src/main/orcad/orcad-profile-state-startup.ts @@ -0,0 +1,50 @@ +import { + createProfileStateStoreForStartup, + orcadProfileStateAuthorityMode +} from '../persistence/profile-state/profile-state-startup-authority' +import type { ProfileStateStoreFactoryResult } from '../persistence/profile-state/profile-state-store-factory' +import { ensureActiveOrcaProfile, initOrcaProfilePaths } from '../orca-profiles/profile-index-store' +import { initSshHostKeyStoreFile } from '../ssh/ssh-host-key-store' +import { emitOrcadProfileStateAuthoritySelected } from './orcad-profile-state-telemetry' + +export type OrcadProfileStateProfile = { + dataFile: string + stateDatabaseFile: string + profile: { id: string } +} + +export type OrcadProfileStateStartup = { + store: ProfileStateStoreFactoryResult['store'] + authority: { + backend: ProfileStateStoreFactoryResult['backend'] + classification: ProfileStateStoreFactoryResult['classification'] + authority_mode: ReturnType + runtime: 'orcad' + migrated: boolean + } +} + +/** Build the headless Store and publish its authority selection at one Node-only seam. */ +export function createOrcadProfileStateStartup(userDataPath: string): OrcadProfileStateStartup { + initOrcaProfilePaths() + const profile = ensureActiveOrcaProfile(userDataPath) + const authorityMode = orcadProfileStateAuthorityMode() + const result = createProfileStateStoreForStartup({ + dataFile: profile.dataFile, + databaseFile: profile.stateDatabaseFile, + profileId: profile.profile.id, + runtime: 'orcad', + authorityMode, + storageAuthority: 'runtime' + }) + initSshHostKeyStoreFile(profile.dataFile) + const authority = { + backend: result.backend, + classification: result.classification, + authority_mode: authorityMode, + runtime: 'orcad' as const, + migrated: result.migrated + } + emitOrcadProfileStateAuthoritySelected(authority) + return { store: result.store, authority } +} diff --git a/src/main/orcad/orcad-profile-state-telemetry.test.ts b/src/main/orcad/orcad-profile-state-telemetry.test.ts new file mode 100644 index 000000000000..9d79952a14c4 --- /dev/null +++ b/src/main/orcad/orcad-profile-state-telemetry.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { + emitOrcadProfileStateAuthoritySelected, + formatOrcadProfileStateAuthoritySelected, + type OrcadProfileStateAuthoritySelection +} from './orcad-profile-state-telemetry' + +const selection: OrcadProfileStateAuthoritySelection = { + backend: 'sqlite', + classification: 'json-only', + authority_mode: 'sqlite-candidate', + runtime: 'orcad', + migrated: true +} + +describe('orcad profile-state telemetry', () => { + it('formats a bounded machine-readable authority selection event', () => { + expect(JSON.parse(formatOrcadProfileStateAuthoritySelected(selection).slice(18))).toEqual({ + event: 'profile_state_authority_selected', + ...selection + }) + }) + + it('strips unexpected runtime fields before writing the record', () => { + const selectionWithRuntimeFields = Object.assign({}, selection, { + database_path: '/private/profile-state.db' + }) + const line = formatOrcadProfileStateAuthoritySelected(selectionWithRuntimeFields) + expect(line).not.toContain('database_path') + }) + + it('sends the event to the supplied sink', () => { + const sink = vi.fn() + emitOrcadProfileStateAuthoritySelected(selection, sink) + expect(sink).toHaveBeenCalledOnce() + expect(sink).toHaveBeenCalledWith(formatOrcadProfileStateAuthoritySelected(selection)) + }) + + it('never lets a failing sink block startup', () => { + expect(() => + emitOrcadProfileStateAuthoritySelected(selection, () => { + throw new Error('closed stderr') + }) + ).not.toThrow() + }) +}) diff --git a/src/main/orcad/orcad-profile-state-telemetry.ts b/src/main/orcad/orcad-profile-state-telemetry.ts new file mode 100644 index 000000000000..e1cec920b6bd --- /dev/null +++ b/src/main/orcad/orcad-profile-state-telemetry.ts @@ -0,0 +1,47 @@ +import type { ProfileStateStorageClassification } from '../persistence/profile-state/profile-state-storage-classification' +import type { ProfileStateStoreAuthorityMode } from '../persistence/profile-state/profile-state-store-factory' + +/** + * The low-cardinality profile-state selection facts that a headless host can publish safely. + * Paths, profile IDs, and serialized state deliberately stay out of this record. + */ +export type OrcadProfileStateAuthoritySelection = { + backend: 'json' | 'sqlite' + classification: ProfileStateStorageClassification + authority_mode: ProfileStateStoreAuthorityMode + runtime: 'orcad' + migrated: boolean +} + +export type OrcadProfileStateTelemetrySink = (line: string) => void + +/** Render one machine-readable stderr line for fleet log collection. */ +export function formatOrcadProfileStateAuthoritySelected( + selection: OrcadProfileStateAuthoritySelection +): string { + // Keep the wire shape explicit even if a future caller passes a structurally-compatible + // object with extra runtime fields. Paths, IDs, and serialized state must never leak here. + return `[orcad-telemetry] ${JSON.stringify({ + event: 'profile_state_authority_selected', + backend: selection.backend, + classification: selection.classification, + authority_mode: selection.authority_mode, + runtime: selection.runtime, + migrated: selection.migrated + })}` +} + +/** + * Publish authority selection without importing Electron or the desktop PostHog client. + * Logging is best-effort: observability must never prevent an orcad host from serving. + */ +export function emitOrcadProfileStateAuthoritySelected( + selection: OrcadProfileStateAuthoritySelection, + sink: OrcadProfileStateTelemetrySink = (line) => console.error(line) +): void { + try { + sink(formatOrcadProfileStateAuthoritySelected(selection)) + } catch { + // A closed stderr or custom supervisor sink cannot turn a successful startup into a failure. + } +} diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index a8fbbc9fe162..0ba7f7275ee9 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -6,12 +6,14 @@ import { DeviceRegistry } from '../runtime/device-registry' import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' import { createPushHostKeypair } from '../runtime/push/push-host-challenge-fixtures' +import { acquireProfileStateMaintenance } from '../persistence/profile-state/profile-state-access' const state = vi.hoisted(() => ({ root: '', controller: null as RuntimeMobileNotificationController | null, registry: null as DeviceRegistry | null, rpcStarted: false, + browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), send: vi.fn(async () => ({ ok: true, results: [] })) })) @@ -20,7 +22,7 @@ vi.mock('./orcad-app-paths', () => ({ resolveOrcadPath: () => state.root, resolveUserDataPath: () => state.root })) -vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: async () => null })) +vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider })) vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) vi.mock('./orcad-daemon-supervision', () => ({ startOrcadDaemon: async () => {}, @@ -38,11 +40,19 @@ vi.mock('../persistence/loading-store/store', () => ({ getSettings() { return {} } + + async flushPendingOrThrowAsync() {} + + freezeWrites() {} } })) vi.mock('../orca-profiles/profile-index-store', () => ({ initOrcaProfilePaths() {}, - ensureActiveOrcaProfile: () => ({ dataFile: join(state.root, 'profile.json') }) + ensureActiveOrcaProfile: () => ({ + dataFile: join(state.root, 'profile.json'), + stateDatabaseFile: join(state.root, 'profile-state.db'), + profile: { id: 'headless-profile' } + }) })) vi.mock('../ssh/ssh-host-key-store', () => ({ initSshHostKeyStoreFile() {} })) vi.mock('../server/serve-readiness', () => ({ @@ -109,6 +119,19 @@ afterEach(() => { vi.clearAllMocks() }) +it('refuses recovery overlap before initializing the browser provider or runtime', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-recovery-')) + const maintenance = acquireProfileStateMaintenance(state.root) + const { startOrcad } = await import('./orcad-entry') + try { + await expect(startOrcad({ noPairing: true, json: true })).rejects.toThrow() + expect(state.browserProvider).not.toHaveBeenCalled() + expect(state.rpcStarted).toBe(false) + } finally { + maintenance.release() + } +}) + it('starts push after RPC identity is available and stops dispatch on shutdown', async () => { state.root = mkdtempSync(join(tmpdir(), 'orca-headless-push-')) state.controller = new RuntimeMobileNotificationController() diff --git a/src/main/persistence/applying-settings/feature-interaction-recording.ts b/src/main/persistence/applying-settings/feature-interaction-recording.ts index 2a29bc64f66b..662e79bd40fd 100644 --- a/src/main/persistence/applying-settings/feature-interaction-recording.ts +++ b/src/main/persistence/applying-settings/feature-interaction-recording.ts @@ -12,7 +12,7 @@ import { getCohortAtEmit } from '../../telemetry/cohort-classifier' export type FeatureInteractionOperations = { state: PersistedState - scheduleSave: () => void + scheduleSave: (domains?: readonly string[]) => void notifyUIChanged: () => void getUI: () => PersistedState['ui'] } @@ -49,7 +49,7 @@ export function recordFeatureInteraction( operations.state.featureInteractionTelemetryBuckets = shouldEmit ? { ...telemetryBuckets, [id]: nextBucket } : telemetryBuckets - operations.scheduleSave() + operations.scheduleSave(['ui', 'featureInteractionTelemetryBuckets']) // Why: live UI only consumes the seen transition; count-only telemetry must not re-hydrate the renderer. if (!existing) { operations.notifyUIChanged() diff --git a/src/main/persistence/applying-settings/ui-state-update.ts b/src/main/persistence/applying-settings/ui-state-update.ts index db06a2738fd8..5aad240e554b 100644 --- a/src/main/persistence/applying-settings/ui-state-update.ts +++ b/src/main/persistence/applying-settings/ui-state-update.ts @@ -57,7 +57,9 @@ export function updatePersistedUI( operations: UIUpdateOperations, updates: Partial ): void { - if ('browserKagiSessionLink' in updates && !updates.browserKagiSessionLink) { + const clearsProtectedSecret = + 'browserKagiSessionLink' in updates && !updates.browserKagiSessionLink + if (clearsProtectedSecret) { operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.browserKagiSessionLink) } const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates) @@ -192,7 +194,8 @@ export function updatePersistedUI( ) : normalizeFeatureInteractions(operations.state.ui?.featureInteractions) } - if (persistedUIValuesEqual(previousUI, nextUI)) { + // A sealed secret looks empty in memory; an explicit clear must still reach disk. + if (!clearsProtectedSecret && persistedUIValuesEqual(previousUI, nextUI)) { if (activeViewChanged) { operations.notifyUIChanged() } diff --git a/src/main/persistence/loading-store/automation-persistence.ts b/src/main/persistence/loading-store/automation-persistence.ts index 2bed89ff09b9..64abd8ed2a9b 100644 --- a/src/main/persistence/loading-store/automation-persistence.ts +++ b/src/main/persistence/loading-store/automation-persistence.ts @@ -53,7 +53,11 @@ import type { ProfilePreferences } from './profile-preferences' type AutomationPersistenceRuntime = Pick< StoreRuntimeState, - 'automationListProjectionCache' | 'state' | 'storageAuthority' + | 'automationListProjectionCache' + | 'dirtyProfileStateDomains' + | 'pendingAutomationRunsAfter' + | 'state' + | 'storageAuthority' > const automationPersistenceContext = Symbol('AutomationPersistence') @@ -195,7 +199,10 @@ export class AutomationPersistence { advanceAutomationNextRun(id: string, now = Date.now()): Automation { return advanceAutomationNextRunOperation( this[automationPersistenceContext].runtime.state, - () => this[automationPersistenceContext].flushBarriers.flush(), + () => { + markAutomationDefinitionDomain(this) + this[automationPersistenceContext].flushBarriers.flush() + }, id, now ) @@ -212,16 +219,31 @@ export function getAutomationDefinitionOperations( return { state: owner[automationPersistenceContext].runtime.state, storageAuthority: owner[automationPersistenceContext].runtime.storageAuthority, - flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + flush: () => { + markAutomationDefinitionDomain(owner) + owner[automationPersistenceContext].flushBarriers.flush() + }, recordCreated: () => - owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-created') + owner[automationPersistenceContext].preferences.recordFeatureInteraction( + 'automation-created' + ), + recordAutomationRunsMutation: (runs) => { + owner[automationPersistenceContext].runtime.pendingAutomationRunsAfter = runs + owner[automationPersistenceContext].runtime.dirtyProfileStateDomains?.add('automationRuns') + } } } export function getAutomationRunOperations(owner: AutomationPersistence): AutomationRunOperations { return { state: owner[automationPersistenceContext].runtime.state, - flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + flush: () => { + markAutomationDomains(owner) + owner[automationPersistenceContext].flushBarriers.flush() + }, + recordAutomationRunsMutation: (runs) => { + owner[automationPersistenceContext].runtime.pendingAutomationRunsAfter = runs + }, recordManualRun: () => owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-run'), getWorkspaceDisplayName: (workspaceId) => @@ -242,6 +264,18 @@ export function getAutomationRunWorkspaceDisplayName( ) } +function markAutomationDomains(owner: AutomationPersistence): void { + const dirtyDomains = owner[automationPersistenceContext].runtime.dirtyProfileStateDomains + if (dirtyDomains !== null) { + dirtyDomains.add('automations') + dirtyDomains.add('automationRuns') + } +} + +function markAutomationDefinitionDomain(owner: AutomationPersistence): void { + owner[automationPersistenceContext].runtime.dirtyProfileStateDomains?.add('automations') +} + export function installAutomationPersistenceContext( target: AutomationPersistence, source: AutomationPersistence diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index d2a31419a039..380482447c5b 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -79,27 +79,67 @@ export class LoadedStateParsingOperations { private readonly cohorts: LoadedCohortMigrationOperations ) {} + /** + * Load the legacy storage representation supplied by a migration/importer. + * + * This deliberately uses the same decrypt, normalization, migration, and + * sidecar handling as a file load. An invalid imported document must fail + * closed instead of falling back to an unrelated on-disk backup. + */ + loadSerialized(raw: string): PersistedState { + return this.loadInternal(true, raw) + } + + /** Load only from an injected authority; never consult the legacy JSON path. */ + loadFromAuthority(raw: string | undefined): PersistedState { + return this.loadInternal(false, raw, true) + } + + loadParsedFromAuthority(parsed: Record | undefined): PersistedState { + return this.loadInternal(false, undefined, true, parsed) + } + load(allowBackupRecovery = true): PersistedState { + return this.loadInternal(allowBackupRecovery) + } + + private loadInternal( + fileRecovery: boolean, + serialized?: string, + authoritySource = false, + parsedInput?: Record + ): PersistedState { // Capture "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. const dataFile = this.runtime.dataFile - const fileExistedOnLoad = existsSync(dataFile) + const fileExistedOnLoad = authoritySource + ? serialized !== undefined || parsedInput !== undefined + : serialized !== undefined || existsSync(dataFile) logPersistenceStartupMilestone('persistence-load-start', { fileExists: fileExistedOnLoad }) let result: PersistedState | null = null + let parsed: PersistedState | undefined try { if (fileExistedOnLoad) { const readStartedAt = performance.now() - const raw = readFileSync(dataFile, 'utf-8') - logPersistenceStartupMilestone('persistence-read-done', { - bytes: Buffer.byteLength(raw), - durationMs: Math.round(performance.now() - readStartedAt) - }) - logPersistenceStartupMilestone('persistence-json-parse-start') - const parsed = JSON.parse(raw) as PersistedState - logPersistenceStartupMilestone('persistence-json-parse-done') - + const raw = + parsedInput === undefined ? (serialized ?? readFileSync(dataFile, 'utf-8')) : undefined + if (raw !== undefined) { + logPersistenceStartupMilestone('persistence-read-done', { + bytes: Buffer.byteLength(raw), + durationMs: Math.round(performance.now() - readStartedAt) + }) + logPersistenceStartupMilestone('persistence-json-parse-start') + parsed = JSON.parse(raw) + logPersistenceStartupMilestone('persistence-json-parse-done') + } else { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Legacy partial records enter the existing domain normalizers through this loader type. + parsed = parsedInput as PersistedState + } + if (parsed === undefined) { + throw new Error('Profile state startup snapshot is missing') + } // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. if (parsed.settings?.opencodeSessionCookie) { parsed.settings.opencodeSessionCookie = this.runtime.protectedSecrets.decrypt( @@ -188,11 +228,15 @@ export class LoadedStateParsingOperations { }) } } catch (err) { + if (serialized !== undefined || authoritySource) { + console.error('[persistence] Failed to load imported profile state:', err) + throw new Error('Failed to load imported profile state', { cause: err }) + } console.error('[persistence] Failed to load primary state, trying backups:', err) } // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. - if (result === null && allowBackupRecovery) { + if (result === null && fileRecovery && !authoritySource) { const hasBackup = hasStateBackup(dataFile) if (fileExistedOnLoad || hasBackup) { if (this.backups.restoreFromBackup(dataFile)) { @@ -216,7 +260,6 @@ export class LoadedStateParsingOperations { if (migratedScrollback.changed) { this.runtime.loadNeedsSave = true } - const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts index 2532ff3b2d3e..9800b541373a 100644 --- a/src/main/persistence/loading-store/metadata-lineage-operations.ts +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -27,7 +27,8 @@ import { getWorktreeMetaForHost as getWorktreeMetaForHostOperation, migrateWorktreeMetadataLocator, removeWorktreeMetadataForHost, - setWorktreeMetaForHost as setWorktreeMetaForHostOperation + setWorktreeMetaForHost as setWorktreeMetaForHostOperation, + WORKTREE_METADATA_DOMAINS } from './worktree-identity-metadata' import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization' import { @@ -122,7 +123,7 @@ export class MetadataLineageOperations { } const updated = mergeWorktreeMetaForWrite(stored, meta) state.worktreeMeta[worktreeId] = updated - scheduleSave(this[metadataLineageOperationsContext].scheduling) + scheduleSave(this[metadataLineageOperationsContext].scheduling, ['worktreeMeta']) return updated } @@ -269,7 +270,11 @@ export class MetadataLineageOperations { mover ) if (legacyChanged || canonicalChanged) { - scheduleSave(this[metadataLineageOperationsContext].scheduling) + // Legacy identity moves also re-key sessions, lineage, mobile selections, and UI state. + scheduleSave( + this[metadataLineageOperationsContext].scheduling, + legacyChanged ? undefined : WORKTREE_METADATA_DOMAINS + ) } } diff --git a/src/main/persistence/loading-store/primary-state-write-context.ts b/src/main/persistence/loading-store/primary-state-write-context.ts new file mode 100644 index 000000000000..6a4ba6a738ba --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-context.ts @@ -0,0 +1,9 @@ +import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' +import type { PrimaryStateWriteOperationsRuntime } from './primary-state-write-runtime' +import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' + +export type PrimaryStateWriteOperationsContext = { + runtime: PrimaryStateWriteOperationsRuntime + serialization: StateSerializationSecretHandlingOperations + backups: BackupRecoveryRotationOperations +} diff --git a/src/main/persistence/loading-store/primary-state-write-runtime.ts b/src/main/persistence/loading-store/primary-state-write-runtime.ts new file mode 100644 index 000000000000..3100ca6bad5d --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-runtime.ts @@ -0,0 +1,47 @@ +import type { StoreRuntimeState } from './store-runtime-state' + +export type PrimaryStateWriteOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'backupRotationInFlight' + | 'dataFile' + | 'dirtyProfileStateDomains' + | 'flushOrThrow' + | 'firstPendingSaveAt' + | 'inFlightAsyncTmpFile' + | 'lastDurableWriteGeneration' + | 'lastWrittenStateHash' + | 'pendingSnapshotFileWork' + | 'pendingAutomationRunsAfter' + | 'pendingWrite' + | 'profileStateAuthority' + | 'protectedSecrets' + | 'quitFlushStarted' + | 'staleTempCleanup' + | 'state' + | 'writeGeneration' + | 'writeTimer' + | 'writesFrozen' +> + +export function markPrimaryStateWriteDurable( + runtime: Pick, + generation: number +): void { + runtime.lastDurableWriteGeneration = Math.max(runtime.lastDurableWriteGeneration, generation) +} + +export function canReuseDurableProfileState( + runtime: Pick, + stateHash: string +): boolean { + if (stateHash !== runtime.lastWrittenStateHash) { + return false + } + const authority = runtime.profileStateAuthority + if (authority && !authority.assertCurrentRevision) { + return false + } + authority?.assertCurrentRevision?.() + return true +} diff --git a/src/main/persistence/loading-store/primary-state-write-sync.ts b/src/main/persistence/loading-store/primary-state-write-sync.ts new file mode 100644 index 000000000000..d7d4a55ab2dd --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-sync.ts @@ -0,0 +1,91 @@ +import { existsSync, mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { writeSelectiveProfileState } from './profile-state-selective-write' +import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' +import { + canReuseDurableProfileState, + markPrimaryStateWriteDurable +} from './primary-state-write-runtime' + +export function writeToDiskSync( + context: PrimaryStateWriteOperationsContext, + opts: { force?: boolean; skipBackupRotation?: boolean; expectedGeneration?: number } = {} +): void { + const { runtime, serialization, backups } = context + if (runtime.writesFrozen) { + return + } + const isCurrent = + opts.expectedGeneration === undefined + ? undefined + : () => runtime.writeGeneration === opts.expectedGeneration + const selective = writeSelectiveProfileState( + runtime.profileStateAuthority, + serialization, + runtime.dirtyProfileStateDomains, + runtime.pendingAutomationRunsAfter, + isCurrent + ) + if (selective.handled) { + if (selective.aborted) { + return + } + if (selective.consumedAutomationRuns) { + runtime.pendingAutomationRunsAfter = undefined + } + runtime.lastWrittenStateHash = null + runtime.protectedSecrets.commitRetentionUpdates(selective.protectedSecretUpdates) + markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) + runtime.profileStateAuthority?.scheduleBackup?.() + return + } + const built = serialization.buildStateToSave( + runtime.profileStateAuthority?.writeCompleteSerializedDomains !== undefined + ) + const { stateHash, protectedSecretUpdates } = built + // Why: matching hash means the file already holds this state; force overrides an async rename race. + if (!opts.force && canReuseDurableProfileState(runtime, stateHash)) { + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) + return + } + if (runtime.profileStateAuthority) { + if (isCurrent && !isCurrent()) { + return + } + if (built.domains && runtime.profileStateAuthority.writeCompleteSerializedDomains) { + runtime.profileStateAuthority.writeCompleteSerializedDomains(built.domains) + } else { + runtime.profileStateAuthority.writeSerializedState(built.payload) + } + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + if (!isCurrent || isCurrent()) { + runtime.lastWrittenStateHash = stateHash + runtime.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) + } else { + runtime.lastWrittenStateHash = null + } + markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) + runtime.profileStateAuthority.scheduleBackup?.() + return + } + const dataFile = runtime.dataFile + const payload = built.payload + const dir = dirname(dataFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + writeFileDurableSync(durableWriteTempPath(dataFile), dataFile, payload) + runtime.dirtyProfileStateDomains = new Set() + runtime.lastWrittenStateHash = stateHash + runtime.pendingAutomationRunsAfter = undefined + runtime.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) + markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) + const now = Date.now() + if (!opts.skipBackupRotation && backups.shouldRotateBackups(now, dataFile)) { + backups.rotateBackupsSync(dataFile) + } +} diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index 3820723fffbb..bbc2abd0d618 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -1,44 +1,22 @@ -import { mkdirSync, existsSync, unlinkSync } from 'node:fs' +import { unlinkSync } from 'node:fs' import { mkdir, open, rm } from 'node:fs/promises' -import { durableWriteTempPath, renameDurable, writeFileDurableSync } from '../../durable-file-write' +import { durableWriteTempPath, renameDurable } from '../../durable-file-write' import { dirname } from 'node:path' import { parseCodexResetCreditAttemptLedger, type CodexResetCreditAttemptLedger } from '../../../shared/codex-reset-credit-attempt-ledger' - -import type { StoreRuntimeState } from './store-runtime-state' +import { + canReuseDurableProfileState, + markPrimaryStateWriteDurable, + type PrimaryStateWriteOperationsRuntime +} from './primary-state-write-runtime' import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' - -type PrimaryStateWriteOperationsRuntime = Pick< - StoreRuntimeState, - | 'activeViewPreference' - | 'backupRotationInFlight' - | 'dataFile' - | 'flushOrThrow' - | 'firstPendingSaveAt' - | 'inFlightAsyncTmpFile' - | 'lastDurableWriteGeneration' - | 'lastWrittenStateHash' - | 'pendingSnapshotFileWork' - | 'pendingWrite' - | 'protectedSecrets' - | 'quitFlushStarted' - | 'staleTempCleanup' - | 'state' - | 'writeGeneration' - | 'writeTimer' - | 'writesFrozen' -> +import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' +import { writeToDiskSync } from './primary-state-write-sync' const primaryStateWriteOperationsContext = Symbol('PrimaryStateWriteOperations') -type PrimaryStateWriteOperationsContext = { - runtime: PrimaryStateWriteOperationsRuntime - serialization: StateSerializationSecretHandlingOperations - backups: BackupRecoveryRotationOperations -} - export class PrimaryStateWriteOperations { readonly [primaryStateWriteOperationsContext]: PrimaryStateWriteOperationsContext @@ -51,33 +29,34 @@ export class PrimaryStateWriteOperations { } flushOrThrow(): void { - if (this[primaryStateWriteOperationsContext].runtime.quitFlushStarted) { + const context = this[primaryStateWriteOperationsContext] + const { runtime } = context + if (runtime.quitFlushStarted) { throw new Error('Cannot synchronously flush after final persistence has started') } - if (this[primaryStateWriteOperationsContext].runtime.writeTimer) { - clearTimeout(this[primaryStateWriteOperationsContext].runtime.writeTimer) - this[primaryStateWriteOperationsContext].runtime.writeTimer = null + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null } - this[primaryStateWriteOperationsContext].runtime.firstPendingSaveAt = null - const asyncWriteWasInFlight = - this[primaryStateWriteOperationsContext].runtime.pendingWrite !== null + runtime.firstPendingSaveAt = null + const asyncWriteWasInFlight = runtime.pendingWrite !== null // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. - this[primaryStateWriteOperationsContext].runtime.writeGeneration++ - if (this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) { + runtime.writeGeneration++ + if (runtime.inFlightAsyncTmpFile) { try { - unlinkSync(this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) - this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null + unlinkSync(runtime.inFlightAsyncTmpFile) + runtime.inFlightAsyncTmpFile = null } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { void enqueueWrite(this).catch(() => {}) throw error } } } // Why: later async flushes must remain serialized behind the invalidated writer. - writeToDiskSync(this, { + writeToDiskSync(context, { force: asyncWriteWasInFlight, - skipBackupRotation: this[primaryStateWriteOperationsContext].runtime.backupRotationInFlight + skipBackupRotation: runtime.backupRotationInFlight }) } @@ -92,65 +71,78 @@ export class PrimaryStateWriteOperations { } replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { - if (this[primaryStateWriteOperationsContext].runtime.writesFrozen) { + const { runtime } = this[primaryStateWriteOperationsContext] + if (runtime.writesFrozen) { throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') } const next = parseCodexResetCreditAttemptLedger(ledger) - const previous = this[primaryStateWriteOperationsContext].runtime.state - .codexResetCreditAttemptLedger - ? structuredClone( - this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger - ) + const previous = runtime.state.codexResetCreditAttemptLedger + ? structuredClone(runtime.state.codexResetCreditAttemptLedger) : undefined - this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = next + runtime.state.codexResetCreditAttemptLedger = next + runtime.dirtyProfileStateDomains?.add('codexResetCreditAttemptLedger') try { - this[primaryStateWriteOperationsContext].runtime.flushOrThrow() + runtime.flushOrThrow() } catch (error) { // Why: callers use a successful return as the durability barrier before // handing a scarce-credit mutation to the provider. - this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = - previous + runtime.state.codexResetCreditAttemptLedger = previous throw error } } } -export function enqueueWrite(owner: PrimaryStateWriteOperations): Promise { +export function enqueueWrite( + owner: PrimaryStateWriteOperations, + options: { fullCheckpoint?: boolean } = {} +): Promise { + const { runtime } = owner[primaryStateWriteOperationsContext] const previousWrite = Promise.all([ - owner[primaryStateWriteOperationsContext].runtime.pendingWrite ?? - owner[primaryStateWriteOperationsContext].runtime.staleTempCleanup, - owner[primaryStateWriteOperationsContext].runtime.pendingSnapshotFileWork ?? Promise.resolve() + runtime.pendingWrite ?? runtime.staleTempCleanup, + runtime.pendingSnapshotFileWork ?? Promise.resolve() ]).then(() => {}) - const write = previousWrite.then(() => writeToDiskAsync(owner)) + const write = previousWrite.then(() => { + // A queued predecessor can clear dirty domains before this checkpoint runs. + if (options.fullCheckpoint) { + runtime.dirtyProfileStateDomains = null + } + return writeToDiskAsync(owner) + }) const trackedWrite = write .catch((err) => { console.error('[persistence] Failed to write state:', err) }) .finally(() => { - if (owner[primaryStateWriteOperationsContext].runtime.pendingWrite === trackedWrite) { - owner[primaryStateWriteOperationsContext].runtime.pendingWrite = null + if (runtime.pendingWrite === trackedWrite) { + runtime.pendingWrite = null } }) - owner[primaryStateWriteOperationsContext].runtime.pendingWrite = trackedWrite + runtime.pendingWrite = trackedWrite return write } export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { - if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { + const { runtime, serialization, backups } = owner[primaryStateWriteOperationsContext] + if (runtime.writesFrozen) { + return + } + const gen = runtime.writeGeneration + if (runtime.profileStateAuthority) { + // SQL commits are synchronous so both entry points share the same generation fence. + writeToDiskSync(owner[primaryStateWriteOperationsContext], { expectedGeneration: gen }) return } - const gen = owner[primaryStateWriteOperationsContext].runtime.writeGeneration - const { payload, stateHash, protectedSecretUpdates } = - owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() + const built = serialization.buildStateToSave() + const { stateHash, protectedSecretUpdates } = built // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. - if (stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash) { - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - gen - ) + if (canReuseDurableProfileState(runtime, stateHash)) { + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + markPrimaryStateWriteDurable(runtime, gen) return } - const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile + const dataFile = runtime.dataFile + const payload = built.payload const dir = dirname(dataFile) await mkdir(dir, { recursive: true }).catch(() => {}) const tmpFile = durableWriteTempPath(dataFile) @@ -168,39 +160,36 @@ export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Prom await handle.close() } // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. - if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { + if (runtime.writeGeneration !== gen) { return } - owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = tmpFile + runtime.inFlightAsyncTmpFile = tmpFile try { await renameDurable(tmpFile, dataFile) renamed = true } catch (error) { if ( - (error as NodeJS.ErrnoException).code !== 'ENOENT' || - owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen + !(error instanceof Error && 'code' in error && error.code === 'ENOENT') || + runtime.writeGeneration === gen ) { throw error } } finally { - if (owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile === tmpFile) { - owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null + if (runtime.inFlightAsyncTmpFile === tmpFile) { + runtime.inFlightAsyncTmpFile = null } } // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. - if (renamed && owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen) { - owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash - owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( - protectedSecretUpdates - ) + if (renamed && runtime.writeGeneration === gen) { + runtime.lastWrittenStateHash = stateHash + runtime.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) } else if (renamed) { - owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = null + runtime.lastWrittenStateHash = null } if (renamed) { - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - gen - ) + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + markPrimaryStateWriteDurable(runtime, gen) } } finally { if (!renamed) { @@ -211,72 +200,10 @@ export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Prom return } // Why (#1158): rotate only after the primary rename while this write still owns its generation. - if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { - return - } - await owner[primaryStateWriteOperationsContext].backups.rotateBackupsAsync(dataFile) -} - -export function writeToDiskSync( - owner: PrimaryStateWriteOperations, - opts: { force?: boolean; skipBackupRotation?: boolean } = {} -): void { - if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { - return - } - const { payload, stateHash, protectedSecretUpdates } = - owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() - // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. - if ( - !opts.force && - stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash - ) { - // Why: flushOrThrow already bumped writeGeneration; the file holds this state, so record it - // durable or persistPtyBinding's fast lane stays parked one generation behind forever. - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - owner[primaryStateWriteOperationsContext].runtime.writeGeneration - ) + if (runtime.writeGeneration !== gen) { return } - const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile - const dir = dirname(dataFile) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - - // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. - let renamed = false - try { - // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty - // content after power loss, losing projects/tabs back to the newest usable .bak slot. - writeFileDurableSync(tmpFile, dataFile, payload) - renamed = true - owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash - owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( - protectedSecretUpdates - ) - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - owner[primaryStateWriteOperationsContext].runtime.writeGeneration - ) - } finally { - if (!renamed) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup; the write already failed, swallow secondary error. - } - } - } - const now = Date.now() - if ( - !opts.skipBackupRotation && - owner[primaryStateWriteOperationsContext].backups.shouldRotateBackups(now, dataFile) - ) { - owner[primaryStateWriteOperationsContext].backups.rotateBackupsSync(dataFile) - } + await backups.rotateBackupsAsync(dataFile) } export function installPrimaryStateWriteOperationsContext( diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts index 8e910ed235d2..b52e748f1908 100644 --- a/src/main/persistence/loading-store/profile-preferences.ts +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -159,7 +159,7 @@ export function getSettingsMutationOperations( bumpLocalWorktreeScanGeneration, removeRetainedBlob: (slot) => owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot), - scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling, ['settings']), notifySettingsChanged: (updates, originWebContentsId) => notifySettingsChanged(owner, updates, originWebContentsId) } @@ -183,7 +183,7 @@ export function getFeatureInteractionOperations( ): FeatureInteractionOperations { return { state: owner[profilePreferencesContext].runtime.state, - scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + scheduleSave: (domains) => scheduleSave(owner[profilePreferencesContext].scheduling, domains), notifyUIChanged: () => notifyUIChanged(owner), getUI: () => owner.getUI() } diff --git a/src/main/persistence/loading-store/profile-state-authority-writes.ts b/src/main/persistence/loading-store/profile-state-authority-writes.ts new file mode 100644 index 000000000000..78949576f5ad --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-authority-writes.ts @@ -0,0 +1,59 @@ +import { createHash } from 'node:crypto' +import { + applySecretSentinelSubstitutions, + type SecretSentinelSubstitution +} from './secret-sentinel-substitution' +import type { ProfileStateDomainReplacement } from './profile-state-authority' + +export function buildProfileStateDomainReplacements( + payload: Buffer, + dirtyDomains: ReadonlySet +): ProfileStateDomainReplacement[] { + const parsed: unknown = JSON.parse(payload.toString('utf8')) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Profile state payload must be a JSON object') + } + const entries = new Map(Object.entries(parsed)) + return [...dirtyDomains].map((domain) => { + if (!entries.has(domain)) { + return { domain, payload: null } + } + const serialized = JSON.stringify(entries.get(domain)) + if (serialized === undefined) { + throw new Error(`Profile state domain payload is not serializable: ${domain}`) + } + return { domain, payload: serialized } + }) +} + +export function serializeCompleteProfileStateDomains( + state: Record, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string +): { payload: Buffer; stateHash: string; domains: readonly ProfileStateDomainReplacement[] } { + const domains: ProfileStateDomainReplacement[] = [] + const hash = createHash('sha1').update(degradedPrefix) + for (const [domain, value] of Object.entries(state)) { + // The wrapper preserves the original property name passed to a value's toJSON. + const fragment = JSON.stringify({ [domain]: value }) + if (fragment === '{}') { + continue + } + const serialized = applySecretSentinelSubstitutions(fragment, substitutions, '') + hash.update(serialized.stateHash) + domains.push({ + domain, + payload: serialized.payload.toString('utf8').slice(JSON.stringify(domain).length + 2, -1) + }) + } + return { + domains, + stateHash: hash.digest('hex'), + get payload() { + return Buffer.from( + `{${domains.map(({ domain, payload }) => `${JSON.stringify(domain)}:${payload}`).join(',')}}`, + 'utf8' + ) + } + } +} diff --git a/src/main/persistence/loading-store/profile-state-authority.ts b/src/main/persistence/loading-store/profile-state-authority.ts new file mode 100644 index 000000000000..e59d7818932c --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-authority.ts @@ -0,0 +1,80 @@ +import type { AutomationRun } from '../../../shared/automations-types' +import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' + +/** + * The primary profile-state boundary used by Store. + * + * The legacy implementation is still the default. Keeping this contract + * independent of SQLite lets the Node 18 orcad bundle load Store without + * eagerly loading a newer runtime's `node:sqlite` module. + */ +export type ProfileStateAuthority = { + /** Return storage-form JSON, or undefined when this authority has no state yet. */ + readSerializedState(): string | undefined + + /** Fence a hash-identical checkpoint without rereading or rewriting its payload. */ + assertCurrentRevision?: () => void + + /** + * Optionally commit only the explicitly dirty top-level domains. Authorities + * without this capability retain the complete-document fallback below. + */ + writeSerializedDomains?: (replacements: readonly ProfileStateDomainReplacement[]) => void + + /** + * Commit automation definition replacements and the changed run projection + * in one profile-revision transaction without serializing unrelated domains. + */ + writeSerializedAutomationRuns?: ( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ) => void + + /** + * Durably replace the complete storage-form document. Implementations may + * reject a stale read instead of allowing last-writer-wins replacement. + */ + writeSerializedState(payload: Buffer): void + + /** Replace the whole profile; omitted domains and null payloads are deleted. */ + writeCompleteSerializedDomains?: (replacements: readonly ProfileStateDomainReplacement[]) => void + + /** Schedule bounded recovery protection after a successful primary commit. */ + scheduleBackup?: () => void + + /** Drain owned backup handles before shutdown or profile file mutations. */ + drainBackups?: () => Promise + + /** Optionally publish a durable JSON export for rollback or a compatibility runtime. */ + writeJsonExport?: (targetPath: string) => number + + /** Publish canonical JSON for an older build and advance its SQLite acceptance marker. */ + writeJsonCompatibilityExport?: (targetPath: string) => number | undefined + + /** Refresh compatibility JSON after the final flush with asynchronous JSON file writes. */ + writeJsonCompatibilityExportAsync?: (targetPath: string) => Promise + + /** Optionally preserve the database family before an explicit recovery decision. */ + quarantineDatabase?: (quarantineRoot?: string, reason?: string) => ProfileStateDatabaseQuarantine + + /** Release any process-local database handle before a profile is switched or removed. */ + close?: () => void +} + +export type ProfileStateDomainReplacement = { + domain: string + /** Storage-form JSON for the domain, or null to remove its row. */ + payload: string | null +} + +/** A startup read paired with the authority that observed its revision. */ +export type ProfileStateAuthorityInitialState = { + readonly authority: ProfileStateAuthority +} & ( + | { readonly serializedState: string | undefined; readonly takeParsedState?: never } + | { + readonly serializedState?: never + /** Transfer this storage-form object once, before the loader can decrypt or mutate it. */ + readonly takeParsedState: () => Record | undefined + } +) diff --git a/src/main/persistence/loading-store/profile-state-checkpoints.test.ts b/src/main/persistence/loading-store/profile-state-checkpoints.test.ts new file mode 100644 index 000000000000..a57fb98c73f9 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-checkpoints.test.ts @@ -0,0 +1,230 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { parseProfileStateRoot } from '../profile-state/profile-state-document-validation' +import { readProfileStateSnapshot } from '../profile-state/profile-state-documents' +import { profileStateJsonExportPath } from '../profile-state/profile-state-export-path' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { Store } from './store' +import { scheduleSave } from './write-scheduling' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const PROFILE_ID = 'checkpoint-test' +const fixtures: { directory: string; store: Store }[] = [] + +afterEach(async () => { + for (const { directory, store } of fixtures.splice(0)) { + store.freezeWrites() + await store.flushAsync() + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-checkpoint-')) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const authority = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + const backup = vi.spyOn(authority, 'scheduleBackup').mockImplementation(() => {}) + authority.writeSerializedState( + Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(directory))) + ) + const store = new Store({ dataFile, profileStateAuthority: authority }) + fixtures.push({ directory, store }) + store.flushOrThrow() + return { + directory, + dataFile, + store, + authority, + backup, + readState: () => { + const opened = openProfileStateDatabaseReadOnly(databasePath, PROFILE_ID) + try { + return parseProfileStateRoot(readProfileStateSnapshot(opened.db).json) + } finally { + opened.db.close() + } + } + } +} + +function mutateThroughGetters(store: Store): void { + store.getWorkspaceSession().activeTabId = 'direct-local-tab' + store.getWorkspaceSession('ssh:build-host').activeTabId = 'direct-remote-tab' +} + +const EXPECTED_CHECKPOINT = { + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'direct-local-tab' }, + workspaceSessionsByHostId: { 'ssh:build-host': { activeTabId: 'direct-remote-tab' } } +} + +describe('complete profile state checkpoints', () => { + it.each(['sync', 'async'] as const)( + 'rejects a stale %s checkpoint even when the local hash is unchanged', + async (mode) => { + const { store, directory, readState } = fixture() + const other = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), PROFILE_ID) + try { + other.readSerializedState() + other.writeSerializedDomains([{ domain: 'futureDomain', payload: '{"external":true}' }]) + scheduleSave(store) + if (mode === 'sync') { + expect(() => store.flushOrThrow()).toThrow(/Profile state revision changed/) + } else { + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow( + /Profile state revision changed/ + ) + } + expect(readState()).toMatchObject({ futureDomain: { external: true } }) + } finally { + other.close() + } + } + ) + + it('captures nested history mutations after an unchanged checkpoint', async () => { + const { store, directory, readState } = fixture() + store.writeProfileStateJsonExport(join(directory, 'before.json')) + const run = store.listAutomationRuns()[0] + if (!run?.outputSnapshot) { + throw new Error('Expected a fixture run with output') + } + run.outputSnapshot.content = 'changed through a nested getter' + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + + await store.flushAsync() + + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + automationRuns: expect.arrayContaining([ + expect.objectContaining({ + id: run.id, + outputSnapshot: expect.objectContaining({ content: 'changed through a nested getter' }) + }) + ]) + }) + }) + + it.each([false, true])( + 'captures getter mutations alongside pending settings on quit (compatibility export: %s)', + async (exportJsonCompatibility) => { + const { store, dataFile, readState } = fixture() + mutateThroughGetters(store) + store.updateSettings({ theme: 'dark' }) + + await store.flushAsync({ exportJsonCompatibility }) + + expect(readState()).toMatchObject(EXPECTED_CHECKPOINT) + if (exportJsonCompatibility) { + expect(parseProfileStateRoot(readFileSync(dataFile, 'utf8'))).toMatchObject( + EXPECTED_CHECKPOINT + ) + } + } + ) + + it.each(['explicit', 'revisioned', 'compatibility'] as const)( + 'includes getter mutations in the %s JSON export', + (mode) => { + const { store, directory, dataFile, readState } = fixture() + mutateThroughGetters(store) + store.updateSettings({ theme: 'dark' }) + let exportPath = join(directory, 'rollback.json') + + if (mode === 'explicit') { + store.writeProfileStateJsonExport(exportPath) + } else if (mode === 'revisioned') { + const revision = store.writeLatestProfileStateJsonExport() + if (revision === undefined) { + throw new Error('Expected a revisioned export') + } + exportPath = profileStateJsonExportPath(dataFile, revision) + } else { + store.writeLatestProfileStateJsonCompatibilityExport() + exportPath = dataFile + } + + expect(readState()).toMatchObject(EXPECTED_CHECKPOINT) + expect(parseProfileStateRoot(readFileSync(exportPath, 'utf8'))).toMatchObject( + EXPECTED_CHECKPOINT + ) + } + ) + + it('takes the final checkpoint after an earlier queued writer clears its dirty domains', async () => { + const { store, backup, readState } = fixture() + store.updateSettings({ theme: 'light' }) + backup.mockImplementationOnce(() => { + queueMicrotask(() => { + mutateThroughGetters(store) + store.updateSettings({ theme: 'dark' }) + }) + }) + + const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const final = store.flushAsync() + await Promise.all([previous, final]) + + expect(readState()).toMatchObject(EXPECTED_CHECKPOINT) + }) + + it('keeps normal pending and synchronous writes selective', async () => { + const { store, authority } = fixture() + const fullWrite = vi.spyOn(authority, 'writeSerializedState') + const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') + + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + store.patchWorkspaceSession({ activeTabId: 'scheduled-tab' }) + store.flushOrThrow() + + expect(fullWrite).not.toHaveBeenCalled() + expect( + selectiveWrite.mock.calls.map(([domains]) => domains.map(({ domain }) => domain)) + ).toEqual([['settings'], ['workspaceSession']]) + }) + + it('clears full-checkpoint mode after a synchronous hash no-op', () => { + const { store, authority } = fixture() + store.writeLatestProfileStateJsonExport() + + const completeWrite = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + + expect(completeWrite).not.toHaveBeenCalled() + expect( + selectiveWrite.mock.calls.map(([domains]) => domains.map(({ domain }) => domain)) + ).toEqual([['settings']]) + }) + + it('does not write a frozen profile when final persistence requests a checkpoint', async () => { + const { store, authority, readState } = fixture() + const before = readState() + const fullWrite = vi.spyOn(authority, 'writeSerializedState') + mutateThroughGetters(store) + store.updateSettings({ theme: 'dark' }) + store.freezeWrites() + + await store.flushAsync() + + expect(fullWrite).not.toHaveBeenCalled() + expect(readState()).toEqual(before) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts new file mode 100644 index 000000000000..2be5aadf960c --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts @@ -0,0 +1,188 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CodexResetCreditAttemptLedger } from '../../../shared/codex-reset-credit-attempt-ledger' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { readProfileStateDomain } from '../profile-state/profile-state-domain-reader' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { Store } from './store' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`), + decryptString: (value: Buffer) => value.toString().slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const PROFILE_ID = 'direct-flush-test' +const fixtures: { directory: string; store: Store }[] = [] + +afterEach(async () => { + for (const fixture of fixtures.splice(0)) { + fixture.store.freezeWrites() + await fixture.store.flushAsync() + rmSync(fixture.directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function fixture(seedState?: unknown) { + const directory = mkdtempSync(join(tmpdir(), 'orca-direct-flush-')) + const databasePath = join(directory, 'profile-state.db') + const authority = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + vi.spyOn(authority, 'scheduleBackup').mockImplementation(() => {}) + if (seedState !== undefined) { + authority.writeSerializedState(Buffer.from(JSON.stringify(seedState))) + } + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) + fixtures.push({ directory, store }) + store.flushOrThrow() + return { + store, + read: (domain: string) => readProfileStateDomain(databasePath, PROFILE_ID, domain), + pendSession: () => store.patchWorkspaceSession({ activeWorktreeId: 'pending-workspace' }) + } +} + +describe('SQLite durability barriers with another selective write pending', () => { + it('commits a reset-credit claim before returning to its provider caller', () => { + const state = fixture() + const ledger: CodexResetCreditAttemptLedger = { + version: 1, + attempts: [ + { + idempotencyKey: '158a0d86-8d8e-4589-b9c5-f53a59bdcdd8', + expectedScope: { + target: { runtime: 'host', wslDistro: null }, + accountId: 'account', + accountRevision: 1, + offerRevision: 'v1:offer' + }, + state: 'providerPending' + } + ] + } + state.pendSession() + state.store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) + expect(state.read('codexResetCreditAttemptLedger')).toMatchObject({ + kind: 'value', + value: ledger + }) + }) + + it('commits Claude live-PTY admission before returning', () => { + const state = fixture() + state.pendSession() + state.store.addClaudeLivePtySessionId('claude-session') + expect(state.read('claudeLivePtySessionIds')).toMatchObject({ + kind: 'value', + value: ['claude-session'] + }) + }) + + it('commits SSH lease admission before returning', () => { + const state = fixture() + state.pendSession() + state.store.upsertSshRemotePtyLease({ targetId: 'ssh-test', ptyId: 'pty-1', state: 'attached' }) + expect(state.read('sshRemotePtyLeases')).toMatchObject({ + kind: 'value', + value: [{ targetId: 'ssh-test', ptyId: 'pty-1', state: 'attached' }] + }) + }) + + it.each(['async', 'shutdown'] as const)( + 'persists SSH detachment through the %s barrier', + async (barrier) => { + const state = fixture() + state.store.upsertSshRemotePtyLease({ + targetId: 'ssh-test', + ptyId: 'pty-1', + state: 'attached' + }) + state.pendSession() + if (barrier === 'async') { + await state.store.markSshRemotePtyLeasesAsync('ssh-test', 'detached') + } else { + state.store.markSshRemotePtyLeasesForShutdown('ssh-test', 'detached') + await state.store.flushAsync() + } + expect(state.read('sshRemotePtyLeases')).toMatchObject({ + kind: 'value', + value: [{ targetId: 'ssh-test', ptyId: 'pty-1', state: 'detached' }] + }) + } + ) + + it('persists sealed SSH consumer recovery before relay setup continues', async () => { + const state = fixture() + state.pendSession() + await state.store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-test', + clientInstanceId: 'client-test', + serverBuildId: 'build-test', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'secret-owner-lease' + }) + const stored = state.read('sshPtyConsumerRecoveries') + expect(stored).toMatchObject({ kind: 'value', value: [{ targetId: 'ssh-test' }] }) + expect(JSON.stringify(stored)).not.toContain('secret-owner-lease') + state.pendSession() + await state.store.removeSshPtyConsumerRecovery('ssh-test') + expect(state.read('sshPtyConsumerRecoveries')).toMatchObject({ kind: 'value', value: [] }) + }) + + it('deletes an automation and its retained runs in the same commit', () => { + const state = fixture(buildProfileStateCutoverFixture()) + const automation = state.store.listAutomations()[0] + if (!automation) { + throw new Error('Fixture automation is absent') + } + expect(state.store.listAutomationRuns(automation.id)).not.toHaveLength(0) + state.store.deleteAutomation(automation.id) + expect(state.read('automations')).toMatchObject({ kind: 'value', value: [] }) + expect(state.read('automationRuns')).toMatchObject({ kind: 'value', value: [] }) + }) + + it('persists the session and UI identities moved with worktree metadata', () => { + const seed = buildProfileStateCutoverFixture() + const oldId = 'repo-local::/fixture/local' + const newId = 'repo-local::/fixture/renamed' + seed.workspaceSession.activeWorktreeId = oldId + seed.ui.showDotfilesByWorktree = { [oldId]: true } + const state = fixture(seed) + state.store.migrateWorktreeIdentity(oldId, newId) + state.store.flushOrThrow() + expect(state.read('workspaceSession')).toMatchObject({ + kind: 'value', + value: { activeWorktreeId: newId } + }) + expect(state.read('ui')).toMatchObject({ + kind: 'value', + value: { showDotfilesByWorktree: { [newId]: true } } + }) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-selective-write.ts b/src/main/persistence/loading-store/profile-state-selective-write.ts new file mode 100644 index 000000000000..3e87f34ad7b6 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-selective-write.ts @@ -0,0 +1,73 @@ +import type { ProtectedSecretRetentionUpdate } from '../../protected-secret-persistence' +import type { ProfileStateAuthority } from './profile-state-authority' +import { buildProfileStateDomainReplacements } from './profile-state-authority-writes' +import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import type { AutomationRun } from '../../../shared/automations-types' + +export type SelectiveProfileStateWriteResult = { + handled: boolean + aborted: boolean + consumedAutomationRuns: boolean + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] +} + +export function writeSelectiveProfileState( + authority: ProfileStateAuthority | undefined, + serialization: StateSerializationSecretHandlingOperations, + dirtyDomains: Set | null, + pendingAutomationRunsAfter: readonly AutomationRun[] | undefined, + isCurrent?: () => boolean +): SelectiveProfileStateWriteResult { + if ( + !authority || + dirtyDomains === null || + dirtyDomains.size === 0 || + !authority.writeSerializedDomains + ) { + return { + handled: false, + aborted: false, + consumedAutomationRuns: false, + protectedSecretUpdates: [] + } + } + const useAutomationDelta = + pendingAutomationRunsAfter !== undefined && + authority.writeSerializedAutomationRuns !== undefined + const serializableDomains = useAutomationDelta + ? new Set([...dirtyDomains].filter((domain) => domain !== 'automationRuns')) + : dirtyDomains + const built = serialization.buildStateDomainsToSave(serializableDomains) + if (built === undefined) { + return { + handled: false, + aborted: false, + consumedAutomationRuns: false, + protectedSecretUpdates: [] + } + } + const { payload, protectedSecretUpdates } = built + if (isCurrent && !isCurrent()) { + return { + handled: true, + aborted: true, + consumedAutomationRuns: false, + protectedSecretUpdates: [] + } + } + if (useAutomationDelta) { + authority.writeSerializedAutomationRuns?.( + buildProfileStateDomainReplacements(payload, serializableDomains), + pendingAutomationRunsAfter + ) + } else { + authority.writeSerializedDomains(buildProfileStateDomainReplacements(payload, dirtyDomains)) + } + dirtyDomains.clear() + return { + handled: true, + aborted: false, + consumedAutomationRuns: pendingAutomationRunsAfter !== undefined, + protectedSecretUpdates + } +} diff --git a/src/main/persistence/loading-store/profile-state-settings-writes.test.ts b/src/main/persistence/loading-store/profile-state-settings-writes.test.ts new file mode 100644 index 000000000000..e7f64b6278a4 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-settings-writes.test.ts @@ -0,0 +1,320 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import { setSecretStore } from '../../../shared/secret-store' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { readProfileStateSnapshot } from '../profile-state/profile-state-documents' +import { parseProfileStateRoot } from '../profile-state/profile-state-document-validation' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { Store } from './store' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const ORIGINAL = { + opencodeSessionCookie: 'original-cookie', + httpProxyUrl: 'http://original:password@proxy.test:8080' +} +type Failure = 'none' | 'unavailable' | 'availability' | 'encryption' | 'decryption' +let failure: Failure = 'none' +let nonce = 0 +const directories: string[] = [] +const stores: Store[] = [] + +beforeEach(() => { + failure = 'none' + nonce = 0 + setSecretStore({ + isEncryptionAvailable: () => { + if (failure === 'availability') { + throw new Error('keychain unavailable') + } + return failure !== 'unavailable' + }, + encryptString: (plaintext) => { + if (failure === 'encryption') { + throw new Error('encryption failed') + } + return Buffer.from(`cipher:${++nonce}:${plaintext}`) + }, + decryptString: (ciphertext) => { + if (failure === 'decryption') { + throw new Error('decryption failed') + } + const value = ciphertext.toString() + if (!value.startsWith('cipher:')) { + throw new Error('invalid ciphertext') + } + return value.slice(value.indexOf(':', 'cipher:'.length) + 1) + }, + describeProtectionGap: () => null + }) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(() => {}) +}) + +afterEach(async () => { + for (const store of stores.splice(0)) { + store.freezeWrites() + await store.flushAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-settings-domain-')) + directories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const profileId = 'settings-domain' + const authority = new ProfileStateSqliteAuthority(databasePath, profileId) + const fixtureState = buildProfileStateCutoverFixture(directory) + authority.writeSerializedState( + Buffer.from( + JSON.stringify({ + ...getDefaultPersistedState(directory), + automationRuns: fixtureState.automationRuns, + futureTopLevelExtension: fixtureState.futureTopLevelExtension + }) + ) + ) + const createStore = (storage = new ProfileStateSqliteAuthority(databasePath, profileId)) => { + const store = new Store({ dataFile, profileStateAuthority: storage }) + stores.push(store) + return store + } + const store = createStore(authority) + store.updateSettings(ORIGINAL) + store.flushOrThrow() + const read = () => { + const opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + return { + state: parseProfileStateRoot(readProfileStateSnapshot(opened.db).json), + otherDocuments: opened.db + .prepare( + "SELECT * FROM profile_state_documents WHERE domain <> 'settings' ORDER BY rowid" + ) + .all(), + runs: opened.db.prepare('SELECT * FROM profile_state_automation_runs ORDER BY run_id').all() + } + } finally { + opened.db.close() + } + } + return { store, authority, read, reopen: () => createStore() } +} + +describe('selective SQLite settings persistence', () => { + it.each(['sync', 'async'] as const)( + 'saves settings through the %s barrier without rewriting history or unknown domains', + async (mode) => { + const state = fixture() + const before = state.read() + const wholeWrite = vi.spyOn(state.authority, 'writeCompleteSerializedDomains') + state.store.updateSettings({ terminalFontSize: 19, httpProxyBypassRules: '*.internal' }) + if (mode === 'sync') { + state.store.flushOrThrow() + } else { + await state.store.flushPendingOrThrowAsync() + } + expect(wholeWrite).not.toHaveBeenCalled() + const after = state.read() + expect(after.otherDocuments).toEqual(before.otherDocuments) + expect(after.runs).toEqual(before.runs) + expect(after.state).toMatchObject({ + settings: { terminalFontSize: 19, httpProxyBypassRules: '*.internal' }, + futureTopLevelExtension: before.state.futureTopLevelExtension + }) + expect(JSON.stringify(after.state)).not.toContain(ORIGINAL.opencodeSessionCookie) + expect(JSON.stringify(after.state)).not.toContain(ORIGINAL.httpProxyUrl) + expect(state.reopen().getSettings()).toMatchObject({ ...ORIGINAL, terminalFontSize: 19 }) + } + ) + + it.each(['unavailable', 'availability', 'encryption'] as const)( + 'retains committed secrets while %s and retries them after recovery', + async (mode) => { + const state = fixture() + const before = state.read() + failure = mode + state.store.updateSettings({ opencodeSessionCookie: 'pending-cookie', terminalFontSize: 18 }) + await state.store.flushPendingOrThrowAsync() + expect(state.read().state.settings).toEqual({ + ...getSettingsRecord(before.state), + terminalFontSize: 18 + }) + failure = 'none' + state.store.updateSettings({ terminalFontSize: 20 }) + await state.store.flushPendingOrThrowAsync() + expect(state.reopen().getSettings()).toMatchObject({ + ...ORIGINAL, + opencodeSessionCookie: 'pending-cookie', + terminalFontSize: 20 + }) + } + ) + + it('preserves sealed settings on unrelated saves and permits an explicit clear', () => { + const state = fixture() + const before = state.read() + state.store.freezeWrites() + failure = 'decryption' + const sealed = state.reopen() + sealed.flushOrThrow() + expect(sealed.getSettings().opencodeSessionCookie).toBe('') + sealed.updateSettings({ terminalFontSize: 21 }) + sealed.flushOrThrow() + expect(state.read().state.settings).toEqual({ + ...getSettingsRecord(before.state), + terminalFontSize: 21 + }) + sealed.updateSettings({ opencodeSessionCookie: '', httpProxyUrl: '' }) + sealed.flushOrThrow() + failure = 'none' + expect(state.reopen().getSettings()).toMatchObject({ + opencodeSessionCookie: '', + httpProxyUrl: '', + terminalFontSize: 21 + }) + }) + + it('does not retain ciphertext from a failed selective commit', () => { + const state = fixture() + const before = state.read() + vi.spyOn(state.authority, 'writeSerializedDomains').mockImplementationOnce(() => { + throw new Error('commit failed') + }) + state.store.updateSettings({ opencodeSessionCookie: 'uncommitted-cookie' }) + expect(() => state.store.flushOrThrow()).toThrow('commit failed') + expect(state.read()).toEqual(before) + failure = 'unavailable' + state.store.updateSettings({ terminalFontSize: 22 }) + state.store.flushOrThrow() + failure = 'none' + expect(state.reopen().getSettings()).toMatchObject({ ...ORIGINAL, terminalFontSize: 22 }) + }) + + it('commits pending session/settings domains together and falls back for unclassified updates', () => { + const state = fixture() + state.store.patchWorkspaceSession({ activeTabId: 'pending-tab' }) + state.store.updateSettings({ terminalFontSize: 23 }) + state.store.flushOrThrow() + expect(state.read().state).toMatchObject({ + settings: { terminalFontSize: 23 }, + workspaceSession: { activeTabId: 'pending-tab' } + }) + const wholeWrite = vi.spyOn(state.authority, 'writeCompleteSerializedDomains') + state.store.updateSettings({ terminalFontSize: 24 }) + state.store.updateOnboarding({ outcome: 'completed' }) + state.store.flushOrThrow() + expect(wholeWrite).toHaveBeenCalledOnce() + expect(state.read().state).toMatchObject({ + settings: { terminalFontSize: 24 }, + onboarding: { outcome: 'completed' } + }) + }) + + it.each(['unavailable', 'encryption'] as const)( + 'retries deferred UI and SSH secrets on a settings save after %s recovers', + async (mode) => { + const state = fixture() + const recovery = { + targetId: 'ssh-test', + clientInstanceId: 'client-test', + serverBuildId: 'build-test', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'original-lease' + } + state.store.updateUI({ browserKagiSessionLink: 'original-link' }) + await state.store.upsertSshPtyConsumerRecovery(recovery) + const before = state.read().state + + failure = mode + state.store.updateUI({ browserKagiSessionLink: 'pending-link' }) + await state.store.flushPendingOrThrowAsync() + await state.store.upsertSshPtyConsumerRecovery({ ...recovery, ownerLease: 'pending-lease' }) + expect(state.read().state).toMatchObject({ + ui: before.ui, + sshPtyConsumerRecoveries: before.sshPtyConsumerRecoveries + }) + + failure = 'none' + const wholeWrite = vi.spyOn(state.authority, 'writeCompleteSerializedDomains') + wholeWrite.mockImplementationOnce(() => { + throw new Error('recovery commit failed') + }) + state.store.updateSettings({ terminalFontSize: 25 }) + await expect(state.store.flushPendingOrThrowAsync()).rejects.toThrow('recovery commit failed') + expect(state.read().state).toMatchObject({ + ui: before.ui, + sshPtyConsumerRecoveries: before.sshPtyConsumerRecoveries + }) + state.store.updateSettings({ terminalFontSize: 26 }) + await state.store.flushPendingOrThrowAsync() + expect(wholeWrite).toHaveBeenCalledTimes(2) + const reopened = state.reopen() + expect(reopened.getUI().browserKagiSessionLink).toBe('pending-link') + expect(reopened.getSshPtyConsumerRecovery('ssh-test')?.ownerLease).toBe('pending-lease') + expect(JSON.stringify(state.read().state)).not.toMatch(/pending-link|pending-lease/) + + state.store.updateSettings({ terminalFontSize: 27 }) + await state.store.flushPendingOrThrowAsync() + expect(wholeWrite).toHaveBeenCalledTimes(2) + } + ) + + it('keeps an explicit UI secret clear after an unavailable write and later settings save', async () => { + const state = fixture() + state.store.updateUI({ browserKagiSessionLink: 'original-link' }) + await state.store.flushPendingOrThrowAsync() + failure = 'unavailable' + state.store.updateUI({ browserKagiSessionLink: 'pending-link' }) + await state.store.flushPendingOrThrowAsync() + state.store.updateUI({ browserKagiSessionLink: null }) + await state.store.flushPendingOrThrowAsync() + failure = 'none' + state.store.updateSettings({ terminalFontSize: 28 }) + await state.store.flushPendingOrThrowAsync() + expect(state.reopen().getUI().browserKagiSessionLink).toBeNull() + }) + + it.each(['unavailable', 'decryption'] as const)( + 'persists an explicit empty UI secret clear after reopening with %s secrets', + async (mode) => { + const state = fixture() + state.store.updateUI({ browserKagiSessionLink: 'original-link' }) + await state.store.flushPendingOrThrowAsync() + state.store.freezeWrites() + failure = mode + const sealed = state.reopen() + await sealed.flushPendingOrThrowAsync() + expect(sealed.getUI().browserKagiSessionLink).toBe('') + sealed.updateUI({ browserKagiSessionLink: '' }) + sealed.updateSettings({ terminalFontSize: 29 }) + await sealed.flushPendingOrThrowAsync() + failure = 'none' + expect(state.reopen().getUI().browserKagiSessionLink).toBeNull() + } + ) +}) + +function getSettingsRecord(state: Record): Record { + const settings = state.settings + if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) { + throw new Error('Expected persisted settings') + } + return Object.fromEntries(Object.entries(settings)) +} diff --git a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts new file mode 100644 index 000000000000..624055a77ee5 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts @@ -0,0 +1,857 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_AUTOMATION_RUNS_PER_AUTOMATION } from '../../../shared/automation-run-retention' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { + profileStateJsonMatchesAcceptance, + exportProfileStateJson, + hashProfileStateJson, + importProfileStateJson, + readProfileStateJsonAcceptance, + readProfileStateSnapshot +} from '../profile-state/profile-state-documents' +import { parseProfileStateRoot } from '../profile-state/profile-state-document-validation' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../profile-state/profile-state-database' +import { profileStateJsonExportPath } from '../profile-state/profile-state-export-path' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +const { Store } = await import('./store') +const { createProfileStateStore } = await import('../profile-state/profile-state-store-factory') + +const temporaryDirectories: string[] = [] +const backupAuthorities = new Set() +const authorities = new Set() +const scheduleBackup = ProfileStateSqliteAuthority.prototype.scheduleBackup + +function createAuthority(databasePath: string, profileId: string): ProfileStateSqliteAuthority { + const authority = new ProfileStateSqliteAuthority(databasePath, profileId) + authorities.add(authority) + return authority +} + +beforeEach(() => { + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation( + function (this: ProfileStateSqliteAuthority) { + backupAuthorities.add(this) + scheduleBackup.call(this) + } + ) +}) + +afterEach(async () => { + for (const authority of authorities) { + authority.close() + await authority.drainBackups() + } + authorities.clear() + backupAuthorities.clear() + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +describe('Store with an injected SQLite profile-state authority', () => { + it('rejects profile-state buffers that are not valid UTF-8', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-invalid-utf8-')) + temporaryDirectories.push(directory) + const authority = createAuthority(join(directory, 'profile-state.db'), 'profile-authority-test') + + authority.writeSerializedState(Buffer.from('{"settings":{"theme":"dark"}}')) + const before = authority.readSerializedState() + const malformed = Buffer.concat([ + Buffer.from('{"settings":{"theme":"'), + Buffer.from([0xff]), + Buffer.from('"}}') + ]) + expect(() => authority.writeSerializedState(malformed)).toThrow( + 'Profile state payload is not valid UTF-8' + ) + expect(authority.readSerializedState()).toBe(before) + authority.close() + }) + + it('mutates, flushes, and reloads without writing the legacy JSON file', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + writeFileSync(dataFile, '{"settings":{"theme":"light"}}', 'utf8') + const legacyBytes = readFileSync(dataFile) + const authority = createAuthority(databaseFile, 'profile-authority-test') + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark', opencodeSessionCookie: 'authority-secret' }) + store.flushOrThrow() + + store.updateSettings({ terminalFontSize: store.getSettings().terminalFontSize + 1 }) + await store.flushPendingOrThrowAsync() + + expect(readFileSync(dataFile)).toEqual(legacyBytes) + expect(existsSync(databaseFile)).toBe(true) + + const reloaded = new Store({ dataFile, profileStateAuthority: authority }) + expect(reloaded.getSettings().theme).toBe('dark') + expect(reloaded.getSettings().terminalFontSize).toBe(store.getSettings().terminalFontSize) + expect(reloaded.getSettings().opencodeSessionCookie).toBe('authority-secret') + expect(readFileSync(dataFile)).toEqual(legacyBytes) + reloaded.freezeWrites() + }) + + it('rejects a corrupt ordering placeholder when normalized rows exist', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-normalized-read-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const fixture = buildProfileStateCutoverFixture() + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify(fixture))) + authority.close() + + const opened = openProfileStateDatabase(databasePath, 'profile-authority-test') + opened.db + .prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?') + .run('{invalid', 'automationRuns') + opened.db.close() + + const runtime = createAuthority(databasePath, 'profile-authority-test') + expect(() => runtime.readSerializedState()).toThrow(/hash mismatch: automationRuns/) + runtime.close() + + const strict = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect(() => readProfileStateSnapshot(strict.db)).toThrow(/hash mismatch: automationRuns/) + } finally { + strict.db.close() + } + }) + + it('rejects invalid domain JSON before handing it to the Store', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-runtime-parse-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'dark' } }))) + authority.close() + + const opened = openProfileStateDatabase(databasePath, 'profile-authority-test') + opened.db + .prepare('UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?') + .run('{invalid', hashProfileStateJson('{invalid'), 'settings') + opened.db.close() + + expect( + () => + new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + ).toThrow('Profile state document payload is invalid JSON: settings') + }) + + it('rejects documents whose revision metadata was removed or reset', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const opened = openProfileStateDatabase(databasePath, 'profile-authority-test') + importProfileStateJson(opened.db, JSON.stringify({ settings: { theme: 'dark' } })) + opened.db.prepare("DELETE FROM profile_state_meta WHERE key = 'revision'").run() + opened.db.close() + + const authority = createAuthority(databasePath, 'profile-authority-test') + expect(() => authority.readSerializedState()).toThrow() + }) + + it('fences a complete-document writer that read before another authority committed', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-cas-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const first = createAuthority(databasePath, 'profile-authority-test') + const second = createAuthority(databasePath, 'profile-authority-test') + + first.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'light' } }))) + expect(second.readSerializedState()).toBe(JSON.stringify({ settings: { theme: 'light' } })) + + first.readSerializedState() + first.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'dark' } }))) + expect(() => + second.writeSerializedState( + Buffer.from(JSON.stringify({ settings: { theme: 'stale-writer' } })) + ) + ).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 1, + actualRevision: 2 + }) + ) + + const verifier = createAuthority(databasePath, 'profile-authority-test') + expect(verifier.readSerializedState()).toBe(JSON.stringify({ settings: { theme: 'dark' } })) + }) + + it('fences a first commit after another authority creates the database', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-create-cas-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const first = createAuthority(databasePath, 'profile-authority-test') + const second = createAuthority(databasePath, 'profile-authority-test') + + expect(first.readSerializedState()).toBeUndefined() + second.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'other' } }))) + + expect(() => + first.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'stale' } }))) + ).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 0, + actualRevision: 1 + }) + ) + }) + + it('keeps normalized rows stable during a complete document replacement', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-complete-write-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const fixture = buildProfileStateCutoverFixture(directory) + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify(fixture))) + + const before = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + const beforeAutomationMeta = before.db + .prepare( + 'SELECT revision, content_hash FROM profile_state_automation_runs_meta WHERE domain = ?' + ) + .get('automationRuns') + const beforeAutomationRows = before.db + .prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs') + .get() + before.db.close() + + const replacement = parseProfileStateRoot(authority.readSerializedState() ?? '{}') + replacement.settings = { theme: 'complete-replacement' } + replacement.unknownDomain = { preserved: true } + delete replacement.ui + authority.writeSerializedState(Buffer.from(JSON.stringify(replacement))) + + expect(JSON.parse(authority.readSerializedState() ?? '{}')).toMatchObject({ + settings: { theme: 'complete-replacement' }, + unknownDomain: { preserved: true } + }) + const after = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect( + after.db.prepare('SELECT value FROM profile_state_meta WHERE key = ?').get('revision') + ).toEqual({ value: '2' }) + expect( + after.db + .prepare( + 'SELECT revision, content_hash FROM profile_state_automation_runs_meta WHERE domain = ?' + ) + .get('automationRuns') + ).toEqual(beforeAutomationMeta) + expect( + after.db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get() + ).toEqual(beforeAutomationRows) + expect( + after.db.prepare('SELECT 1 FROM profile_state_documents WHERE domain = ?').get('ui') + ).toBe(undefined) + } finally { + after.db.close() + } + }) + + it('reopens its writer after an explicit close without losing the revision fence', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-close-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'light' } }))) + authority.close() + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'dark' } }))) + + const verifier = createAuthority(databasePath, 'profile-authority-test') + expect(verifier.readSerializedState()).toBe(JSON.stringify({ settings: { theme: 'dark' } })) + verifier.close() + }) + + it('keeps a newer Store commit when a stale Store flushes afterward', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-store-cas-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'light' } }))) + + const first = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + const stale = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + const initialTerminalFontSize = first.getSettings().terminalFontSize + first.updateSettings({ theme: 'dark' }) + first.flushOrThrow() + + stale.updateSettings({ terminalFontSize: stale.getSettings().terminalFontSize + 1 }) + expect(() => stale.flushOrThrow()).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 1, + actualRevision: 2 + }) + ) + + const verifier = createAuthority(databasePath, 'profile-authority-test') + expect(JSON.parse(verifier.readSerializedState() ?? '{}')).toMatchObject({ + settings: { theme: 'dark', terminalFontSize: initialTerminalFontSize } + }) + }) + + it('writes a local session mutation as dirty domains and preserves unrelated rows', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-domain-write-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seedDataFile = join(directory, 'seed-orca-data.json') + const seedStore = new Store({ dataFile: seedDataFile }) + seedStore.updateSettings({ theme: 'light' }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(readFileSync(seedDataFile)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeDomains = vi.spyOn(authority, 'writeSerializedDomains') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + store.setWorkspaceSession({ + ...store.getWorkspaceSession(), + activeTabId: 'after' + }) + store.flushOrThrow() + + expect(writeDomains).toHaveBeenCalledTimes(1) + expect(writeDomains.mock.calls[0]?.[0].map(({ domain }) => domain)).toEqual([ + 'workspaceSession' + ]) + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect(reloaded.getWorkspaceSession().activeTabId).toBe('after') + expect(reloaded.getSettings().theme).toBe('light') + reloaded.freezeWrites() + }) + + it('writes a PTY rebind through the workspace-session domain', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-pty-domain-write-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const fixtureFile = join(directory, 'fixture-orca-data.json') + writeFileSync(fixtureFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ dataFile: fixtureFile }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(readFileSync(fixtureFile)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeDomains = vi.spyOn(authority, 'writeSerializedDomains') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + const session = store.getWorkspaceSession() + const worktreeId = session.activeWorktreeId + const tabId = session.activeTabId + const layout = tabId ? session.terminalLayoutsByTabId[tabId] : undefined + const leafId = layout ? Object.keys(layout.ptyIdsByLeafId ?? {})[0] : undefined + const previousPtyId = leafId ? layout?.ptyIdsByLeafId?.[leafId] : undefined + if (!worktreeId || !tabId || !layout || !leafId || !previousPtyId) { + throw new Error('fixture did not produce a normalized PTY binding') + } + + expect( + store.persistPtyBinding({ + worktreeId, + tabId, + leafId, + ptyId: 'pty-rebound', + expectedBinding: { ptyId: previousPtyId } + }) + ).toBe(true) + expect(writeDomains).toHaveBeenCalledTimes(1) + expect(writeDomains.mock.calls[0]?.[0].map(({ domain }) => domain)).toEqual([ + 'workspaceSession' + ]) + + const remoteSession = store.getWorkspaceSession('ssh:build-host') + const remoteWorktreeId = remoteSession.activeWorktreeId + const remoteTabId = remoteSession.activeTabId + const remoteLayout = remoteTabId ? remoteSession.terminalLayoutsByTabId[remoteTabId] : undefined + const remoteLeafId = remoteLayout + ? Object.keys(remoteLayout.ptyIdsByLeafId ?? {})[0] + : undefined + const remotePtyId = remoteLeafId ? remoteLayout?.ptyIdsByLeafId?.[remoteLeafId] : undefined + if (!remoteWorktreeId || !remoteTabId || !remoteLayout || !remoteLeafId || !remotePtyId) { + throw new Error('fixture did not produce a normalized remote PTY binding') + } + expect( + store.persistPtyBinding( + { + worktreeId: remoteWorktreeId, + tabId: remoteTabId, + leafId: remoteLeafId, + ptyId: 'pty-remote-rebound', + expectedBinding: { ptyId: remotePtyId } + }, + 'ssh:build-host' + ) + ).toBe(true) + expect(writeDomains).toHaveBeenCalledTimes(2) + expect(writeDomains.mock.calls[1]?.[0].map(({ domain }) => domain)).toEqual([ + 'workspaceSessionsByHostId' + ]) + + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect( + reloaded.getWorkspaceSession().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId + ).toMatchObject({ + [leafId]: 'pty-rebound' + }) + reloaded.freezeWrites() + }) + + it('writes scheduled automation changes as automations and automationRuns domains', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-automation-write-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const seedDataFile = join(directory, 'seed-orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(seedDataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ dataFile: seedDataFile }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(readFileSync(seedDataFile)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeAutomationRuns = vi.spyOn(authority, 'writeSerializedAutomationRuns') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + const automation = store.listAutomations()[0] + if (!automation) { + throw new Error('fixture automation missing') + } + store.createAutomationRun(automation, Date.now(), 'scheduled') + + expect(writeAutomationRuns).toHaveBeenCalledTimes(1) + expect(writeAutomationRuns.mock.calls[0]?.[0].map(({ domain }) => domain)).toEqual([ + 'automations' + ]) + expect(writeAutomationRuns.mock.calls[0]?.[1]).toHaveLength(2) + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect(reloaded.listAutomationRuns()).toHaveLength(2) + reloaded.freezeWrites() + }) + + it('persists an automation run lifecycle through normalized rows', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-automation-lifecycle-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const seedDataFile = join(directory, 'seed-orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(seedDataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ dataFile: seedDataFile }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(readFileSync(seedDataFile)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeAutomationRuns = vi.spyOn(authority, 'writeSerializedAutomationRuns') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + const automation = store.listAutomations()[0] + if (!automation) { + throw new Error('fixture automation missing') + } + + store.updateUI({ browserKagiSessionLink: 'https://kagi.com/session?t=authority' }) + store.flushOrThrow() + const pending = store.createAutomationRun(automation, 30, 'manual') + store.flushOrThrow() + expect(pending.status).toBe('pending') + const completed = store.updateAutomationRun({ + runId: pending.id, + status: 'completed', + outputSnapshot: { + format: 'plain_text', + content: 'completed through sqlite', + capturedAt: 31, + truncated: false + } + }) + store.flushOrThrow() + expect(completed.status).toBe('completed') + expect(writeAutomationRuns).toHaveBeenCalledTimes(2) + expect(writeAutomationRuns.mock.calls[1]?.[0].map(({ domain }) => domain)).toEqual([ + 'automations' + ]) + + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect(reloaded.listAutomationRuns('automation-fixture')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: pending.id, + status: 'completed', + outputSnapshot: expect.objectContaining({ content: 'completed through sqlite' }) + }) + ]) + ) + expect(reloaded.getUI().featureInteractions?.['automation-run']?.interactionCount).toBe(1) + expect(reloaded.getUI().browserKagiSessionLink).toBe('https://kagi.com/session?t=authority') + const stored = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + const uiRow = stored.db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('ui') + expect(uiRow).toEqual( + expect.objectContaining({ payload: expect.not.stringContaining('authority') }) + ) + } finally { + stored.db.close() + } + reloaded.freezeWrites() + }) + + it('prunes normalized automation rows and reloads the retained window', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-automation-retention-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const seedDataFile = join(directory, 'seed-orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(seedDataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ dataFile: seedDataFile }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(readFileSync(seedDataFile)) + seedStore.freezeWrites() + + const store = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + store.flushOrThrow() + const automation = store.listAutomations()[0] + if (!automation) { + throw new Error('fixture automation missing') + } + for (let index = 0; index < MAX_AUTOMATION_RUNS_PER_AUTOMATION + 2; index += 1) { + const run = store.createAutomationRun(automation, 100 + index, 'scheduled') + store.updateAutomationRun({ runId: run.id, status: 'completed' }) + } + + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + const retained = reloaded.listAutomationRuns('automation-fixture') + expect(retained).toHaveLength(MAX_AUTOMATION_RUNS_PER_AUTOMATION) + expect(retained.some((run) => run.id === 'automation-run-fixture')).toBe(false) + reloaded.flushOrThrow() + const stored = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect( + stored.db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get() + ).toEqual({ count: MAX_AUTOMATION_RUNS_PER_AUTOMATION }) + } finally { + stored.db.close() + } + reloaded.freezeWrites() + }) + + it('writes host-qualified worktree metadata as projected domain rows', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-worktree-write-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const seedDataFile = join(directory, 'seed-orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(seedDataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ dataFile: seedDataFile }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(readFileSync(seedDataFile)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeDomains = vi.spyOn(authority, 'writeSerializedDomains') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + store.setWorktreeMetaForHost('repo-local::/fixture/local', 'local', { + displayName: 'Updated fixture local' + }) + store.flushOrThrow() + + expect(writeDomains).toHaveBeenCalledTimes(1) + expect(writeDomains.mock.calls[0]?.[0].map(({ domain }) => domain)).toEqual( + expect.arrayContaining(['worktreeMeta', 'worktreeMetaByIdentity', 'worktreeIdentityAliases']) + ) + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect( + reloaded.getWorktreeMetaForHost('repo-local::/fixture/local', 'local')?.displayName + ).toBe('Updated fixture local') + reloaded.freezeWrites() + }) + + it('publishes a durable JSON rollback export without changing the SQLite authority', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-json-export-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState( + Buffer.from(JSON.stringify({ settings: { theme: 'dark' }, unknownDomain: { keep: true } })) + ) + + const exportPath = join(directory, 'rollback', 'orca-data.json.r3') + const revision = authority.writeJsonExport(exportPath) + + expect(revision).toBe(1) + expect(JSON.parse(readFileSync(exportPath, 'utf8'))).toEqual({ + settings: { theme: 'dark' }, + unknownDomain: { keep: true } + }) + const reopened = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect(exportProfileStateJson(reopened.db)).toBe(readFileSync(exportPath, 'utf8')) + } finally { + reopened.db.close() + } + }) + + it('publishes the Store export after flushing pending SQLite state', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-store-export-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seed = new Store({ dataFile }) + seed.flushOrThrow() + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(seed.prepareProfileStateExport().json, 'utf8')) + seed.freezeWrites() + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark' }) + const exportPath = join(directory, 'rollback', 'orca-data.json.current') + const revision = store.writeProfileStateJsonExport(exportPath) + + expect(revision).toBe(2) + expect(JSON.parse(readFileSync(exportPath, 'utf8')).settings.theme).toBe('dark') + store.freezeWrites() + }) + + it('publishes the latest SQLite revision as an idempotent versioned export', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-latest-export-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seed = new Store({ dataFile }) + seed.updateSettings({ theme: 'light' }) + seed.flushOrThrow() + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(seed.prepareProfileStateExport().json, 'utf8')) + seed.freezeWrites() + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark' }) + + const firstRevision = store.writeLatestProfileStateJsonExport() + expect(firstRevision).toBe(2) + if (firstRevision === undefined) { + throw new Error('Expected the SQLite authority to publish a revisioned export') + } + const firstPath = profileStateJsonExportPath(dataFile, firstRevision) + expect(JSON.parse(readFileSync(firstPath, 'utf8')).settings.theme).toBe('dark') + + expect(store.writeLatestProfileStateJsonExport()).toBe(firstRevision) + expect(readFileSync(profileStateJsonExportPath(dataFile, 2), 'utf8')).toBe( + readFileSync(firstPath, 'utf8') + ) + expect( + readdirSync(directory).some((name) => + name.startsWith('orca-data.json.sqlite-export.pending.') + ) + ).toBe(false) + store.freezeWrites() + }) + + it('publishes canonical JSON for an older build and advances its acceptance marker', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-compat-export-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seed = new Store({ dataFile }) + seed.updateSettings({ theme: 'light' }) + seed.flushOrThrow() + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(seed.prepareProfileStateExport().json, 'utf8')) + seed.freezeWrites() + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark' }) + const revision = store.writeLatestProfileStateJsonCompatibilityExport() + + expect(revision).toBe(2) + const canonical = readFileSync(dataFile, 'utf8') + expect(JSON.parse(canonical).settings.theme).toBe('dark') + const opened = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect(readProfileStateJsonAcceptance(opened.db)).toEqual({ + jsonHash: hashProfileStateJson(canonical), + acceptedRevision: revision + }) + expect(profileStateJsonMatchesAcceptance(opened.db, canonical)).toBe(true) + } finally { + opened.db.close() + } + authority.close() + const reopened = createProfileStateStore({ + dataFile, + databaseFile: databasePath, + profileId: 'profile-authority-test', + authorityMode: 'sqlite-established' + }) + expect(reopened.backend).toBe('sqlite') + expect(reopened.store.getSettings().theme).toBe('dark') + reopened.store.freezeWrites() + store.freezeWrites() + }) + + it('refuses to overwrite a conflicting export for the same SQLite revision', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-export-conflict-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'dark' } }))) + const store = new Store({ dataFile, profileStateAuthority: authority }) + const revision = store.writeLatestProfileStateJsonExport() + if (revision === undefined) { + throw new Error('Expected the SQLite authority to publish a revisioned export') + } + const exportPath = profileStateJsonExportPath(dataFile, revision) + mkdirSync(dirname(exportPath), { recursive: true }) + writeFileSync(exportPath, '{"settings":{"theme":"tampered"}}', 'utf8') + + expect(() => store.writeLatestProfileStateJsonExport()).toThrow( + 'already exists with different content' + ) + expect(readFileSync(exportPath, 'utf8')).toContain('tampered') + expect( + readdirSync(directory).some((name) => + name.startsWith('orca-data.json.sqlite-export.pending.') + ) + ).toBe(false) + store.freezeWrites() + }) + + it('freezes Store writes before quarantining the SQLite database family', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-quarantine-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'light' } }))) + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark' }) + store.flushOrThrow() + const sourceBytes = readFileSync(databasePath) + writeFileSync(`${databasePath}-wal`, 'wal-preservation-sentinel') + + const result = store.quarantineProfileStateDatabase( + join(directory, 'quarantine'), + 'store-recovery-test' + ) + + expect(readFileSync(join(result.directory, 'profile-state.db'))).toEqual(sourceBytes) + expect(readFileSync(join(result.directory, 'profile-state.db-wal'), 'utf8')).toBe( + 'wal-preservation-sentinel' + ) + expect(JSON.parse(readFileSync(result.manifestPath, 'utf8'))).toMatchObject({ + profileId: 'profile-authority-test', + reason: 'store-recovery-test' + }) + expect(readFileSync(databasePath)).toEqual(sourceBytes) + }) + + it('keeps the Store export path JSON-compatible without creating SQLite', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-legacy-export-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const store = new Store({ dataFile }) + store.updateSettings({ theme: 'dark' }) + + const exportPath = join(directory, 'rollback', 'orca-data.json.legacy.json') + expect(store.writeProfileStateJsonExport(exportPath)).toBeUndefined() + expect(JSON.parse(readFileSync(exportPath, 'utf8')).settings.theme).toBe('dark') + expect(existsSync(join(directory, 'profile-state.db'))).toBe(false) + store.freezeWrites() + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-store-backups.test.ts b/src/main/persistence/loading-store/profile-state-store-backups.test.ts new file mode 100644 index 000000000000..c7ed2f7d07fa --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-store-backups.test.ts @@ -0,0 +1,215 @@ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushActiveProfileBeforeFileMutation } from '../../orca-profiles/profile-persistence-deadline' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { readProfileStateSnapshot } from '../profile-state/profile-state-documents' +import { profileStateDatabaseBackups } from '../profile-state/profile-state-backup-path' +import * as snapshots from '../profile-state/profile-state-database-snapshot' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { Store } from './store' +import { scheduleSave } from './write-scheduling' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`), + decryptString: (value: Buffer) => value.toString().slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const PROFILE_ID = 'store-backup-test' +const HOUR = 60 * 60 * 1000 +const fixtures: { directory: string; store: Store; authority: ProfileStateSqliteAuthority }[] = [] +const releases: (() => void)[] = [] + +afterEach(async () => { + for (const release of releases.splice(0)) { + release() + } + for (const fixture of fixtures.splice(0)) { + await fixture.authority.drainBackups() + fixture.store.freezeWrites() + await fixture.store.flushAsync() + rmSync(fixture.directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +async function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-backup-')) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const legacyBytes = '{"settings":{"theme":"system"},"legacy":"retained"}' + writeFileSync(dataFile, legacyBytes) + const beginning = Date.now() + const clock = vi.spyOn(Date, 'now').mockReturnValue(beginning) + const authority = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + const store = new Store({ dataFile, profileStateAuthority: authority }) + fixtures.push({ directory, store, authority }) + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + await authority.drainBackups() + const retained = profileStateDatabaseBackups(databasePath) + expect(retained).toHaveLength(1) + const retainedBytes = readFileSync(retained[0].path) + clock.mockReturnValue(beginning + HOUR + 1) + return { + directory, + databasePath, + dataFile, + legacyBytes, + store, + authority, + retained, + retainedBytes + } +} + +function readSnapshot(path: string) { + const opened = openProfileStateDatabaseReadOnly(path, PROFILE_ID) + try { + const snapshot = readProfileStateSnapshot(opened.db) + return { revision: snapshot.revision, state: JSON.parse(snapshot.json) } + } finally { + opened.db.close() + } +} + +describe('Store automatic SQLite recovery snapshots', () => { + it.each([ + ['selective', 'sync'], + ['selective', 'async'], + ['complete', 'sync'], + ['complete', 'async'] + ] as const)('backs up a %s %s commit without rewriting retained JSON', async (scope, flush) => { + const state = await fixture() + const fullWrite = vi.spyOn(state.authority, 'writeCompleteSerializedDomains') + const selectiveWrite = vi.spyOn(state.authority, 'writeSerializedDomains') + state.store.patchWorkspaceSession({ activeWorktreeId: 'backup-worktree' }) + if (scope === 'complete') { + scheduleSave(state.store) + } + if (flush === 'sync') { + state.store.flushOrThrow() + await state.authority.drainBackups() + } else { + await state.store.flushPendingOrThrowAsync() + } + + expect(scope === 'complete' ? fullWrite : selectiveWrite).toHaveBeenCalledOnce() + expect(scope === 'complete' ? selectiveWrite : fullWrite).not.toHaveBeenCalled() + const backups = profileStateDatabaseBackups(state.databasePath) + expect(backups).toHaveLength(2) + expect(readSnapshot(backups[0].path)).toEqual(readSnapshot(state.databasePath)) + expect(readSnapshot(backups[0].path).state.workspaceSession.activeWorktreeId).toBe( + 'backup-worktree' + ) + expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) + expect( + readdirSync(state.directory) + .filter((name) => name.includes('.backup.')) + .sort() + ).toEqual(backups.map((backup) => basename(backup.path)).sort()) + }) + + it.each(['quit', 'profile mutation'] as const)( + '%s waits for its owned backup across Store close', + async (kind) => { + const state = await fixture() + const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync + let begin: () => void = () => {} + let release: () => void = () => {} + const started = new Promise((resolve) => { + begin = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + releases.push(release) + vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockImplementationOnce( + async (db, target) => { + begin() + await gate + await realSnapshot(db, target) + } + ) + state.store.updateSettings({ theme: 'dark' }) + state.store.flushOrThrow() + await started + const drain = vi.spyOn(state.authority, 'drainBackups') + let settled = false + const barrier = ( + kind === 'quit' + ? state.store.flushAsync() + : flushActiveProfileBeforeFileMutation(state.store) + ).then(() => { + settled = true + }) + await vi.waitFor(() => expect(drain).toHaveBeenCalled()) + expect(settled).toBe(false) + state.store.freezeWrites() + expect(profileStateDatabaseBackups(state.databasePath)).toHaveLength(1) + release() + await barrier + + expect(settled).toBe(true) + const backups = profileStateDatabaseBackups(state.databasePath) + expect(backups).toHaveLength(2) + expect(readSnapshot(backups[0].path).state.settings.theme).toBe('dark') + expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) + } + ) + + it.each(['sync', 'async'] as const)( + 'does not reject a committed %s flush when its backup fails', + async (flush) => { + const state = await fixture() + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('injected backup disk failure') + const snapshot = vi + .spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync') + .mockRejectedValueOnce(failure) + state.store.updateSettings({ theme: 'dark' }) + if (flush === 'sync') { + expect(() => state.store.flushOrThrow()).not.toThrow() + await expect(state.authority.drainBackups()).resolves.toBeUndefined() + } else { + await expect(state.store.flushPendingOrThrowAsync()).resolves.toBeUndefined() + } + + expect(snapshot).toHaveBeenCalledOnce() + expect(log).toHaveBeenCalledWith( + '[persistence] Failed to back up profile state database:', + failure + ) + expect(readSnapshot(state.databasePath).state.settings.theme).toBe('dark') + expect(profileStateDatabaseBackups(state.databasePath)).toEqual(state.retained) + expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readSnapshot(state.retained[0].path).state.settings.theme).toBe('light') + expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) + expect(existsSync(`${state.dataFile}.bak.0`)).toBe(false) + } + ) +}) diff --git a/src/main/persistence/loading-store/profile-state-update-quit.test.ts b/src/main/persistence/loading-store/profile-state-update-quit.test.ts new file mode 100644 index 000000000000..2e7f07138d6f --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-update-quit.test.ts @@ -0,0 +1,235 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as durableFiles from '../../durable-file-write' +import { settleTeardownWithinDeadline } from '../../quit-teardown-deadline' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { + hashProfileStateJson, + readProfileStateJsonAcceptance, + readProfileStateSnapshot +} from '../profile-state/profile-state-documents' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { createProfileStateStore } from '../profile-state/profile-state-store-factory' +import { Store } from './store' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { isEncryptionAvailable: () => false }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const PROFILE_ID = 'update-quit-test' +const TARGET_ID = 'remote-host' +const fixtures: { directory: string; store: Store; authority: ProfileStateSqliteAuthority }[] = [] +const releases: (() => void)[] = [] + +afterEach(async () => { + for (const release of releases.splice(0)) { + release() + } + for (const { store, authority, directory } of fixtures.splice(0)) { + await store.flushAsync() + await authority.drainBackups() + store.freezeWrites() + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +async function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-update-quit-')) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const authority = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + const store = new Store({ dataFile, profileStateAuthority: authority }) + fixtures.push({ directory, store, authority }) + store.upsertSshRemotePtyLease({ targetId: TARGET_ID, ptyId: 'remote-pty', state: 'attached' }) + await store.flushPendingOrThrowAsync() + store.writeLatestProfileStateJsonExport() + store.writeLatestProfileStateJsonCompatibilityExport() + return { store, authority, databasePath, dataFile } +} + +function persistedState(databasePath: string) { + const opened = openProfileStateDatabaseReadOnly(databasePath, PROFILE_ID) + try { + return { + ...readProfileStateSnapshot(opened.db), + acceptance: readProfileStateJsonAcceptance(opened.db) + } + } finally { + opened.db.close() + } +} + +function gate() { + let release: () => void = () => {} + const promise = new Promise((resolve) => { + release = resolve + }) + releases.push(release) + return { promise, release } +} + +describe('SQLite profile state during an update quit', () => { + it('exports final SSH shutdown writes and reloads them in a JSON-only Store', async () => { + const { store, dataFile, databasePath } = await fixture() + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync({ exportJsonCompatibility: true }) + + const json = readFileSync(dataFile, 'utf8') + const snapshot = persistedState(databasePath) + expect(json).toBe(snapshot.json) + expect(snapshot.acceptance).toEqual({ + jsonHash: hashProfileStateJson(json), + acceptedRevision: snapshot.revision + }) + const legacy = new Store({ dataFile }) + try { + expect(legacy.getSshRemotePtyLeases(TARGET_ID)).toEqual([ + expect.objectContaining({ state: 'detached', lastDetachedAt: expect.any(Number) }) + ]) + } finally { + legacy.freezeWrites() + } + }) + + it('keeps ordinary quits free of compatibility JSON writes', async () => { + const { store, dataFile, databasePath } = await fixture() + const retainedJson = readFileSync(dataFile, 'utf8') + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync() + + expect(readFileSync(dataFile, 'utf8')).toBe(retainedJson) + expect(persistedState(databasePath).json).not.toBe(retainedJson) + }) + + it('does not publish or accept a snapshot when final persistence fails', async () => { + const { store, authority, dataFile, databasePath } = await fixture() + const retainedJson = readFileSync(dataFile, 'utf8') + const before = persistedState(databasePath) + const failure = new Error('injected final commit failure') + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementation(() => { + throw failure + }) + const exportJson = vi.spyOn(authority, 'writeJsonCompatibilityExportAsync') + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync({ exportJsonCompatibility: true }) + + expect(exportJson).not.toHaveBeenCalled() + expect(readFileSync(dataFile, 'utf8')).toBe(retainedJson) + expect(persistedState(databasePath)).toEqual(before) + }) + + it('retains the preflight export and acceptance if the final file write fails', async () => { + const { store, dataFile, databasePath } = await fixture() + const retainedJson = readFileSync(dataFile, 'utf8') + const before = persistedState(databasePath) + const failure = new Error('injected final export failure') + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(durableFiles, 'writeFileDurable').mockRejectedValueOnce(failure) + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync({ exportJsonCompatibility: true }) + + expect(readFileSync(dataFile, 'utf8')).toBe(retainedJson) + expect(persistedState(databasePath).acceptance).toMatchObject(before.acceptance ?? {}) + expect(persistedState(databasePath).revision).toBeGreaterThan(before.revision) + expect(log).toHaveBeenCalledWith('[persistence] Failed to flush final state:', failure) + }) + + it('joins the final barrier and permits its deadline while the JSON file write stalls', async () => { + const { store, dataFile, databasePath } = await fixture() + const retainedJson = readFileSync(dataFile, 'utf8') + const writing = gate() + const held = gate() + const writeFileDurable = durableFiles.writeFileDurable + const exportWrite = vi + .spyOn(durableFiles, 'writeFileDurable') + .mockImplementationOnce(async (...args) => { + writing.release() + await held.promise + await writeFileDurable(...args) + }) + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + const pending = store.flushAsync({ exportJsonCompatibility: true }) + expect(store.flushAsync()).toBe(pending) + await writing.promise + + await expect( + settleTeardownWithinDeadline([{ name: 'state', promise: pending }], 25) + ).resolves.toEqual(['state']) + expect(readFileSync(dataFile, 'utf8')).toBe(retainedJson) + held.release() + await pending + + expect(exportWrite).toHaveBeenCalledOnce() + expect(readFileSync(dataFile, 'utf8')).toBe(persistedState(databasePath).json) + }) + + it('reopens the latest SQLite state when a concurrent commit prevents export promotion', async () => { + const { store, dataFile, databasePath } = await fixture() + const before = persistedState(databasePath) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const writeFileDurable = durableFiles.writeFileDurable + vi.spyOn(durableFiles, 'writeFileDurable').mockImplementationOnce(async (...args) => { + await writeFileDurable(...args) + const competitor = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + try { + competitor.readSerializedState() + competitor.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"dark"}' }]) + } finally { + competitor.close() + } + }) + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync({ exportJsonCompatibility: true }) + + const snapshot = persistedState(databasePath) + expect(snapshot.acceptance).toMatchObject(before.acceptance ?? {}) + expect(snapshot.acceptance?.pending?.jsonHash).toBe( + hashProfileStateJson(readFileSync(dataFile, 'utf8')) + ) + expect(log).toHaveBeenCalledWith( + '[persistence] Failed to flush final state:', + expect.objectContaining({ code: 'profile-state-revision-conflict' }) + ) + store.freezeWrites() + const reopened = createProfileStateStore({ + dataFile, + databaseFile: databasePath, + profileId: PROFILE_ID, + authorityMode: 'sqlite-established' + }) + try { + expect(reopened.backend).toBe('sqlite') + expect(reopened.store.getSettings().theme).toBe('dark') + expect(reopened.store.getSshRemotePtyLeases(TARGET_ID)[0]?.state).toBe('detached') + } finally { + reopened.store.freezeWrites() + } + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index c3cbff0796a8..242a94941f00 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -27,6 +27,7 @@ type PtyBindingPersistenceOperationsRuntime = Pick< | 'lastDurableWriteGeneration' | 'pendingWrite' | 'quitFlushStarted' + | 'dirtyProfileStateDomains' | 'state' | 'writeGeneration' | 'writeTimer' @@ -130,6 +131,15 @@ function writePtyBinding( } } applyPtyBinding(args, session, bindingWorktreeId, paneKey) + // The binding path flushes synchronously; mark the domain without scheduling a second timer. + const dirtyDomains = runtime.dirtyProfileStateDomains + if (dirtyDomains !== null) { + dirtyDomains.add( + resolvedHostId === LOCAL_EXECUTION_HOST_ID + ? 'workspaceSession' + : 'workspaceSessionsByHostId' + ) + } runtime.flushOrThrow() } catch (err) { if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts index e58d0280baea..b39d31871478 100644 --- a/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts @@ -1,11 +1,7 @@ -/** - * The bar for this change is "the bytes on disk did not move". Every case below runs the exact - * loop `applySecretSentinelSubstitutions` replaced — reproduced in `previousImplementation` — and - * compares payload bytes and guard hash, because a drifting hash silently disables the no-op write - * guard and a drifting payload is corrupted persisted state. - */ +/** Full serialization retains its bytes/hash; domain serialization preserves bytes and equality. */ import { createHash, randomUUID } from 'node:crypto' import { describe, expect, it } from 'vitest' +import { serializeCompleteProfileStateDomains } from './profile-state-authority-writes' import { applySecretSentinelSubstitutions, type SecretSentinelSubstitution @@ -44,6 +40,65 @@ function sentinel(): string { return `orca-secret-slot-${randomUUID()}` } +describe('complete profile domain serialization', () => { + it('preserves escaped domain names, omission and the keys passed to toJSON', () => { + const domain = '雪"\\\ud800' + const state = { + [domain]: { + toJSON(key: string) { + return { key, nested: { toJSON: (nestedKey: string) => nestedKey } } + } + }, + omitted: undefined, + nullable: null, + history: [{ id: 'z' }, { id: 'a' }] + } + + const serialized = serializeCompleteProfileStateDomains(state, [], '') + + expect(serialized.payload.toString('utf8')).toBe(JSON.stringify(state)) + expect(serialized.domains.map(({ domain }) => domain)).toEqual([domain, 'nullable', 'history']) + expect(JSON.parse(serialized.payload.toString('utf8'))).toMatchObject({ + [domain]: { key: domain, nested: 'nested' }, + nullable: null + }) + }) + + it('keeps the complete hash stable across ciphertext changes and sensitive to plaintext and absence', () => { + const slot = sentinel() + const state = { + settings: { cookie: slot }, + future: { shadow: 'ciphertext-one' }, + nullable: null + } + const firstSub = [{ sentinel: slot, blob: 'ciphertext-one', hashValue: 'secret' }] + const first = serializeCompleteProfileStateDomains(state, firstSub, '') + const second = serializeCompleteProfileStateDomains( + state, + [{ sentinel: slot, blob: 'ciphertext-two', hashValue: 'secret' }], + '' + ) + + expect(first.payload).toEqual( + applySecretSentinelSubstitutions(JSON.stringify(state), firstSub, '').payload + ) + expect(second.payload).not.toEqual(first.payload) + expect(second.stateHash).toBe(first.stateHash) + expect(JSON.parse(second.payload.toString('utf8')).future).toEqual({ shadow: 'ciphertext-one' }) + expect( + serializeCompleteProfileStateDomains( + state, + [{ sentinel: slot, blob: 'ciphertext-one', hashValue: 'changed-secret' }], + '' + ).stateHash + ).not.toBe(first.stateHash) + expect( + serializeCompleteProfileStateDomains({ ...state, nullable: undefined }, firstSub, '') + .stateHash + ).not.toBe(first.stateHash) + }) +}) + describe('applySecretSentinelSubstitutions', () => { it('produces bytes and a hash identical to the previous implementation', () => { const subs: SecretSentinelSubstitution[] = [ diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index d358f4c8f624..12efd45a19ec 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -146,7 +146,10 @@ export function removeWorkspaceSessionOwnerInPartition( [resolved]: session } } - scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) + scheduleSave( + owner[sessionHostPartitionOperationsContext].scheduling, + resolved === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId'] + ) } export function partitionOwnsWorktreeTabs( @@ -206,7 +209,9 @@ export function setHostWorkspaceSession( ...owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId, [hostId]: pruned } - scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) + scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling, [ + 'workspaceSessionsByHostId' + ]) } export function installSessionHostPartitionOperationsContext( diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index 37ffd9d366b2..2389b4471516 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -84,7 +84,10 @@ export class SessionSnapshotOperations { [resolved]: next } } - scheduleSave(this[sessionSnapshotOperationsContext].scheduling) + scheduleSave( + this[sessionSnapshotOperationsContext].scheduling, + resolved === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId'] + ) } } diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts index 7da5144898ea..1c1da638cc96 100644 --- a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -46,7 +46,10 @@ import type { WriteSchedulingOperations } from './write-scheduling' import { flushDurableStateOrThrowAsync } from './write-flush-barriers' import { scheduleSave } from './write-scheduling' -type SshLeaseRecoveryOperationsRuntime = Pick +type SshLeaseRecoveryOperationsRuntime = Pick< + StoreRuntimeState, + 'dirtyProfileStateDomains' | 'protectedSecrets' | 'state' +> const sshLeaseRecoveryOperationsContext = Symbol('SshLeaseRecoveryOperations') type SshLeaseRecoveryOperationsContext = { @@ -127,6 +130,9 @@ export class SshLeaseRecoveryOperations { markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { markSshRemotePtyLeasesForShutdownOperation(getSshPtyLeaseOperations(this), targetId, state) + this[sshLeaseRecoveryOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'sshRemotePtyLeases' + ) } async markSshRemotePtyLeasesAsync( @@ -195,8 +201,12 @@ export function getSshPtyConsumerRecoveryOperations( return { state: owner[sshLeaseRecoveryOperationsContext].runtime.state, protectedSecrets: owner[sshLeaseRecoveryOperationsContext].runtime.protectedSecrets, - flushDurableStateOrThrowAsync: () => - flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + flushDurableStateOrThrowAsync: () => { + owner[sshLeaseRecoveryOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'sshPtyConsumerRecoveries' + ) + return flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + } } } @@ -238,9 +248,18 @@ export function getSshPtyLeaseOperations(owner: SshLeaseRecoveryOperations): Ssh targetId, leases ), - flush: () => owner[sshLeaseRecoveryOperationsContext].flushBarriers.flush(), - flushDurableStateOrThrowAsync: () => - flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + flush: () => { + owner[sshLeaseRecoveryOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'sshRemotePtyLeases' + ) + owner[sshLeaseRecoveryOperationsContext].flushBarriers.flush() + }, + flushDurableStateOrThrowAsync: () => { + owner[sshLeaseRecoveryOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'sshRemotePtyLeases' + ) + return flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + } } } diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts index d21ab5f04dae..ce81cea373b8 100644 --- a/src/main/persistence/loading-store/ssh-profile-operations.ts +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -32,7 +32,10 @@ import { syncProjectHostSetupCompatibilityState } from './repo-lifecycle-operati import { scheduleSave } from './write-scheduling' import { forgetSshConnectionGeneration } from '../../ssh/ssh-connection-generation' -type SshProfileOperationsRuntime = Pick +type SshProfileOperationsRuntime = Pick< + StoreRuntimeState, + 'dirtyProfileStateDomains' | 'protectedSecrets' | 'state' +> const sshProfileOperationsContext = Symbol('SshProfileOperations') type SshProfileOperationsContext = { @@ -146,7 +149,12 @@ export function getSshTargetStateOperations(owner: SshProfileOperations): SshTar state: owner[sshProfileOperationsContext].runtime.state, protectedSecrets: owner[sshProfileOperationsContext].runtime.protectedSecrets, scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling), - flush: () => owner[sshProfileOperationsContext].flushBarriers.flush() + flush: () => { + owner[sshProfileOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'claudeLivePtySessionIds' + ) + owner[sshProfileOperationsContext].flushBarriers.flush() + } } } diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index 026afd12c01c..d324c0956cfe 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -1,3 +1,5 @@ +import { serializeCompleteProfileStateDomains } from './profile-state-authority-writes' +import type { ProfileStateDomainReplacement } from './profile-state-authority' import { randomUUID } from 'node:crypto' import type { PersistedState } from '../../../shared/persisted-state-types' import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' @@ -30,7 +32,92 @@ export class StateSerializationSecretHandlingOperations { return durable } - buildStateToSave(): { + /** Serialize domains with complete secret handling; unknown domains fall back to a full write. */ + buildStateDomainsToSave(domains: ReadonlySet): + | { + payload: Buffer + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] + } + | undefined { + // A later save must retry secrets deferred by any domain, until a durable commit succeeds. + if (this.runtime.protectedSecrets.hasPendingEncryption()) { + return undefined + } + const stateToSave: Record = {} + const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] + const encrypt = (slot: string, plaintext: string): string => { + const encrypted = this.runtime.protectedSecrets.encrypt(slot, plaintext) + if (encrypted.retentionUpdate) { + protectedSecretUpdates.push(encrypted.retentionUpdate) + } + return encrypted.blob + } + for (const domain of domains) { + switch (domain) { + case 'settings': + stateToSave[domain] = this.buildSettingsToSave(encrypt) + break + case 'workspaceSession': + stateToSave[domain] = this.runtime.state.workspaceSession + break + case 'automations': + case 'automationRuns': + stateToSave[domain] = this.runtime.state[domain] + break + case 'featureInteractionTelemetryBuckets': + stateToSave[domain] = this.runtime.state.featureInteractionTelemetryBuckets + break + case 'ui': + stateToSave[domain] = { + ...this.runtime.state.ui, + browserKagiSessionLink: + encrypt( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + this.runtime.state.ui.browserKagiSessionLink ?? '' + ) || null + } + break + case 'worktreeMeta': + stateToSave[domain] = omitDefaultWorktreeMetaFieldsInMap(this.runtime.state.worktreeMeta) + break + case 'worktreeMetaByIdentity': + if (this.runtime.state.worktreeMetaByIdentity !== undefined) { + stateToSave[domain] = omitDefaultWorktreeMetaFieldsInMap( + projectWorktreeMetaByIdentityOntoLocators( + this.runtime.state.worktreeMetaByIdentity, + this.runtime.state + ) + ) + } + break + case 'worktreeIdentityAliases': + if (this.runtime.state.worktreeIdentityAliases !== undefined) { + stateToSave[domain] = this.runtime.state.worktreeIdentityAliases + } + break + case 'workspaceSessionsByHostId': + if (this.runtime.state.workspaceSessionsByHostId !== undefined) { + stateToSave[domain] = withoutRedundantPartitionGlobals( + this.runtime.state.workspaceSessionsByHostId, + this.runtime.state.workspaceSession + ) + } + break + case 'sshRemotePtyLeases': + stateToSave[domain] = this.runtime.state.sshRemotePtyLeases + break + default: + return undefined + } + } + return { + payload: Buffer.from(JSON.stringify(stateToSave), 'utf8'), + protectedSecretUpdates + } + } + + buildStateToSave(serializeDomains = false): { + domains?: readonly ProfileStateDomainReplacement[] payload: Buffer stateHash: string protectedSecretUpdates: ProtectedSecretRetentionUpdate[] @@ -122,21 +209,7 @@ export class StateSerializationSecretHandlingOperations { ) }) ), - settings: { - ...stripRetiredGlobalSettings(this.runtime.state.settings), - opencodeSessionCookie: encryptToSentinel( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - this.runtime.state.settings.opencodeSessionCookie - ), - opencodeGoApiKey: encryptToSentinel( - PROTECTED_SECRET_SLOT.opencodeGoApiKey, - this.runtime.state.settings.opencodeGoApiKey ?? '' - ), - httpProxyUrl: encryptToSentinel( - PROTECTED_SECRET_SLOT.httpProxyUrl, - this.runtime.state.settings.httpProxyUrl ?? '' - ) - }, + settings: this.buildSettingsToSave(encryptToSentinel), ui: { ...this.runtime.state.ui, browserKagiSessionLink: encryptOptionalToSentinel( @@ -145,6 +218,24 @@ export class StateSerializationSecretHandlingOperations { ) } } + if ( + serializeDomains && + !('toJSON' in stateToSave && typeof stateToSave.toJSON === 'function') + ) { + const serialized = serializeCompleteProfileStateDomains( + stateToSave, + secretSubs, + protectedStorageDegraded ? 'safeStorage-degraded\0' : '' + ) + return { + domains: serialized.domains, + stateHash: serialized.stateHash, + get payload() { + return serialized.payload + }, + protectedSecretUpdates + } + } // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. // One full-state stringify; secret slots currently hold sentinels. const serialized = JSON.stringify(stateToSave) @@ -158,4 +249,22 @@ export class StateSerializationSecretHandlingOperations { ) return { payload, stateHash, protectedSecretUpdates } } + + private buildSettingsToSave(encrypt: (slot: string, plaintext: string) => string) { + return { + ...stripRetiredGlobalSettings(this.runtime.state.settings), + opencodeSessionCookie: encrypt( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + this.runtime.state.settings.opencodeSessionCookie + ), + opencodeGoApiKey: encrypt( + PROTECTED_SECRET_SLOT.opencodeGoApiKey, + this.runtime.state.settings.opencodeGoApiKey ?? '' + ), + httpProxyUrl: encrypt( + PROTECTED_SECRET_SLOT.httpProxyUrl, + this.runtime.state.settings.httpProxyUrl ?? '' + ) + } + } } diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index b14ea8ce3a41..881bc2d07240 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -19,10 +19,13 @@ import type { AutomationListProjectionCache, AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' +import type { ProfileStateAuthority } from './profile-state-authority' +import type { AutomationRun } from '../../../shared/automations-types' export type StoreRuntimeOptions = { dataFile?: string storageAuthority?: AutomationStorageAuthority + profileStateAuthority?: ProfileStateAuthority } /** Mutable coordination state shared only with this Store's private collaborators. */ @@ -30,6 +33,7 @@ export class StoreRuntimeState { state!: PersistedState readonly dataFile: string readonly storageAuthority: AutomationStorageAuthority + readonly profileStateAuthority: ProfileStateAuthority | undefined automationListProjectionCache: AutomationListProjectionCache | null = null activeViewPreference!: ActiveViewPreference readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage @@ -46,6 +50,9 @@ export class StoreRuntimeState { lastWrittenStateHash: string | null = null lastDurableWriteGeneration = -1 firstPendingSaveAt: number | null = null + /** Known dirty domains, or null when a caller requires a complete-document fallback. */ + dirtyProfileStateDomains: Set | null = new Set() + pendingAutomationRunsAfter: readonly AutomationRun[] | undefined githubCacheDirty = false githubCacheGeneration = 0 pendingGithubCacheWrite: Promise | null = null @@ -72,6 +79,7 @@ export class StoreRuntimeState { constructor(options: StoreRuntimeOptions = {}) { this.dataFile = options.dataFile ?? getDataFile() this.storageAuthority = options.storageAuthority ?? 'desktop' + this.profileStateAuthority = options.profileStateAuthority this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS }) diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 017583e8f863..f20dade9da37 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -1,3 +1,4 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname } from 'node:path' import { setMigrationUnsupportedPty, @@ -16,6 +17,11 @@ import { } from './store-domain-composition' import type { PersistedState } from '../../../shared/persisted-state-types' import { scheduleSave } from './write-scheduling' +import { + durableWriteTempPath, + renameDurableSync, + writeFileDurableSync +} from '../../durable-file-write' import type { WriteSchedulingOperations } from './write-scheduling' import type { PrimaryStateWriteOperations } from './primary-state-writes' import type { ProjectCollectionOperations } from './project-collection-operations' @@ -32,8 +38,21 @@ import type { SshProfileOperations } from './ssh-profile-operations' import type { RetiredWorktreeNamePersistence } from './retired-worktree-name-persistence' import type { SshLeaseRecoveryOperations } from './ssh-lease-recovery-operations' import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' +import { profileStateJsonExportPath } from '../profile-state/profile-state-export-path' +import type { ProfileStateAuthorityInitialState } from './profile-state-authority' + +export type StoreOptions = StoreRuntimeOptions & { + /** Storage-form JSON supplied by a read-only profile migration/import boundary. */ + serializedState?: string + /** Reuse the authority's validated startup read without retaining a cached copy. */ + initialAuthorityState?: ProfileStateAuthorityInitialState +} -export type StoreOptions = StoreRuntimeOptions +export type PreparedProfileStateExport = { + readonly json: string + commit(): void +} export type PtyBindingSourceExpectation = { worktreeId?: string tabId: string @@ -50,11 +69,39 @@ export class Store { private readonly state: PersistedState constructor(options: StoreOptions = {}) { + if (options.profileStateAuthority !== undefined && options.serializedState !== undefined) { + throw new Error('Store cannot use both a profile-state authority and serialized state') + } + if ( + options.initialAuthorityState !== undefined && + (options.profileStateAuthority === undefined || + options.initialAuthorityState.authority !== options.profileStateAuthority) + ) { + throw new Error('Store initial authority state must belong to its profile-state authority') + } + const initial = options.initialAuthorityState + const parsedState = initial?.takeParsedState?.() this.runtime = new StoreRuntimeState(options) this.domains = createStoreDomains(this.runtime) installStoreDomainContexts(this, this.domains) this.runtime.flushOrThrow = () => this.flushOrThrow() - const loaded = this.domains.loader.load() + let loaded: PersistedState + if (options.profileStateAuthority !== undefined) { + if (initial !== undefined) { + loaded = + initial.takeParsedState !== undefined + ? this.domains.loader.loadParsedFromAuthority(parsedState) + : this.domains.loader.loadFromAuthority(initial.serializedState) + } else { + loaded = this.domains.loader.loadFromAuthority( + options.profileStateAuthority.readSerializedState() + ) + } + } else if (options.serializedState !== undefined) { + loaded = this.domains.loader.loadSerialized(options.serializedState) + } else { + loaded = this.domains.loader.load() + } const normalized = normalizePersistedPaneIdentityState(loaded) this.state = normalized.state this.runtime.state = this.state @@ -89,18 +136,127 @@ export class Store { ) { scheduleSave(this.domains.scheduling) } + // An imported source is not a legacy JSON authority. The caller must + // commit through its database/export boundary instead of writing a file. + if (options.serializedState !== undefined) { + this.freezeWrites() + } } getProfileStorageDirectory(): string { return dirname(this.runtime.dataFile) } + /** + * Prepare a storage-form export for a database importer. + * + * Secret retention is committed only after the caller durably accepts the + * export. This keeps a failed migration from discarding the prior sealed + * value from the in-memory fallback store. + */ + prepareProfileStateExport(): PreparedProfileStateExport { + const built = this.domains.serialization.buildStateToSave() + let committed = false + return { + json: built.payload.toString('utf8'), + commit: () => { + if (committed) { + return + } + this.runtime.protectedSecrets.commitRetentionUpdates(built.protectedSecretUpdates) + committed = true + } + } + } + + /** Publish an explicit rollback/compatibility export after flushing current state. */ + writeProfileStateJsonExport(targetPath: string): number | undefined { + this.runtime.dirtyProfileStateDomains = null + this.flushOrThrow() + const authority = this.runtime.profileStateAuthority + if (authority?.writeJsonExport) { + return authority.writeJsonExport(targetPath) + } + + const prepared = this.prepareProfileStateExport() + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, prepared.json) + prepared.commit() + return undefined + } + + /** Publish the latest SQLite revision as a durable, versioned rollback export. */ + writeLatestProfileStateJsonExport(): number | undefined { + const authority = this.runtime.profileStateAuthority + if (!authority?.writeJsonExport) { + return undefined + } + this.runtime.dirtyProfileStateDomains = null + this.flushOrThrow() + + const stagingPath = `${this.runtime.dataFile}.sqlite-export.pending.${process.pid}.${Date.now()}.tmp` + let published = false + try { + const revision = authority.writeJsonExport(stagingPath) + if (revision === 0) { + rmSync(stagingPath, { force: true }) + published = true + return undefined + } + const targetPath = profileStateJsonExportPath(this.runtime.dataFile, revision) + mkdirSync(dirname(targetPath), { recursive: true }) + if (existsSync(targetPath)) { + const staged = readFileSync(stagingPath) + const existing = readFileSync(targetPath) + if (!staged.equals(existing)) { + throw new Error( + `Profile state export revision ${revision} already exists with different content` + ) + } + rmSync(stagingPath, { force: true }) + } else { + renameDurableSync(stagingPath, targetPath) + } + published = true + return revision + } finally { + if (!published) { + rmSync(stagingPath, { force: true }) + } + } + } + + /** Publish canonical JSON for a pre-update older-build compatibility window. */ + writeLatestProfileStateJsonCompatibilityExport(): number | undefined { + const authority = this.runtime.profileStateAuthority + if (!authority?.writeJsonCompatibilityExport) { + return undefined + } + this.runtime.dirtyProfileStateDomains = null + this.flushOrThrow() + return authority.writeJsonCompatibilityExport(this.runtime.dataFile) + } + + /** Freeze writes, then preserve the SQLite family for an explicit recovery decision. */ + quarantineProfileStateDatabase( + quarantineRoot?: string, + reason?: string + ): ProfileStateDatabaseQuarantine { + this.freezeWrites() + const authority = this.runtime.profileStateAuthority + if (!authority?.quarantineDatabase) { + throw new Error('SQLite profile-state quarantine is unavailable') + } + return authority.quarantineDatabase(quarantineRoot, reason) + } + freezeWrites(): void { this.runtime.writesFrozen = true if (this.runtime.writeTimer) { clearTimeout(this.runtime.writeTimer) this.runtime.writeTimer = null } + this.runtime.profileStateAuthority?.close?.() } } diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index 5b7f90cbb8fa..96834ffbcf92 100644 --- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -111,7 +111,19 @@ export function setLocalWorkspaceSession( if (deferSnapshotFiles) { enqueueTerminalScrollbackSnapshotWork(owner, prior, session) } - scheduleSave(context.scheduling) + if ( + remappedAcknowledgements.changed || + remappedActivityCutoffs.changed || + remappedManualUnread.changed + ) { + // UI remaps include protected fields, so keep the complete serializer boundary. + scheduleSave(context.scheduling) + } else { + scheduleSave( + context.scheduling, + remappedLeases.changed ? ['workspaceSession', 'sshRemotePtyLeases'] : ['workspaceSession'] + ) + } } export function enqueueTerminalScrollbackSnapshotWork( diff --git a/src/main/persistence/loading-store/worktree-identity-metadata.ts b/src/main/persistence/loading-store/worktree-identity-metadata.ts index 21fea4ac15c1..9515986f86a9 100644 --- a/src/main/persistence/loading-store/worktree-identity-metadata.ts +++ b/src/main/persistence/loading-store/worktree-identity-metadata.ts @@ -15,6 +15,13 @@ import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization' type MetadataRuntime = Pick +/** Storage rows changed together by host-qualified metadata writes. */ +export const WORKTREE_METADATA_DOMAINS = [ + 'worktreeMeta', + 'worktreeMetaByIdentity', + 'worktreeIdentityAliases' +] as const + /** Select one readable row without discarding competing alias candidates. */ function resolveAliasIdentityKey(state: PersistedState, alias: string): string | undefined { const identityKeys = state.worktreeIdentityAliases?.[alias] ?? [] @@ -161,7 +168,7 @@ export function getWorktreeMetaForHost( const alias = composeWorktreeHostIdentity(executionHostId, worktreeId) const identityKey = resolveAliasIdentityKey(state, alias) if (changed) { - scheduleSave(scheduling) + scheduleSave(scheduling, WORKTREE_METADATA_DOMAINS) } if (identityKey) { return state.worktreeMetaByIdentity?.[identityKey] @@ -238,6 +245,6 @@ export function setWorktreeMetaForHost( if (!legacy || legacy.hostId === executionHostId) { state.worktreeMeta[worktreeId] = updated } - scheduleSave(scheduling) + scheduleSave(scheduling, WORKTREE_METADATA_DOMAINS) return updated } diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts index c08d4367989d..de4bb8dd5537 100644 --- a/src/main/persistence/loading-store/write-flush-barriers.ts +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -17,6 +17,7 @@ type WriteFlushBarrierOperationsRuntime = Pick< | 'githubCacheGeneration' | 'lastDurableWriteGeneration' | 'pendingGithubCacheWrite' + | 'profileStateAuthority' | 'quitFlushPromise' | 'quitFlushStarted' | 'staleGithubCacheTempCleanup' @@ -57,16 +58,20 @@ export class WriteFlushBarrierOperations { writeGithubCacheSnapshotSync(this) } - flushAsync(): Promise { - if (this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise) { - return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + flushAsync(options: { exportJsonCompatibility?: boolean } = {}): Promise { + const { runtime } = this[writeFlushBarrierOperationsContext] + if (runtime.quitFlushPromise) { + return runtime.quitFlushPromise } - this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted = true - this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise = flushCurrentStateAsync( - this, - true - ).catch(() => {}) - return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + runtime.quitFlushStarted = true + runtime.quitFlushPromise = flushCurrentStateAsync(this, true) + .then(async () => { + if (options.exportJsonCompatibility) { + await runtime.profileStateAuthority?.writeJsonCompatibilityExportAsync?.(runtime.dataFile) + } + }) + .catch((error) => console.error('[persistence] Failed to flush final state:', error)) + return runtime.quitFlushPromise } flushPendingAsync(): Promise { @@ -137,22 +142,22 @@ export async function flushCurrentStateAsync( owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration try { - await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) - } catch (error) { + await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes, { + fullCheckpoint: final + }) + } finally { await (final ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( signal )) await writeGithubCacheSnapshotAsync(owner, final, signal) - throw error + if (final || drainToStableGeneration) { + await owner[ + writeFlushBarrierOperationsContext + ].runtime.profileStateAuthority?.drainBackups?.() + } } - await (final - ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() - : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( - signal - )) - await writeGithubCacheSnapshotAsync(owner, final, signal) if (signal?.aborted) { throw new Error('Persistence flush aborted') } diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts index 0301da34a7e9..987edfb97f99 100644 --- a/src/main/persistence/loading-store/write-scheduling.ts +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -9,6 +9,7 @@ type WriteSchedulingOperationsRuntime = Pick< StoreRuntimeState, | 'activeViewPreference' | 'automationListProjectionCache' + | 'dirtyProfileStateDomains' | 'firstPendingSaveAt' | 'pendingWrite' | 'quitFlushStarted' @@ -37,8 +38,19 @@ export class WriteSchedulingOperations { } } -export function scheduleSave(owner: WriteSchedulingOperations): void { +export function scheduleSave( + owner: WriteSchedulingOperations, + dirtyDomains?: readonly string[] +): void { owner[writeSchedulingOperationsContext].runtime.automationListProjectionCache = null + const trackedDomains = owner[writeSchedulingOperationsContext].runtime.dirtyProfileStateDomains + if (dirtyDomains === undefined) { + owner[writeSchedulingOperationsContext].runtime.dirtyProfileStateDomains = null + } else if (trackedDomains !== null) { + for (const domain of dirtyDomains) { + trackedDomains.add(domain) + } + } // Why: once the quit flush has snapshotted, a newly debounced write would fire during // teardown with nothing awaiting it, and the process can exit mid-rename. The quit // flush is the last write by construction. diff --git a/src/main/persistence/profile-state-cutover-fixture.test.ts b/src/main/persistence/profile-state-cutover-fixture.test.ts new file mode 100644 index 000000000000..1d1c937e8c69 --- /dev/null +++ b/src/main/persistence/profile-state-cutover-fixture.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from './profile-state-cutover-fixture' +import { + exportProfileStateJson, + importProfileStateJson +} from './profile-state/profile-state-documents' +import { + openProfileStateDatabase, + profileStateDatabaseFile +} from './profile-state/profile-state-database' +import { Store } from './loading-store/store' +import { createStore, dataFile, testState, writeDataFile } from '../persistence-test-harness' + +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +describe('profile-state cutover fixture', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-cutover-fixture-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('keeps object insertion order out of semantic comparisons while preserving array order', () => { + expect(canonicalProfileStateJson({ b: 2, a: { d: 4, c: 3 }, rows: ['first', 'second'] })).toBe( + canonicalProfileStateJson({ rows: ['first', 'second'], a: { c: 3, d: 4 }, b: 2 }) + ) + expect(canonicalProfileStateJson({ rows: ['first', 'second'] })).not.toBe( + canonicalProfileStateJson({ rows: ['second', 'first'] }) + ) + expect(canonicalProfileStateJson({ missing: undefined, nullable: null })).toBe( + canonicalProfileStateJson({ nullable: null }) + ) + }) + + it('loads the cross-domain fixture through the legacy Store contract', () => { + const fixture = buildProfileStateCutoverFixture(testState.dir) + writeDataFile(fixture) + + const store = createStore() + store.flushOrThrow() + store.freezeWrites() + + expect(store.getRepos().map((repo) => repo.id)).toEqual(['repo-local', 'repo-remote']) + expect(store.getProjects().map((project) => project.id)).toEqual([ + 'repo:repo-local', + 'repo:repo-remote' + ]) + expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual([ + 'repo-local', + 'repo-remote' + ]) + expect(store.getWorktreeMeta('repo-local::/fixture/local')).toMatchObject({ + instanceId: 'instance-local', + linkedPR: 42, + comment: 'Preserve this comment' + }) + expect(store.getWorkspaceSession().activeTabId).toBe('tab-local') + expect(store.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(store.listAutomations().map((automation) => automation.id)).toEqual([ + 'automation-fixture' + ]) + expect(store.listAutomationRuns('automation-fixture').map((run) => run.id)).toEqual([ + 'automation-run-fixture' + ]) + expect(store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + + const persisted: unknown = JSON.parse(readFileSync(dataFile(), 'utf-8')) + expect(persisted).toHaveProperty('futureTopLevelExtension', { + keep: 'forward-compatible', + nullable: null + }) + expect(persisted).toHaveProperty( + 'settings.opencodeSessionCookie', + fixture.settings.opencodeSessionCookie + ) + + const reloaded = createStore() + reloaded.freezeWrites() + expect(reloaded.getWorkspaceSession().activeTabId).toBe('tab-local') + expect(reloaded.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(reloaded.listAutomationRuns('automation-fixture')[0]?.outputSnapshot?.content).toBe( + 'fixture output' + ) + expect(reloaded.getSettings().opencodeSessionCookie).toBe('fixture-secret') + }) + + it('imports and exports through the real Store normalization and secret boundaries', () => { + const fixture = buildProfileStateCutoverFixture(testState.dir) + writeDataFile(fixture) + + const source = createStore() + source.flushOrThrow() + const prepared = source.prepareProfileStateExport() + const databaseDirectory = mkdtempSync(join(testState.dir, 'profile-state-db-')) + const opened = openProfileStateDatabase( + profileStateDatabaseFile(databaseDirectory), + 'profile-cutover' + ) + try { + importProfileStateJson(opened.db, prepared.json, { now: () => 456 }) + const exported = exportProfileStateJson(opened.db) + prepared.commit() + + const candidateDirectory = mkdtempSync(join(testState.dir, 'candidate-')) + const candidate = new Store({ + dataFile: join(candidateDirectory, 'orca-data.json'), + serializedState: exported + }) + + expect(candidate.getSettings().opencodeSessionCookie).toBe('fixture-secret') + expect(candidate.getWorkspaceSession().activeTabId).toBe('tab-local') + expect(candidate.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(candidate.listAutomationRuns('automation-fixture')[0]?.outputSnapshot?.content).toBe( + 'fixture output' + ) + expect(candidate.getWorktreeMeta('repo-local::/fixture/local')).toMatchObject({ + linkedPR: 42, + comment: 'Preserve this comment' + }) + + const persisted: unknown = JSON.parse(candidate.prepareProfileStateExport().json) + expect(persisted).toHaveProperty('futureTopLevelExtension', fixture.futureTopLevelExtension) + expect(persisted).toHaveProperty('settings.opencodeSessionCookie') + expect(existsSync(join(candidateDirectory, 'orca-data.json'))).toBe(false) + } finally { + opened.db.close() + } + }) + + it('fails closed for an invalid serialized import instead of reading a fallback file', () => { + writeDataFile(buildProfileStateCutoverFixture(testState.dir)) + + expect(() => new Store({ dataFile: dataFile(), serializedState: '{not valid json' })).toThrow( + 'Failed to load imported profile state' + ) + }) +}) diff --git a/src/main/persistence/profile-state-cutover-fixture.ts b/src/main/persistence/profile-state-cutover-fixture.ts new file mode 100644 index 000000000000..9d3bdf7ea39d --- /dev/null +++ b/src/main/persistence/profile-state-cutover-fixture.ts @@ -0,0 +1,291 @@ +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import type { PersistedState } from '../../shared/persisted-state-types' +import type { Project, ProjectHostSetup } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' +import type { TerminalTab, TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' + +const LOCAL_WORKTREE_ID = 'repo-local::/fixture/local' +const REMOTE_WORKTREE_ID = 'repo-remote::/fixture/remote' +const LOCAL_TAB_ID = 'tab-local' +const REMOTE_TAB_ID = 'tab-remote' +const LOCAL_LEAF_ID = 'leaf-local' +const REMOTE_LEAF_ID = 'leaf-remote' +const REMOTE_HOST_ID = 'ssh:build-host' + +export type ProfileStateCutoverFixture = PersistedState & { + futureTopLevelExtension: { + keep: string + nullable: null + } +} + +function fixtureRepo(overrides: Partial): Repo { + return { + id: 'repo-local', + path: '/fixture/local', + displayName: 'Fixture local', + badgeColor: '#737373', + addedAt: 1, + ...overrides + } +} + +function fixtureProject(overrides: Partial): Project { + return { + id: 'project-fixture', + displayName: 'Fixture project', + badgeColor: '#737373', + sourceRepoIds: ['repo-local', 'repo-remote'], + createdAt: 1, + updatedAt: 2, + ...overrides + } +} + +function fixtureSetup(overrides: Partial): ProjectHostSetup { + return { + id: 'setup-local', + projectId: 'project-fixture', + hostId: 'local', + repoId: 'repo-local', + path: '/fixture/local', + displayName: 'Fixture local', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 2, + ...overrides + } +} + +function fixtureTab(overrides: Partial): TerminalTab { + return { + id: LOCAL_TAB_ID, + ptyId: 'pty-local', + worktreeId: LOCAL_WORKTREE_ID, + title: 'Fixture terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function fixtureLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId }, + titlesByLeafId: { [leafId]: 'Fixture pane' } + } +} + +function fixtureSession(args: { + repoId: string + worktreeId: string + tab: TerminalTab + layout: TerminalLayoutSnapshot +}): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeRepoId: args.repoId, + activeWorktreeId: args.worktreeId, + activeTabId: args.tab.id, + tabsByWorktree: { [args.worktreeId]: [args.tab] }, + terminalLayoutsByTabId: { [args.tab.id]: args.layout }, + activeTabIdByWorktree: { [args.worktreeId]: args.tab.id }, + activeWorktreeIdsOnShutdown: [args.worktreeId], + browserUrlHistory: [ + { + url: 'https://fixture.test/é😀', + normalizedUrl: 'https://fixture.test/é😀', + title: 'Fixture', + lastVisitedAt: 3, + visitCount: 2 + } + ] + } +} + +function fixtureAutomation(): Automation { + return { + id: 'automation-fixture', + name: 'Fixture automation', + prompt: 'Keep the fixture valid', + precheck: null, + agentId: 'claude', + projectId: 'project-fixture', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: LOCAL_WORKTREE_ID, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 10, + enabled: true, + nextRunAt: 20, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 5, + createdAt: 1, + updatedAt: 2 + } +} + +function fixtureAutomationRun(): AutomationRun { + return { + id: 'automation-run-fixture', + automationId: 'automation-fixture', + title: 'Fixture run', + scheduledFor: 20, + status: 'completed', + trigger: 'manual', + workspaceId: LOCAL_WORKTREE_ID, + workspaceDisplayName: 'Fixture local', + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: 'terminal-fixture', + terminalPaneKey: 'pane-fixture', + terminalPtyId: 'pty-local', + outputSnapshot: { + format: 'plain_text', + content: 'fixture output', + capturedAt: 21, + truncated: false + }, + precheckResult: null, + usage: null, + error: null, + startedAt: 20, + dispatchedAt: 20, + createdAt: 20, + runNumber: 1 + } +} + +function fixtureWorktreeMeta(): WorktreeMeta { + const now = Date.now() + return { + instanceId: 'instance-local', + projectId: 'project-fixture', + hostId: 'local', + projectHostSetupId: 'setup-local', + displayName: 'Fixture local', + comment: 'Preserve this comment', + linkedIssue: null, + linkedPR: 42, + linkedLinearIssue: null, + isArchived: false, + isUnread: true, + isPinned: true, + sortOrder: 1, + lastActivityAt: now, + createdAt: now + } +} + +export function buildProfileStateCutoverFixture( + homedir = '/fixture/home' +): ProfileStateCutoverFixture { + const localTab = fixtureTab({}) + const remoteTab = fixtureTab({ + id: REMOTE_TAB_ID, + ptyId: 'pty-remote', + worktreeId: REMOTE_WORKTREE_ID + }) + const localSession = fixtureSession({ + repoId: 'repo-local', + worktreeId: LOCAL_WORKTREE_ID, + tab: localTab, + layout: fixtureLayout(LOCAL_LEAF_ID, 'pty-local') + }) + const remoteSession = fixtureSession({ + repoId: 'repo-remote', + worktreeId: REMOTE_WORKTREE_ID, + tab: remoteTab, + layout: fixtureLayout(REMOTE_LEAF_ID, 'pty-remote') + }) + const state = getDefaultPersistedState(homedir) + state.repos = [ + fixtureRepo({}), + fixtureRepo({ + id: 'repo-remote', + path: '/fixture/remote', + displayName: 'Fixture remote', + connectionId: 'build-host', + executionHostId: REMOTE_HOST_ID + }) + ] + state.projects = [fixtureProject({})] + state.projectHostSetups = [ + fixtureSetup({}), + fixtureSetup({ + id: 'setup-remote', + hostId: REMOTE_HOST_ID, + repoId: 'repo-remote', + path: '/fixture/remote', + displayName: 'Fixture remote', + connectionId: 'build-host', + executionHostId: REMOTE_HOST_ID + }) + ] + state.worktreeMeta = { + [LOCAL_WORKTREE_ID]: fixtureWorktreeMeta(), + [REMOTE_WORKTREE_ID]: { + ...fixtureWorktreeMeta(), + instanceId: 'instance-remote', + hostId: REMOTE_HOST_ID, + projectHostSetupId: 'setup-remote', + displayName: 'Fixture remote' + } + } + state.workspaceSession = localSession + state.workspaceSessionsByHostId = { [REMOTE_HOST_ID]: remoteSession } + state.sshTargets = [ + { + id: 'build-host', + label: 'Build host', + host: 'build.example.test', + port: 22, + username: 'builder', + source: 'manual', + generation: 3 + } + ] + state.automations = [fixtureAutomation()] + state.automationRuns = [fixtureAutomationRun()] + state.settings = { + ...state.settings, + opencodeSessionCookie: Buffer.from('vitest-sealed:fixture-secret', 'utf-8').toString('base64') + } + state.ui = { ...state.ui, activeView: 'tasks', browserKagiSessionLink: null } + return Object.assign(state, { + futureTopLevelExtension: { keep: 'forward-compatible', nullable: null } + }) +} + +function sortForStableJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortForStableJson) + } + if (!value || typeof value !== 'object') { + return value + } + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, sortForStableJson(child)]) + ) +} + +/** Compares state semantics while ignoring object insertion order and preserving array order. */ +export function canonicalProfileStateJson(state: unknown): string { + return JSON.stringify(sortForStableJson(state)) +} diff --git a/src/main/persistence/profile-state-cutover-soak.test.ts b/src/main/persistence/profile-state-cutover-soak.test.ts new file mode 100644 index 000000000000..d31db671d286 --- /dev/null +++ b/src/main/persistence/profile-state-cutover-soak.test.ts @@ -0,0 +1,350 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from './profile-state-cutover-fixture' +import { + readAgentHookSettingsFromProfileState, + updateAgentHookSettingsInProfileState, + type ProfileStateOfflineLocation +} from './profile-state/profile-state-offline-settings' +import { + createProfileStateStore, + type ProfileStateStoreFactoryOptions, + type ProfileStateStoreFactoryResult +} from './profile-state/profile-state-store-factory' +import { profileStateDatabaseFile } from './profile-state/profile-state-database' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const { Store } = await import('./loading-store/store') + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +type MigratedProfile = { + options: ProfileStateStoreFactoryOptions + location: ProfileStateOfflineLocation + first: ProfileStateStoreFactoryResult +} + +function createProfile(profileId: string, theme: string): MigratedProfile { + const directory = mkdtempSync(join(tmpdir(), `orca-profile-state-cutover-${profileId}-`)) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(directory) + const fixture = buildProfileStateCutoverFixture(directory) + writeFileSync( + dataFile, + JSON.stringify({ + ...fixture, + settings: { ...fixture.settings, theme }, + // Keep enough unrelated data to make repeated complete-document commits meaningful. + soakExtension: { bytes: 'x'.repeat(96 * 1024), nullable: null } + }) + ) + const options: ProfileStateStoreFactoryOptions = { + dataFile, + databaseFile, + profileId, + authorityMode: 'sqlite-candidate' + } + const first = createProfileStateStore(options) + expect(first.backend).toBe('sqlite') + expect(first.migrated).toBe(true) + return { + options, + location: { dataFile, databaseFile, profileId }, + first + } +} + +function removeLegacyJson(profile: MigratedProfile): void { + profile.first.store.freezeWrites() + rmSync(profile.options.dataFile, { force: true }) + expect(existsSync(profile.options.dataFile)).toBe(false) + expect(existsSync(profile.options.databaseFile)).toBe(true) +} + +function reopen(profile: MigratedProfile): ProfileStateStoreFactoryResult { + return createProfileStateStore(profile.options) +} + +function exportJson(store: ProfileStateStoreFactoryResult['store']): unknown { + return JSON.parse(store.prepareProfileStateExport().json) +} + +describe('profile-state candidate cutover soak', () => { + it('keeps the retained JSON export usable for legacy rollback', () => { + const profile = createProfile('rollback-window', 'light') + const retainedJson = readFileSync(profile.options.dataFile) + + profile.first.store.updateSettings({ theme: 'dark', terminalFontSize: 123 }) + profile.first.store.flushOrThrow() + expect(readFileSync(profile.options.dataFile)).toEqual(retainedJson) + + const legacyStore = new Store({ dataFile: profile.options.dataFile }) + expect(legacyStore.getSettings().theme).toBe('light') + expect(legacyStore.getSettings().terminalFontSize).not.toBe(123) + legacyStore.freezeWrites() + profile.first.store.freezeWrites() + }) + + it('migrates a complete profile, removes JSON, and survives restart/domain/offline churn', () => { + const profile = createProfile('soak-primary', 'light') + const initial = exportJson(profile.first.store) + const folderGroup = profile.first.store.createProjectGroup({ + name: 'Fixture folders', + createdFrom: 'manual', + parentPath: '/fixture/folder', + connectionId: 'build-host' + }) + const folderWorkspace = profile.first.store.createFolderWorkspace({ + projectGroupId: folderGroup.id, + name: 'Remote folder fixture', + folderPath: '/fixture/folder', + connectionId: 'build-host' + }) + const remoteAutomation = profile.first.store.createAutomation({ + name: 'Remote fixture automation', + prompt: 'Keep the remote fixture valid', + agentId: 'claude', + projectId: 'repo-remote', + workspaceMode: 'existing', + workspaceId: 'repo-remote::/fixture/remote', + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 10, + enabled: true, + missedRunGraceMinutes: 5 + }) + profile.first.store.createAutomationRun(remoteAutomation, 30, 'manual') + profile.first.store.flushOrThrow() + removeLegacyJson(profile) + + for (let round = 0; round < 8; round += 1) { + const reopened = reopen(profile) + const currentSession = reopened.store.getWorkspaceSession() + const currentAutomation = reopened.store.listAutomations()[0] + if (!currentAutomation) { + throw new Error('cutover fixture lost its automation') + } + + reopened.store.updateSettings({ + theme: round % 2 === 0 ? 'dark' : 'light', + terminalFontSize: reopened.store.getSettings().terminalFontSize + 1 + }) + reopened.store.updateUI({ activeView: round % 2 === 0 ? 'tasks' : 'terminal' }) + reopened.store.patchWorkspaceSession({ + browserUrlHistory: [ + ...(currentSession.browserUrlHistory ?? []), + { + url: `https://fixture.test/soak/${round}`, + normalizedUrl: `https://fixture.test/soak/${round}`, + title: `Soak ${round}`, + lastVisitedAt: round + 10, + visitCount: 1 + } + ] + }) + reopened.store.setWorktreeMeta('repo-local::/fixture/local', { + comment: `soak-${round}`, + linkedPR: 100 + round + }) + reopened.store.createAutomationRun(currentAutomation, 100 + round, 'manual') + reopened.store.flushOrThrow() + reopened.store.freezeWrites() + + const restarted = reopen(profile) + expect(restarted.store.getWorkspaceSession().activeTabId).toBe('tab-local') + expect(restarted.store.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(restarted.store.getFolderWorkspace(folderWorkspace.id)).toMatchObject({ + name: 'Remote folder fixture', + folderPath: '/fixture/folder', + connectionId: 'build-host' + }) + expect(restarted.store.getWorktreeMeta('repo-local::/fixture/local')).toMatchObject({ + comment: `soak-${round}`, + linkedPR: 100 + round + }) + expect(restarted.store.listAutomationRuns('automation-fixture').length).toBeGreaterThan( + round + 1 + ) + const persistedRemoteAutomation = restarted.store + .listAutomations() + .find((automation) => automation.id === remoteAutomation.id) + expect(persistedRemoteAutomation).toMatchObject({ + executionTargetType: 'ssh', + executionTargetId: 'build-host', + schedulerOwner: 'ssh_bridge', + workspaceId: 'repo-remote::/fixture/remote' + }) + expect( + restarted.store + .listAutomationRuns(remoteAutomation.id) + .some((run) => run.trigger === 'manual') + ).toBe(true) + restarted.store.freezeWrites() + } + + const beforeOffline = readAgentHookSettingsFromProfileState(profile.location) + const offlineUpdate = updateAgentHookSettingsInProfileState(profile.location, false) + expect(offlineUpdate.settingsPath).toBe(profile.options.databaseFile) + expect(readAgentHookSettingsFromProfileState(profile.location).agentStatusHooksEnabled).toBe( + false + ) + + const afterOffline = reopen(profile) + const persisted = exportJson(afterOffline.store) + expect(afterOffline.store.getSettings().agentStatusHooksEnabled).toBe(false) + expect(afterOffline.store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + expect(afterOffline.store.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(afterOffline.store.getFolderWorkspace(folderWorkspace.id)).toMatchObject({ + folderPath: '/fixture/folder', + connectionId: 'build-host' + }) + expect(afterOffline.store.listAutomationRuns('automation-fixture').length).toBeGreaterThan(8) + expect( + afterOffline.store + .listAutomations() + .find((automation) => automation.id === remoteAutomation.id) + ).toMatchObject({ + executionTargetType: 'ssh', + executionTargetId: 'build-host', + schedulerOwner: 'ssh_bridge' + }) + expect(afterOffline.store.listAutomationRuns(remoteAutomation.id)).toHaveLength(1) + expect(persisted).toHaveProperty('soakExtension', { + bytes: 'x'.repeat(96 * 1024), + nullable: null + }) + expect(beforeOffline.agentStatusHooksEnabled).toBe(true) + expect(existsSync(profile.options.dataFile)).toBe(false) + expect(canonicalProfileStateJson(persisted)).not.toBe(canonicalProfileStateJson(initial)) + afterOffline.store.freezeWrites() + }) + + it('switches between independent SQLite profiles without crossing state', () => { + const first = createProfile('switch-first', 'dark') + const second = createProfile('switch-second', 'light') + removeLegacyJson(first) + removeLegacyJson(second) + const firstInitial = reopen(first) + const firstInitialFontSize = firstInitial.store.getSettings().terminalFontSize + firstInitial.store.freezeWrites() + const secondInitial = reopen(second) + const secondInitialFontSize = secondInitial.store.getSettings().terminalFontSize + secondInitial.store.freezeWrites() + + for (let round = 0; round < 6; round += 1) { + const active = round % 2 === 0 ? first : second + const inactive = active === first ? second : first + const activeStore = reopen(active) + activeStore.store.updateSettings({ + theme: active === first ? 'dark' : 'light', + terminalFontSize: (active === first ? 100 : 200) + round + }) + activeStore.store.flushOrThrow() + activeStore.store.freezeWrites() + + const inactiveStore = reopen(inactive) + expect(inactiveStore.store.getSettings().terminalFontSize).toBe( + round === 0 + ? inactive === first + ? firstInitialFontSize + : secondInitialFontSize + : (inactive === first ? 100 : 200) + round - 1 + ) + expect(inactiveStore.store.getWorkspaceSession().activeTabId).toBe('tab-local') + inactiveStore.store.freezeWrites() + } + + const firstFinal = reopen(first) + const secondFinal = reopen(second) + expect(firstFinal.store.getSettings().terminalFontSize).toBe(104) + expect(secondFinal.store.getSettings().terminalFontSize).toBe(205) + expect(firstFinal.store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + expect(secondFinal.store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + firstFinal.store.freezeWrites() + secondFinal.store.freezeWrites() + }) + + it('allows one stale complete-document writer and rejects the rest', () => { + const profile = createProfile('soak-cas', 'light') + removeLegacyJson(profile) + const staleWriters = Array.from({ length: 7 }, () => reopen(profile)) + + for (const [index, writer] of staleWriters.entries()) { + writer.store.updateSettings({ + theme: index % 2 === 0 ? 'dark' : 'light', + terminalFontSize: 100 + index + }) + } + + let commits = 0 + let conflicts = 0 + for (const writer of staleWriters) { + try { + writer.store.flushOrThrow() + commits += 1 + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'profile-state-revision-conflict' + ) { + conflicts += 1 + } else { + throw error + } + } finally { + writer.store.freezeWrites() + } + } + + expect(commits).toBe(1) + expect(conflicts).toBe(staleWriters.length - 1) + const verifier = reopen(profile) + expect(verifier.store.getSettings().terminalFontSize).toBe(100) + expect(verifier.store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + expect(verifier.store.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(readFileSync(profile.options.databaseFile)).toBeTruthy() + verifier.store.freezeWrites() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-access-owner.ts b/src/main/persistence/profile-state/profile-state-access-owner.ts new file mode 100644 index 000000000000..6c9952001a95 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-owner.ts @@ -0,0 +1,239 @@ +import { randomUUID } from 'node:crypto' +import { + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + readdirSync, + realpathSync, + renameSync, + rmdirSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { hostname } from 'node:os' +import { join } from 'node:path' + +export class ProfileStateAccessError extends Error { + readonly code = 'profile-state-access-refused' as const + + constructor(message: string) { + super(message) + this.name = 'ProfileStateAccessError' + } +} + +export type ProfileStateAccessPaths = ReturnType + +export function profileStateAccessPaths(userDataPath: string) { + mkdirSync(userDataPath, { recursive: true, mode: 0o700 }) + const root = join(realpathSync(userDataPath), '.profile-state-access') + const paths = { + root, + participants: join(root, 'participants'), + candidates: join(root, 'candidates'), + maintenance: join(root, 'maintenance') + } + for (const path of [root, paths.participants, paths.candidates]) { + mkdirSync(path, { recursive: true, mode: 0o700 }) + } + return paths +} + +export const PROFILE_STATE_ACCESS_TOKEN = /^[a-f0-9-]{36}$/ + +type AccessOwner = { + token: string + pid: number + host: string + platform: string + pidNamespace: string | null +} + +function currentPidNamespace(): string | null { + if (process.platform !== 'linux') { + return null + } + try { + return readlinkSync('/proc/self/ns/pid') + } catch { + return null + } +} + +function readOwner(path: string): AccessOwner | undefined { + try { + if (!lstatSync(path).isFile()) { + throw new ProfileStateAccessError(`Profile state owner is not a regular file: ${path}`) + } + const owner: unknown = JSON.parse(readFileSync(path, 'utf8')) + if ( + typeof owner === 'object' && + owner !== null && + 'token' in owner && + typeof owner.token === 'string' && + PROFILE_STATE_ACCESS_TOKEN.test(owner.token) && + 'pid' in owner && + typeof owner.pid === 'number' && + Number.isSafeInteger(owner.pid) && + owner.pid > 0 && + 'host' in owner && + typeof owner.host === 'string' && + owner.host.length > 0 && + 'platform' in owner && + typeof owner.platform === 'string' && + 'pidNamespace' in owner && + (owner.pidNamespace === null || typeof owner.pidNamespace === 'string') + ) { + return { + token: owner.token, + pid: owner.pid, + host: owner.host, + platform: owner.platform, + pidNamespace: owner.pidNamespace + } + } + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return undefined + } + throw new ProfileStateAccessError(`Profile state ownership is unverifiable: ${path}`) + } + throw new ProfileStateAccessError(`Profile state ownership is malformed: ${path}`) +} + +function ownerExited(owner: AccessOwner): boolean { + if (owner.host !== hostname() || owner.platform !== process.platform) { + return false + } + // Windows/WSL and Linux PID namespaces cannot establish each other's process absence. + if ( + process.platform === 'linux' && + (owner.pidNamespace === null || owner.pidNamespace !== currentPidNamespace()) + ) { + return false + } + try { + process.kill(owner.pid, 0) + return false + } catch (error) { + return hasCode(error, 'ESRCH') + } +} + +export function hasCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + +export function removeOwnerEntry(path: string): void { + try { + unlinkSync(path) + } catch (error) { + if (!hasCode(error, 'ENOENT')) { + throw error + } + } +} + +function removeEmptyOwnerDirectory(path: string): void { + try { + rmdirSync(path) + } catch (error) { + if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EBUSY'].some((code) => hasCode(error, code))) { + throw error + } + } +} + +/** Only remove immutable entries whose owner is positively known to have exited. */ +export function reclaimExitedOwner(path: string): void { + let entries: string[] + try { + if (!lstatSync(path).isDirectory()) { + throw new ProfileStateAccessError(`Profile state owner is not a directory: ${path}`) + } + entries = readdirSync(path) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return + } + throw error + } + for (const entry of entries) { + const token = entry.endsWith('.owner') ? entry.slice(0, -6) : '' + if (!PROFILE_STATE_ACCESS_TOKEN.test(token)) { + throw new ProfileStateAccessError(`Profile state ownership is unverifiable: ${path}`) + } + const owner = readOwner(join(path, entry)) + if (owner === undefined) { + continue + } + if (owner.token !== token || !ownerExited(owner)) { + throw new ProfileStateAccessError( + `Profile state is in use or its owner is unverifiable: ${path}` + ) + } + removeOwnerEntry(join(path, entry)) + } + // A replacement owner keeps the directory nonempty, even if our observation is stale. + removeEmptyOwnerDirectory(path) +} + +export function publishAccessOwner(paths: ProfileStateAccessPaths, exclusive: boolean) { + const token = randomUUID() + const candidate = join(paths.candidates, token) + const target = exclusive ? paths.maintenance : join(paths.participants, token) + const entry = `${token}.owner` + mkdirSync(candidate, { mode: 0o700 }) + let published = false + try { + writeFileSync( + join(candidate, entry), + JSON.stringify({ + token, + pid: process.pid, + host: hostname(), + platform: process.platform, + pidNamespace: currentPidNamespace() + }), + { + flag: 'wx', + mode: 0o600 + } + ) + for (let attempt = 0; ; attempt += 1) { + try { + renameSync(candidate, target) + published = true + break + } catch (error) { + if (!exclusive || attempt >= 2) { + throw error + } + reclaimExitedOwner(target) + } + } + } finally { + if (!published) { + removeOwnerEntry(join(candidate, entry)) + removeEmptyOwnerDirectory(candidate) + } + } + let released = false + return { + token, + assertActive(): void { + if (released || readOwner(join(target, entry))?.token !== token) { + throw new ProfileStateAccessError('Profile state access has already been released') + } + }, + release(): void { + if (released) { + return + } + removeOwnerEntry(join(target, entry)) + removeEmptyOwnerDirectory(target) + released = true + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-access-process.test.ts b/src/main/persistence/profile-state/profile-state-access-process.test.ts new file mode 100644 index 000000000000..eef48007bf1d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-process.test.ts @@ -0,0 +1,150 @@ +import { once } from 'node:events' +import { mkdtempSync, readdirSync, rmSync, utimesSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { buildSync } from 'esbuild' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { spawnProcess } from '../../../shared/child-process/run-process' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { profileStateAccessPaths } from './profile-state-access-owner' + +const fixture = mkdtempSync(join(tmpdir(), 'orca-state-access-process-')) +const bundle = join(fixture, 'access.cjs') +const children = new Set>() + +beforeAll(() => { + buildSync({ + entryPoints: [resolve(__dirname, 'profile-state-access.ts')], + outfile: bundle, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external' + }) +}) + +afterEach(async () => { + await Promise.all([...children].map(stopChild)) +}) + +afterAll(() => rmSync(fixture, { recursive: true, force: true })) + +async function stopChild(child: ReturnType): Promise { + children.delete(child) + if (child.exitCode !== null || child.signalCode !== null) { + return + } + const closed = once(child, 'close') + child.kill('SIGKILL') + await closed +} + +const CHILD_SOURCE = ` +const fs = require('node:fs') +const [root, bundle, mode] = process.argv.slice(1) +const rename = fs.renameSync +if (mode === 'candidate' || mode === 'published') { + fs.renameSync = (from, to) => { + if (mode === 'candidate' && String(to).endsWith('maintenance')) { + fs.writeSync(1, 'barrier\\n') + fs.readSync(0, Buffer.alloc(1), 0, 1) + } + rename(from, to) + if (mode === 'published' && String(to).includes('participants')) { + fs.writeSync(1, 'barrier\\n') + fs.readSync(0, Buffer.alloc(1), 0, 1) + } + } +} +const access = require(bundle) +const owner = mode === 'runtime' || mode === 'published' + ? access.acquireProfileStateRuntimeAdmission(root) + : access.acquireProfileStateMaintenance(root) +fs.writeSync(1, 'ready\\n') +process.stdin.resume() +` + +async function startChild(root: string, mode: string): Promise> { + const child = spawnProcess({ + program: process.execPath, + args: ['-e', CHILD_SOURCE, root, bundle, mode], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + children.add(child) + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + await new Promise((resolveReady, reject) => { + let stdout = '' + const onData = (chunk: Buffer) => { + stdout += chunk.toString() + if (stdout.includes('ready\n') || stdout.includes('barrier\n')) { + cleanup() + resolveReady() + } + } + const onExit = () => { + cleanup() + reject(new Error(`Owner child exited before its barrier: ${stderr}`)) + } + const onError = (error: Error) => { + cleanup() + reject(error) + } + const cleanup = () => { + child.stdout.off('data', onData) + child.off('exit', onExit) + child.off('error', onError) + } + child.stdout.on('data', onData) + child.once('exit', onExit) + child.once('error', onError) + }) + return child +} + +describe('profile state owners across actual process death', () => { + it('excludes recovery while a runtime lives and reclaims its registration after SIGKILL', async () => { + const root = join(fixture, 'runtime') + const child = await startChild(root, 'runtime') + expect(() => acquireProfileStateMaintenance(root)).toThrow('in use') + await stopChild(child) + const maintenance = acquireProfileStateMaintenance(root) + expect(readdirSync(profileStateAccessPaths(root).participants)).toEqual([]) + maintenance.release() + }) + + it('never steals a live maintenance owner with arbitrarily old timestamps', async () => { + const root = join(fixture, 'maintenance') + const child = await startChild(root, 'maintenance') + const gate = profileStateAccessPaths(root).maintenance + for (const file of readdirSync(gate)) { + utimesSync(join(gate, file), 0, 0) + } + utimesSync(gate, 0, 0) + expect(() => acquireProfileStateRuntimeAdmission(root)).toThrow('in use') + expect(() => acquireProfileStateMaintenance(root)).toThrow('in use') + await stopChild(child) + acquireProfileStateRuntimeAdmission(root).release() + }) + + it('treats a crash before complete owner publication as an inert candidate', async () => { + const root = join(fixture, 'candidate') + const child = await startChild(root, 'candidate') + acquireProfileStateRuntimeAdmission(root).release() + await stopChild(child) + acquireProfileStateMaintenance(root).release() + }) + + it('excludes recovery after participant publication even before runtime admission finishes', async () => { + const root = join(fixture, 'published') + const child = await startChild(root, 'published') + expect(() => acquireProfileStateMaintenance(root)).toThrow('in use') + await stopChild(child) + acquireProfileStateMaintenance(root).release() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-access.test.ts b/src/main/persistence/profile-state/profile-state-access.test.ts new file mode 100644 index 000000000000..1b1fee209f4b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access.test.ts @@ -0,0 +1,256 @@ +import * as fs from 'node:fs' +import { randomUUID } from 'node:crypto' +import { tmpdir, hostname } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission, + assertProfileStateMaintenance, + type ProfileStateMaintenance +} from './profile-state-access' +import { profileStateAccessPaths } from './profile-state-access-owner' + +vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()) })) + +const roots: string[] = [] +function root(): string { + const path = fs.mkdtempSync(join(tmpdir(), 'orca-state-access-')) + roots.push(path) + return path +} + +afterEach(() => { + vi.restoreAllMocks() + for (const path of roots.splice(0)) { + fs.rmSync(path, { recursive: true, force: true }) + } +}) + +describe('profile state admission and maintenance', () => { + it('allows concurrent normal writers and refuses maintenance until every admission releases', () => { + const path = root() + const first = acquireProfileStateRuntimeAdmission(path) + const second = acquireProfileStateRuntimeAdmission(path) + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + first.release() + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + second.release() + const maintenance = acquireProfileStateMaintenance(path) + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('in use') + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + maintenance.release() + acquireProfileStateRuntimeAdmission(path).release() + }) + + it('refuses a runtime publishing after maintenance has acquired and scanned', () => { + const path = root() + const rename = fs.renameSync + let maintenance: ProfileStateMaintenance | undefined + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (String(to).includes('participants')) { + maintenance = acquireProfileStateMaintenance(path) + } + rename(from, to) + }) + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('in use') + expect(maintenance).toBeDefined() + maintenance?.release() + expect(fs.readdirSync(profileStateAccessPaths(path).participants)).toEqual([]) + }) + + it('refuses maintenance when runtime publication precedes its final admission check', () => { + const path = root() + const rename = fs.renameSync + let refused = false + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + rename(from, to) + if (String(to).includes('participants')) { + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + refused = true + } + }) + const admission = acquireProfileStateRuntimeAdmission(path) + expect(refused).toBe(true) + admission.release() + }) + + it('rejects released and fabricated maintenance owners and binds valid owners to exact profile paths', () => { + const path = root() + const other = root() + const profileId = 'profile-test' + const profileDir = join(path, 'profiles', profileId) + fs.mkdirSync(profileDir, { recursive: true }) + const files = { + profileId, + dataFile: join(profileDir, 'orca-data.json'), + databasePath: join(profileDir, 'profile-state.db') + } + const owner = acquireProfileStateMaintenance(path) + assertProfileStateMaintenance(owner, files) + expect(() => assertProfileStateMaintenance({ ...owner }, files)).toThrow('acquired') + const wrong = acquireProfileStateMaintenance(other) + expect(() => assertProfileStateMaintenance(wrong, files)).toThrow('paths') + expect(() => + assertProfileStateMaintenance(owner, { ...files, profileId: '../escape' }) + ).toThrow('acquired') + owner.release() + expect(() => assertProfileStateMaintenance(owner, files)).toThrow('released') + wrong.release() + }) + + it.skipIf(process.platform === 'win32')( + 'refuses symlinked profile directories outside the protected root', + () => { + const path = root() + const outside = root() + fs.mkdirSync(join(path, 'profiles')) + fs.symlinkSync(outside, join(path, 'profiles', 'escaped')) + const owner = acquireProfileStateMaintenance(path) + expect(() => + assertProfileStateMaintenance(owner, { + profileId: 'escaped', + dataFile: join(outside, 'orca-data.json'), + databasePath: join(outside, 'profile-state.db') + }) + ).toThrow('paths') + owner.release() + } + ) + + it.each(['', '../outside', 'x'.repeat(129)])( + 'rejects invalid recovery profile IDs: %s', + (profileId) => { + const path = root() + const owner = acquireProfileStateMaintenance(path) + expect(() => owner.assertProfile(profileId, path, path)).toThrow('acquired') + owner.release() + acquireProfileStateRuntimeAdmission(path).release() + } + ) +}) + +function staleGate(path: string, pid = 12345, host = hostname()): string { + const gate = profileStateAccessPaths(path).maintenance + fs.mkdirSync(gate) + const token = randomUUID() + const record = join(gate, `${token}.owner`) + fs.writeFileSync( + record, + JSON.stringify({ + token, + pid, + host, + platform: process.platform, + pidNamespace: process.platform === 'linux' ? fs.readlinkSync('/proc/self/ns/pid') : null + }) + ) + return record +} + +describe('profile state owner reclamation', () => { + it('does not infer exit from a PID in another platform on a shared root', () => { + const path = root() + const owner = staleGate(path) + const record: unknown = JSON.parse(fs.readFileSync(owner, 'utf8')) + if (typeof record !== 'object' || record === null) { + throw new Error('Owner fixture missing') + } + fs.writeFileSync( + owner, + JSON.stringify({ ...record, platform: process.platform === 'win32' ? 'linux' : 'win32' }) + ) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('absent here'), { code: 'ESRCH' }) + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(true) + }) + + it.skipIf(process.platform !== 'linux')( + 'refuses a different Linux PID namespace even with the same hostname', + () => { + const path = root() + const owner = staleGate(path) + const record: unknown = JSON.parse(fs.readFileSync(owner, 'utf8')) + if (typeof record !== 'object' || record === null) { + throw new Error('Owner fixture missing') + } + fs.writeFileSync(owner, JSON.stringify({ ...record, pidNamespace: 'pid:[foreign]' })) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + } + ) + + it.each(['EPERM', 'EINVAL', 'EACCES'])('refuses unverifiable process query %s', (code) => { + const path = root() + const owner = staleGate(path) + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error(code), { code }) + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('refuses live reused PIDs regardless of old timestamps', () => { + const path = root() + const owner = staleGate(path, process.pid) + fs.utimesSync(owner, 0, 0) + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('refuses foreign host and malformed owners without reclaiming them', () => { + const path = root() + const owner = staleGate(path, process.pid, `${hostname()}-other`) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + fs.writeFileSync(owner, '{}') + expect(() => acquireProfileStateMaintenance(path)).toThrow('malformed') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('cannot remove a replacement published while it releases its own token', () => { + const path = root() + const original = acquireProfileStateMaintenance(path) + const unlink = fs.unlinkSync + let replacement: ProfileStateMaintenance | undefined + let intercepted = false + vi.spyOn(fs, 'unlinkSync').mockImplementation((entry) => { + unlink(entry) + if (!intercepted && String(entry).includes('maintenance')) { + intercepted = true + replacement = acquireProfileStateMaintenance(path) + } + }) + original.release() + expect(replacement).toBeDefined() + replacement?.assertActive() + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('in use') + replacement?.release() + }) + + it('cannot remove a replacement published after stale-owner observation', () => { + const path = root() + const old = staleGate(path) + const kill = process.kill + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === 12345) { + throw Object.assign(new Error('exited'), { code: 'ESRCH' }) + } + return kill(pid, signal) + }) + const unlink = fs.unlinkSync + let replacement: ProfileStateMaintenance | undefined + vi.spyOn(fs, 'unlinkSync').mockImplementation((entry) => { + unlink(entry) + if (entry === old) { + replacement = acquireProfileStateMaintenance(path) + } + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + replacement?.assertActive() + expect(replacement).toBeDefined() + replacement?.release() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-access.ts b/src/main/persistence/profile-state/profile-state-access.ts new file mode 100644 index 000000000000..724549dc7de1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access.ts @@ -0,0 +1,99 @@ +import { lstatSync, readdirSync, realpathSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { + ProfileStateAccessError, + hasCode, + profileStateAccessPaths, + publishAccessOwner, + reclaimExitedOwner +} from './profile-state-access-owner' +export { ProfileStateAccessError } from './profile-state-access-owner' + +export type ProfileStateRuntimeAdmission = { + assertActive(): void + release(): void +} + +export type ProfileStateMaintenance = ProfileStateRuntimeAdmission & { + assertProfile(profileId: string, dataFile: string, databasePath: string): void +} + +const maintenanceRoots = new WeakMap() + +/** Bind destructive operations to a genuine, live maintenance owner for these exact profile paths. */ +export function assertProfileStateMaintenance( + maintenance: ProfileStateMaintenance, + profile: { profileId: string; dataFile: string; databasePath: string } +): void { + const root = maintenanceRoots.get(maintenance) + if (root === undefined || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(profile.profileId)) { + throw new ProfileStateAccessError( + 'Profile state recovery requires an acquired maintenance owner' + ) + } + maintenance.assertActive() + const expectedDirectory = join(root, 'profiles', profile.profileId) + for (const [path, expectedName] of [ + [profile.dataFile, 'orca-data.json'], + [profile.databasePath, 'profile-state.db'] + ] as const) { + if ( + !samePath(realpathSync(dirname(path)), expectedDirectory) || + !samePath(basename(path), expectedName) + ) { + throw new ProfileStateAccessError( + 'Profile state recovery paths do not belong to the maintenance root' + ) + } + try { + if (lstatSync(path).isSymbolicLink()) { + throw new ProfileStateAccessError('Profile state recovery cannot replace a symbolic link') + } + } catch (error) { + if (!hasCode(error, 'ENOENT')) { + throw error + } + } + } +} + +function samePath(left: string, right: string): boolean { + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right +} + +/** Admit before any profile read, and retain until every Store and worker has stopped. */ +export function acquireProfileStateRuntimeAdmission( + userDataPath: string +): ProfileStateRuntimeAdmission { + const paths = profileStateAccessPaths(userDataPath) + const owner = publishAccessOwner(paths, false) + try { + reclaimExitedOwner(paths.maintenance) + return owner + } catch (error) { + owner.release() + throw error + } +} + +/** Exclude startup and all participating readers/writers through durable recovery publication. */ +export function acquireProfileStateMaintenance(userDataPath: string): ProfileStateMaintenance { + const paths = profileStateAccessPaths(userDataPath) + const owner = publishAccessOwner(paths, true) + try { + for (const entry of readdirSync(paths.participants)) { + reclaimExitedOwner(join(paths.participants, entry)) + } + const maintenance: ProfileStateMaintenance = { + ...owner, + assertProfile(profileId, dataFile, databasePath): void { + assertProfileStateMaintenance(maintenance, { profileId, dataFile, databasePath }) + } + } + maintenanceRoots.set(maintenance, dirname(paths.root)) + return maintenance + } catch (error) { + owner.release() + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-active-location.ts b/src/main/persistence/profile-state/profile-state-active-location.ts new file mode 100644 index 000000000000..15c38fe3ac1a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-active-location.ts @@ -0,0 +1,49 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + getOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile +} from '../../../shared/profile-state-storage-paths' +import type { ProfileStateOfflineLocation } from './profile-state-offline-settings' +import { ProfileStateRecoveryCommandError } from '../../../shared/profile-state-recovery-command' + +/** Resolve the active profile files for offline profile-state commands. */ +export function getActiveProfileStateLocation( + userDataPath: string +): ProfileStateOfflineLocation | undefined { + const indexPath = join(userDataPath, 'orca-profile-index.json') + const candidates = [indexPath, `${indexPath}.bak`].filter(existsSync) + if (candidates.length === 0) { + return undefined + } + for (const candidate of candidates) { + try { + const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf-8')) + if (!isRecord(parsed) || !Array.isArray(parsed.profiles)) { + continue + } + const profileId = parsed.activeProfileId + if ( + typeof profileId === 'string' && + /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(profileId) && + parsed.profiles.some((profile) => isRecord(profile) && profile.id === profileId) + ) { + return { + dataFile: getOrcaProfileDataFile(profileId, userDataPath), + databaseFile: getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId + } + } + } catch { + // Try the profile-index backup before failing closed. + } + } + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + `Could not read active profile index ${indexPath}` + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts new file mode 100644 index 000000000000..4f2bfe6140ca --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts @@ -0,0 +1,478 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Database from '../../sqlite/sync-database' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import * as profileStateDocuments from './profile-state-documents' +import { profileStateJsonExportPath } from './profile-state-export-path' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { restoreProfileStateJsonExport } from './profile-state-recovery' +import { + bootstrapProfileStateAuthority as bootstrapProfileStateAuthorityImpl, + classifyProfileStateStorage, + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-authority-bootstrap' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +const { Store } = await import('../loading-store/store') + +const temporaryDirectories: string[] = [] +const authoritiesToClose: NonNullable< + ReturnType['authority'] +>[] = [] + +function bootstrapProfileStateAuthority( + options: Parameters[0] +): ReturnType { + const result = bootstrapProfileStateAuthorityImpl(options) + if (result.authority !== undefined) { + authoritiesToClose.push(result.authority) + } + return result +} + +afterEach(() => { + for (const authority of authoritiesToClose.splice(0)) { + authority.close?.() + } + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function createDirectory(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-profile-state-bootstrap-')) + temporaryDirectories.push(root) + const directory = join(root, 'profiles', 'profile-bootstrap-test') + mkdirSync(directory, { recursive: true }) + return directory +} + +function paths(directory: string): { + dataFile: string + databaseFile: string + profileId: string +} { + return { + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'profile-bootstrap-test' + } +} + +describe('profile state authority bootstrap', () => { + it('classifies all four storage-presence states without opening SQLite', () => { + const directory = createDirectory() + const { dataFile, databaseFile } = paths(directory) + + expect(classifyProfileStateStorage(dataFile, databaseFile)).toBe('neither') + writeFileSync(dataFile, '{}') + expect(classifyProfileStateStorage(dataFile, databaseFile)).toBe('json-only') + + const opened = openProfileStateDatabase(databaseFile, 'profile-bootstrap-test') + opened.db.close() + expect(classifyProfileStateStorage(dataFile, databaseFile)).toBe('both') + + rmSync(dataFile) + expect(classifyProfileStateStorage(dataFile, databaseFile)).toBe('sqlite-only') + }) + + it('imports JSON-only state through Store export and returns the SQLite authority', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync( + options.dataFile, + JSON.stringify({ settings: { theme: 'dark' }, futureExtension: { keep: true } }) + ) + + const result = bootstrapProfileStateAuthority(options) + + expect(result.classification).toBe('json-only') + expect(result.migrated).toBe(true) + expect(result.authority?.readSerializedState()).toContain('futureExtension') + expect(existsSync(options.dataFile)).toBe(true) + expect(existsSync(profileStateJsonExportPath(options.dataFile, 1))).toBe(true) + expect(readFileSync(profileStateJsonExportPath(options.dataFile, 1), 'utf8')).toContain( + 'futureExtension' + ) + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe('both') + + if (result.authority === undefined) { + throw new Error('JSON migration did not return a SQLite authority') + } + const store = new Store({ + dataFile: options.dataFile, + profileStateAuthority: result.authority + }) + expect(store.getSettings().theme).toBe('dark') + + const repeated = bootstrapProfileStateAuthority(options) + expect(repeated.authority?.readSerializedState()).toContain('futureExtension') + }) + + it('fails closed when both files are present without a matching acceptance marker', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + opened.db.close() + + expect(() => bootstrapProfileStateAuthority(options)).toThrowError( + ProfileStateAuthorityBootstrapError + ) + }) + + it('rejects a legacy JSON edit after migration instead of selecting stale SQLite state', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + bootstrapProfileStateAuthority(options) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'light' } })) + + expect(() => bootstrapProfileStateAuthority(options)).toThrowError( + ProfileStateAuthorityBootstrapError + ) + }) + + it.each([1, 2])( + 'requires explicit recovery for unreleased schema %s without changing its bytes', + (version) => { + const options = paths(createDirectory()) + const raw = JSON.stringify({ settings: { theme: 'dark' }, futureDomain: { retained: true } }) + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + profileStateDocuments.importProfileStateJson(opened.db, raw, { + acceptedLegacyJsonHash: profileStateDocuments.hashProfileStateJson(raw) + }) + if (version === 1) { + opened.db.exec( + 'DROP TABLE profile_state_automation_runs; DROP TABLE profile_state_automation_runs_meta' + ) + } else { + opened.db.exec('DELETE FROM profile_state_automation_runs_meta') + } + opened.db.pragma(`user_version = ${version}`) + opened.db.close() + const before = readFileSync(options.databaseFile) + for (const withJson of [false, true]) { + if (withJson) { + writeFileSync(options.dataFile, raw) + } + expect(() => bootstrapProfileStateAuthority(options)).toThrow( + ProfileStateRecoveryRequiredError + ) + expect(readFileSync(options.databaseFile)).toEqual(before) + } + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + writeFileSync(exportPath, raw) + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(dirname(options.dataFile)))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + profileId: options.profileId, + exportPath + }) + const recovered = bootstrapProfileStateAuthority(options) + expect(recovered.migrated).toBe(true) + expect(JSON.parse(recovered.authority?.readSerializedState() ?? '{}')).toMatchObject({ + futureDomain: { retained: true } + }) + } + ) + + it('returns no authority for a brand-new profile', () => { + const directory = createDirectory() + const result = bootstrapProfileStateAuthority(paths(directory)) + + expect(result).toEqual({ classification: 'neither', authority: undefined, migrated: false }) + }) + + it('cleans failed empty-profile initialization before a successful retry', () => { + const directory = createDirectory() + const options = { ...paths(directory), allowEmptyProfileState: true } + const initializationFailure = new Error('injected initial schema failure') + const originalExec = Database.prototype.exec + const execSpy = vi + .spyOn(Database.prototype, 'exec') + .mockImplementation(function (this: Database, sql) { + originalExec.call(this, sql) + if (sql.includes('CREATE TABLE')) { + const row = this.prepare('PRAGMA database_list').get() + if (typeof row?.file !== 'string') { + throw new Error('Expected a file-backed database during initialization') + } + expect(existsSync(`${row.file}-journal`)).toBe(true) + for (const suffix of ['-wal', '-shm']) { + writeFileSync(`${row.file}${suffix}`, 'interrupted schema initialization') + } + throw initializationFailure + } + }) + + expect(() => bootstrapProfileStateAuthority(options)).toThrowError( + expect.objectContaining({ cause: initializationFailure }) + ) + expect(existsSync(options.databaseFile)).toBe(false) + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe('neither') + expect(readdirSync(directory)).toEqual([]) + + execSpy.mockRestore() + const retry = bootstrapProfileStateAuthority(options) + expect(retry.migrated).toBe(false) + expect(retry.authority).toBeDefined() + expect(retry.authority?.readSerializedState()).toBeUndefined() + expect(bootstrapProfileStateAuthority(options).classification).toBe('sqlite-only') + }) + + it('preserves JSON created while an empty database is being initialized', () => { + const options = { ...paths(createDirectory()), allowEmptyProfileState: true } + const source = '{"settings":{"theme":"dark"}}' + const originalClose = Database.prototype.close + vi.spyOn(Database.prototype, 'close').mockImplementationOnce(function (this: Database) { + originalClose.call(this) + writeFileSync(options.dataFile, source) + }) + + expect(() => bootstrapProfileStateAuthority(options)).toThrow( + 'Profile state storage changed while creating an empty database' + ) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + expect(readdirSync(dirname(options.dataFile))).toEqual(['orca-data.json']) + }) + + it.each(['-wal', '-shm', '-journal'])( + 'treats an orphaned SQLite %s sidecar as authority evidence with or without JSON', + (suffix) => { + const options = paths(createDirectory()) + const sidecar = `${options.databaseFile}${suffix}` + writeFileSync(sidecar, 'orphaned recovery evidence') + + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe( + 'sqlite-only' + ) + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + writeFileSync(options.dataFile, '{"settings":{"theme":"dark"}}') + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe('both') + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(sidecar, 'utf8')).toBe('orphaned recovery evidence') + expect(readFileSync(options.dataFile, 'utf8')).toBe('{"settings":{"theme":"dark"}}') + } + ) + + it('validates and returns an existing SQLite-only authority', () => { + const directory = createDirectory() + const options = paths(directory) + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + opened.db.close() + + const result = bootstrapProfileStateAuthority(options) + + expect(result.classification).toBe('sqlite-only') + expect(result.migrated).toBe(false) + expect(result.authority?.readSerializedState()).toBeUndefined() + }) + + it('rejects malformed SQLite-only state before Store can select it', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.databaseFile, 'not sqlite') + + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + }) + + it('reports retained exports when an established SQLite profile needs recovery', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const bootstrap = bootstrapProfileStateAuthority(options) + bootstrap.authority?.close?.() + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + const exportPathRevision2 = profileStateJsonExportPath(options.dataFile, 2) + const exportPathRevision10 = profileStateJsonExportPath(options.dataFile, 10) + writeFileSync(exportPathRevision2, readFileSync(exportPath)) + writeFileSync(exportPathRevision10, readFileSync(exportPath)) + writeFileSync( + `${options.dataFile}.sqlite-export.9007199254740992.json`, + readFileSync(exportPath) + ) + writeFileSync(options.databaseFile, 'not sqlite') + + try { + bootstrapProfileStateAuthority(options) + throw new Error('expected recovery-required startup failure') + } catch (error) { + expect(error).toBeInstanceOf(ProfileStateRecoveryRequiredError) + if (!(error instanceof ProfileStateRecoveryRequiredError)) { + throw error + } + expect(error.dataFile).toBe(options.dataFile) + expect(error.databaseFile).toBe(options.databaseFile) + expect(error.exportPaths).toEqual([exportPathRevision10, exportPathRevision2, exportPath]) + } + }) + + it('does not create a database when the legacy JSON cannot be imported', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, '{ malformed') + + expect(() => bootstrapProfileStateAuthority(options)).toThrow( + 'Failed to load imported profile state' + ) + expect(existsSync(options.databaseFile)).toBe(false) + }) + + it('cleans a temporary database when import fails after opening SQLite', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const importFailure = new Error('injected import failure') + const importSpy = vi + .spyOn(profileStateDocuments, 'importProfileStateJson') + .mockImplementation(() => { + throw importFailure + }) + + expect(() => bootstrapProfileStateAuthority(options)).toThrow(importFailure) + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe('json-only') + expect(existsSync(options.databaseFile)).toBe(false) + expect( + readdirSync(directory).some((name) => name.includes('.migration.') && name.endsWith('.tmp')) + ).toBe(false) + + importSpy.mockRestore() + const retry = bootstrapProfileStateAuthority(options) + expect(retry.migrated).toBe(true) + expect(retry.authority?.readSerializedState()).toContain('"dark"') + }) + + it('refuses a pre-existing retained export before migrating JSON again', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + mkdirSync(exportPath) + + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + expect(existsSync(options.dataFile)).toBe(true) + expect(existsSync(options.databaseFile)).toBe(false) + + rmSync(exportPath, { recursive: true }) + const retry = bootstrapProfileStateAuthority(options) + expect(retry.classification).toBe('json-only') + expect(retry.migrated).toBe(true) + expect(retry.authority?.readSerializedState()).toContain('"dark"') + }) + + it('boots the revisioned export through the legacy Store after SQLite corruption', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + bootstrapProfileStateAuthority(options) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + + writeFileSync(options.databaseFile, 'corrupt sqlite primary') + writeFileSync(options.dataFile, readFileSync(exportPath)) + const rollbackStore = new Store({ dataFile: options.dataFile }) + expect(rollbackStore.getSettings().theme).toBe('dark') + rollbackStore.freezeWrites() + }) + + it('quarantines SQLite and restores a selected export for legacy rollback', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const bootstrap = bootstrapProfileStateAuthority(options) + bootstrap.authority?.close?.() + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + writeFileSync(options.databaseFile, 'corrupt sqlite primary') + writeFileSync(`${options.databaseFile}-wal`, 'corrupt wal sidecar') + const restoredJson = readFileSync(exportPath) + + const result = restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(dirname(options.dataFile)))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + profileId: options.profileId, + exportPath, + quarantineRoot: join(directory, 'quarantine') + }) + + expect(result.removedDatabaseFiles).toEqual( + expect.arrayContaining([options.databaseFile, `${options.databaseFile}-wal`]) + ) + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(options.dataFile)).toEqual(restoredJson) + expect(existsSync(exportPath)).toBe(false) + expect(readFileSync(join(result.quarantine.directory, 'profile-state.db'), 'utf8')).toBe( + 'corrupt sqlite primary' + ) + expect(readFileSync(join(result.quarantine.directory, 'profile-state.db-wal'), 'utf8')).toBe( + 'corrupt wal sidecar' + ) + + const rollbackStore = new Store({ dataFile: options.dataFile }) + expect(rollbackStore.getSettings().theme).toBe('dark') + rollbackStore.freezeWrites() + }) + + it('validates the selected export before quarantining or replacing anything', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + bootstrapProfileStateAuthority(options) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + writeFileSync(exportPath, '{ malformed') + const databaseBytes = readFileSync(options.databaseFile) + const dataBytes = readFileSync(options.dataFile) + + expect(() => + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(dirname(options.dataFile)))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + profileId: options.profileId, + exportPath + }) + ).toThrow('Profile state JSON is invalid') + expect(readFileSync(options.databaseFile)).toEqual(databaseBytes) + expect(readFileSync(options.dataFile)).toEqual(dataBytes) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts new file mode 100644 index 000000000000..453c4c6ac3bc --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts @@ -0,0 +1,143 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { dirname } from 'node:path' +import { publishFileDurableSync } from '../../durable-file-write' +import type { + ProfileStateAuthority, + ProfileStateAuthorityInitialState +} from '../loading-store/profile-state-authority' +import { Store } from '../loading-store/store' +import { isProfileStateSqliteAvailable, openProfileStateDatabase } from './profile-state-database' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { migrateProfileStateToSqlite } from './profile-state-migration' +import { ProfileStateRecoveryRequiredError } from './profile-state-recovery-required' +export { ProfileStateRecoveryRequiredError } from './profile-state-recovery-required' +import { + classifyProfileStateStorage, + profileStateDatabaseFiles, + type ProfileStateStorageClassification +} from './profile-state-storage-classification' + +export { classifyProfileStateStorage } from './profile-state-storage-classification' +export type { ProfileStateStorageClassification } from './profile-state-storage-classification' + +export type ProfileStateAuthorityBootstrapResult = { + classification: ProfileStateStorageClassification + migrated: boolean +} & ( + | { authority: ProfileStateAuthority; initialState: ProfileStateAuthorityInitialState } + | { authority: undefined; initialState?: never } +) + +export type ProfileStateAuthorityBootstrapOptions = { + dataFile: string + databaseFile: string + profileId: string + /** Establish empty profiles before their first Store write. */ + allowEmptyProfileState?: boolean +} + +export class ProfileStateAuthorityBootstrapError extends Error { + readonly code = 'ambiguous-profile-state' as const + + constructor(message: string) { + super(message) + this.name = 'ProfileStateAuthorityBootstrapError' + } +} + +/** Normalize legacy state once, then hand one validated authority to Store. */ +export function bootstrapProfileStateAuthority( + options: ProfileStateAuthorityBootstrapOptions +): ProfileStateAuthorityBootstrapResult { + const classification = classifyProfileStateStorage(options.dataFile, options.databaseFile) + if (classification === 'neither' && options.allowEmptyProfileState !== true) { + return { classification, authority: undefined, migrated: false } + } + if (!isProfileStateSqliteAvailable()) { + if (classification === 'neither' || classification === 'json-only') { + return { classification, authority: undefined, migrated: false } + } + throw new ProfileStateAuthorityBootstrapError( + 'SQLite profile state is present but this runtime cannot validate it' + ) + } + if (classification === 'json-only') { + return migrateJsonOnlyProfile(options) + } + if (classification === 'neither') { + mkdirSync(dirname(options.databaseFile), { recursive: true }) + createEmptyProfileStateDatabase(options) + } else if (classification === 'sqlite-only' && !existsSync(options.databaseFile)) { + throw new ProfileStateAuthorityBootstrapError( + 'SQLite profile state has an orphaned database sidecar' + ) + } + + const authority = new ProfileStateSqliteAuthority(options.databaseFile, options.profileId) + try { + const initialState = + classification === 'both' + ? authority.readAcceptedState(readFileSync(options.dataFile, 'utf8')) + : authority.readInitialState() + if (initialState === undefined) { + throw new ProfileStateAuthorityBootstrapError( + 'Profile state has both JSON and SQLite storage without a matching acceptance marker' + ) + } + return { classification, authority, initialState, migrated: false } + } catch (error) { + authority.close() + if (error instanceof ProfileStateAuthorityBootstrapError) { + throw error + } + throw new ProfileStateRecoveryRequiredError(options, error) + } +} + +function createEmptyProfileStateDatabase({ + dataFile, + databaseFile, + profileId +}: ProfileStateAuthorityBootstrapOptions): void { + const temporaryDatabaseFile = `${databaseFile}.empty.${process.pid}.${randomUUID()}.tmp` + let published = false + try { + const opened = openProfileStateDatabase(temporaryDatabaseFile, profileId) + opened.db.close() + if (classifyProfileStateStorage(dataFile, databaseFile) !== 'neither') { + throw new ProfileStateAuthorityBootstrapError( + 'Profile state storage changed while creating an empty database' + ) + } + if (!publishFileDurableSync(temporaryDatabaseFile, databaseFile)) { + throw new ProfileStateAuthorityBootstrapError( + 'Profile state storage changed while creating an empty database' + ) + } + published = true + } finally { + if (!published) { + for (const path of profileStateDatabaseFiles(temporaryDatabaseFile)) { + rmSync(path, { force: true }) + } + } + } +} + +function migrateJsonOnlyProfile( + options: ProfileStateAuthorityBootstrapOptions +): ProfileStateAuthorityBootstrapResult { + const rawJson = readFileSync(options.dataFile, 'utf8') + // serializedState makes malformed input fail closed and prevents backup + // recovery or a normalization write from changing the legacy source. + const store = new Store({ dataFile: options.dataFile, serializedState: rawJson }) + const prepared = store.prepareProfileStateExport() + const migrated = migrateProfileStateToSqlite({ + ...options, + expectedLegacyJson: rawJson, + serializedState: prepared.json + }) + prepared.commit() + return { classification: 'json-only', ...migrated, migrated: true } +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts b/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts new file mode 100644 index 000000000000..0fec8e1626be --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { clearProfileStateAutomationRuns } from './profile-state-automation-runs' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateRevision, + readProfileStateSnapshot +} from './profile-state-documents' +import { writeProfileStateDomains } from './profile-state-domain-writes' + +const databases: { db: ReturnType['db']; directory: string }[] = [] + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-history-equality-')) + const { db } = openProfileStateDatabase(join(directory, 'state.db'), 'profile') + const runs = [{ id: 'run', extension: { content: '雪 🐋' } }] + const payload = JSON.stringify(runs) + importProfileStateJson(db, JSON.stringify({ settings: {}, automationRuns: runs })) + const result = { db, directory, runs, payload } + databases.push(result) + return result +} + +afterEach(() => { + for (const { db, directory } of databases.splice(0)) { + db.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('automation history replacement equality', () => { + it.each([false, true])( + 'keeps revision and timestamp for equal history (whitespace: %s)', + (spaces) => { + const { db, payload, runs } = fixture() + const before = readProfileStateSnapshot(db) + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { + domain: 'automationRuns', + payload: spaces ? JSON.stringify(runs, null, 2) : payload, + now: () => { + throw new Error('An unchanged replacement must not request a timestamp') + } + } + ] + }) + ).toEqual({ changed: false, revision: 1, changedDomains: [] }) + expect(readProfileStateSnapshot(db)).toEqual(before) + } + ) + + it('fences a stale caller even when its history still matches', () => { + const { db, payload } = fixture() + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'settings', payload: '{"theme":"dark"}' }] + }) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload }] + }) + ).toThrow(expect.objectContaining({ code: 'profile-state-revision-conflict' })) + expect(readProfileStateRevision(db)).toBe(2) + }) + + it('rejects malformed JSON before trusting an equal stored hash', () => { + const { db } = fixture() + db.prepare('UPDATE profile_state_automation_runs_meta SET content_hash = ?').run( + hashProfileStateJson('[') + ) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload: '[' }] + }) + ).toThrow('Profile state domain payload is invalid JSON: automationRuns') + expect(db.isTransaction).toBe(false) + expect(readProfileStateRevision(db)).toBe(1) + }) + + it('normalizes an equal document payload when normalized storage is not established', () => { + const { db, payload } = fixture() + clearProfileStateAutomationRuns(db) + db.prepare( + "UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = 'automationRuns'" + ).run(payload, hashProfileStateJson(payload)) + const before = readProfileStateSnapshot(db) + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload }] + }) + ).toEqual({ changed: true, revision: 2, changedDomains: ['automationRuns'] }) + expect(readProfileStateSnapshot(db).json).toBe(before.json) + expect(db.prepare('SELECT presence FROM profile_state_automation_runs_meta').get()).toEqual({ + presence: 'array' + }) + }) + + it('rejects a corrupt document placeholder before accepting equal normalized history', () => { + const { db, payload } = fixture() + db.prepare( + "UPDATE profile_state_documents SET payload = 'true' WHERE domain = 'automationRuns'" + ).run() + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload }] + }) + ).toThrow('Profile state document hash mismatch: automationRuns') + expect(readProfileStateRevision(db)).toBe(1) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-migration.ts b/src/main/persistence/profile-state/profile-state-automation-runs-migration.ts new file mode 100644 index 000000000000..adf996192423 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-migration.ts @@ -0,0 +1,46 @@ +import type Database from '../../sqlite/sync-database' +import { + readProfileStateRevision, + assertProfileStateDocumentRevision +} from './profile-state-revision' +import { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs-reader' +import { + ProfileStateDocumentCorruptionError, + validateProfileStateDocumentRow +} from './profile-state-document-validation' +import { + markAutomationRunsDocumentStorage, + compactAutomationRunsDocument +} from './profile-state-automation-runs-storage' + +export function migrateAutomationRunsStorage(db: Database.Database, storedVersion: number): void { + if (storedVersion < 2) { + markAutomationRunsDocumentStorage(db) + } else { + const meta = db + .prepare('SELECT domain FROM profile_state_automation_runs_meta WHERE domain = ?') + .get('automationRuns') + if (meta === undefined) { + // Schema 2 cannot distinguish cleared history from a lost projection marker. + if ( + readProfileStateRevision(db) !== 0 || + db.prepare('SELECT 1 FROM profile_state_documents LIMIT 1').get() !== undefined || + db.prepare('SELECT 1 FROM profile_state_automation_runs LIMIT 1').get() !== undefined + ) { + throw new ProfileStateDocumentCorruptionError( + 'Schema 2 automationRuns storage is ambiguous; restore a validated backup or JSON export', + 'automationRuns' + ) + } + markAutomationRunsDocumentStorage(db) + } + } + const revision = readProfileStateRevision(db) + for (const row of db.prepare('SELECT * FROM profile_state_documents').all()) { + const document = validateProfileStateDocumentRow(row) + assertProfileStateDocumentRevision(document.revision, revision, document.domain) + } + if (readProfileStateAutomationRunsDocument(db, revision) !== undefined) { + compactAutomationRunsDocument(db) + } +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-model.ts b/src/main/persistence/profile-state/profile-state-automation-runs-model.ts new file mode 100644 index 000000000000..1fc5a70b9ffa --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-model.ts @@ -0,0 +1,55 @@ +export const PROFILE_STATE_AUTOMATION_RUNS_TABLE = 'profile_state_automation_runs' +export const PROFILE_STATE_AUTOMATION_RUNS_META_TABLE = 'profile_state_automation_runs_meta' + +export const AUTOMATION_RUNS_DOMAIN = 'automationRuns' +export const AUTOMATION_RUNS_ABSENT = 'absent' +export const AUTOMATION_RUNS_NULL = 'null' +export const AUTOMATION_RUNS_ARRAY = 'array' +export const AUTOMATION_RUNS_DOCUMENT = 'document' + +export type AutomationRunsPresence = + | typeof AUTOMATION_RUNS_DOCUMENT + | typeof AUTOMATION_RUNS_ABSENT + | typeof AUTOMATION_RUNS_NULL + | typeof AUTOMATION_RUNS_ARRAY + +export type AutomationRunsReplacement = { + payload: string | null + domainVersion: number + now?: () => number +} + +export type AutomationRunsMeta = { + presence: AutomationRunsPresence + domainVersion: number + revision: number + updatedAt: number + contentHash: string +} + +export type AutomationRunPayload = { + id: string + ordinal: number + payload: string + contentHash: string +} + +export type NormalizedAutomationRunRow = AutomationRunPayload & { + revision: number + updatedAt: number +} + +export type AutomationRunIdentity = { + id: string + ordinal: number + contentHash: string +} + +export type ParsedAutomationRunsReplacement = + | { presence: typeof AUTOMATION_RUNS_ABSENT; payload: null; runs?: undefined } + | { presence: typeof AUTOMATION_RUNS_NULL; payload: 'null'; runs?: undefined } + | { + presence: typeof AUTOMATION_RUNS_ARRAY + payload: string + runs: readonly AutomationRunPayload[] + } diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts b/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts new file mode 100644 index 000000000000..6a9f756c00e4 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts @@ -0,0 +1,70 @@ +import { hashProfileStatePayload, isRecord } from './profile-state-document-validation' +import { + AUTOMATION_RUNS_ABSENT, + AUTOMATION_RUNS_ARRAY, + AUTOMATION_RUNS_NULL, + type AutomationRunPayload, + type ParsedAutomationRunsReplacement +} from './profile-state-automation-runs-model' + +export function parseAutomationRunsReplacement( + payload: string | null +): ParsedAutomationRunsReplacement | undefined { + if (payload === null) { + return { presence: AUTOMATION_RUNS_ABSENT, payload: null } + } + let parsed: unknown + try { + parsed = JSON.parse(payload) + } catch { + return undefined + } + if (parsed === null) { + return { presence: AUTOMATION_RUNS_NULL, payload: 'null' } + } + if (!Array.isArray(parsed)) { + return undefined + } + const runs = parseAutomationRunValues(parsed) + if (runs === undefined) { + return undefined + } + return { + presence: AUTOMATION_RUNS_ARRAY, + payload: `[${runs.map((run) => run.payload).join(',')}]`, + runs + } +} + +export function parseAutomationRunValues( + values: readonly unknown[] +): AutomationRunPayload[] | undefined { + const ids = new Set() + const runs: AutomationRunPayload[] = [] + for (const [ordinal, value] of values.entries()) { + if (!isRecord(value) || typeof value.id !== 'string' || ids.has(value.id)) { + return undefined + } + const runPayload = JSON.stringify(value) + if (runPayload === undefined) { + return undefined + } + ids.add(value.id) + runs.push({ + id: value.id, + ordinal, + payload: runPayload, + contentHash: hashProfileStatePayload(runPayload) + }) + } + return runs +} + +export function hashAutomationRunsReplacement( + replacement: ParsedAutomationRunsReplacement +): string { + if (replacement.presence === AUTOMATION_RUNS_ABSENT) { + return '' + } + return hashProfileStatePayload(replacement.payload) +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts b/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts new file mode 100644 index 000000000000..6246f71a0c52 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts @@ -0,0 +1,128 @@ +import { createHash } from 'node:crypto' +import type Database from '../../sqlite/sync-database' +import { readProfileStateRevision } from './profile-state-revision' +import { + hashProfileStatePayload, + ProfileStateDocumentCorruptionError, + type ProfileStateDocument, + type ProfileStateParsedDocument +} from './profile-state-document-validation' +import { + AUTOMATION_RUNS_ABSENT, + AUTOMATION_RUNS_DOCUMENT, + AUTOMATION_RUNS_DOMAIN, + AUTOMATION_RUNS_NULL, + PROFILE_STATE_AUTOMATION_RUNS_TABLE, + type AutomationRunsMeta +} from './profile-state-automation-runs-model' +import { + assertNoNormalizedAutomationRuns, + parseNormalizedAutomationRunRow +} from './profile-state-automation-runs-validation' + +import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' + +/** Undefined means explicit document storage; null means the domain is absent. */ +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision?: number +): ProfileStateDocument | null | undefined +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision: number, + representation: 'parsed' +): ProfileStateParsedDocument | null | undefined +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision = readProfileStateRevision(db), + representation: 'serialized' | 'parsed' = 'serialized' +): ProfileStateDocument | ProfileStateParsedDocument | null | undefined { + const meta = readCurrentAutomationRunsState(db, profileRevision) + if (meta.presence === AUTOMATION_RUNS_DOCUMENT) { + return undefined + } + if (meta.presence === AUTOMATION_RUNS_ABSENT) { + assertNoNormalizedAutomationRuns(db) + return null + } + if (meta.presence === AUTOMATION_RUNS_NULL) { + if (meta.contentHash !== hashProfileStatePayload('null')) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns null hash mismatch', + AUTOMATION_RUNS_DOMAIN + ) + } + assertNoNormalizedAutomationRuns(db) + return makeAutomationRunsDocument( + 'null', + meta, + representation === 'parsed' ? { value: null } : undefined + ) + } + + // Sort references instead of copying large payloads into SQLite's temporary sort table. + const parsedRows = db + .prepare( + `SELECT run_id, ordinal, payload, content_hash, revision, updated_at FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE}` + ) + .all() + .map((row) => parseNormalizedAutomationRunRow(row, representation === 'parsed')) + .sort((left, right) => left.ordinal - right.ordinal) + const payloads: string[] = [] + const values: unknown[] = [] + const aggregate = representation === 'parsed' ? createHash('sha256').update('[') : undefined + const ids = new Set() + parsedRows.forEach((parsed, index) => { + if (parsed.ordinal !== index || ids.has(parsed.id)) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns ordering is corrupt', + AUTOMATION_RUNS_DOMAIN + ) + } + if ( + parsed.revision > meta.revision || + (parsed.revision === meta.revision && parsed.updatedAt !== meta.updatedAt) + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row metadata is inconsistent', + AUTOMATION_RUNS_DOMAIN + ) + } + ids.add(parsed.id) + if (aggregate) { + if (index > 0) { + aggregate.update(',') + } + aggregate.update(parsed.payload, 'utf8') + values.push(parsed.value) + } else { + payloads.push(parsed.payload) + } + }) + const payload = aggregate ? '' : `[${payloads.join(',')}]` + const contentHash = aggregate + ? aggregate.update(']').digest('hex') + : hashProfileStatePayload(payload) + if (contentHash !== meta.contentHash) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns aggregate hash mismatch', + AUTOMATION_RUNS_DOMAIN + ) + } + return makeAutomationRunsDocument(payload, meta, aggregate ? { value: values } : undefined) +} + +function makeAutomationRunsDocument( + payload: string, + meta: AutomationRunsMeta, + parsed?: { value: unknown } +): ProfileStateDocument | ProfileStateParsedDocument { + return { + domain: AUTOMATION_RUNS_DOMAIN, + ...(parsed ?? { payload }), + domainVersion: meta.domainVersion, + revision: meta.revision, + updatedAt: meta.updatedAt, + contentHash: meta.contentHash + } +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts b/src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts new file mode 100644 index 000000000000..389498aa3649 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { openProfileStateDatabase } from './profile-state-database' +import { importProfileStateJson, readProfileStateSnapshot } from './profile-state-documents' +import { writeProfileStateDomains } from './profile-state-domain-writes' + +describe('automation history serialization', () => { + it.each(['import', 'replacement', 'delta'] as const)( + 'preserves JSON ordering, escaping and unknown fields through %s', + (operation) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-automation-run-serialization-')) + const { db } = openProfileStateDatabase(join(directory, 'state.db'), 'profile') + try { + const fixtureRun = buildProfileStateCutoverFixture().automationRuns[0] + if (!fixtureRun) { + throw new Error('Expected an automation run fixture') + } + const runs = [ + { + ...fixtureRun, + id: 'second', + extension: { + '3': 3, + '1': 1, + z: '雪 🐋\ud800', + a: ['\\', '\n', '"', null], + omitted: undefined + } + }, + { ...fixtureRun, id: 'first', extension: { fractional: -0 } } + ] + const settings = { note: 'unchanged' } + const expected = JSON.stringify({ settings, automationRuns: runs }) + importProfileStateJson(db, JSON.stringify({ settings, automationRuns: runs.toReversed() })) + if (operation === 'import') { + importProfileStateJson(db, expected, { expectedRevision: 1 }) + } else { + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: + operation === 'replacement' + ? [{ domain: 'automationRuns', payload: JSON.stringify(runs) }] + : [], + ...(operation === 'delta' ? { automationRunsAfter: runs } : {}) + }) + } + + expect(readProfileStateSnapshot(db)).toMatchObject({ revision: 2, json: expected }) + expect( + writeProfileStateDomains(db, { + expectedRevision: 2, + replacements: [], + automationRunsAfter: runs + }) + ).toEqual({ changed: false, revision: 2, changedDomains: [] }) + } finally { + db.close() + rmSync(directory, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-storage.ts b/src/main/persistence/profile-state/profile-state-automation-runs-storage.ts new file mode 100644 index 000000000000..c9ee590d3695 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-storage.ts @@ -0,0 +1,49 @@ +import type Database from '../../sqlite/sync-database' +import { hashProfileStatePayload } from './profile-state-document-validation' +import { assertProfileStateDocumentRevision } from './profile-state-revision' +import { + AUTOMATION_RUNS_DOCUMENT, + AUTOMATION_RUNS_DOMAIN, + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + type AutomationRunsMeta +} from './profile-state-automation-runs-model' +import { + assertNoNormalizedAutomationRuns, + parseAutomationRunsMeta +} from './profile-state-automation-runs-validation' + +export function readCurrentAutomationRunsState( + db: Database.Database, + actualRevision: number +): AutomationRunsMeta { + const row = db + .prepare( + `SELECT domain, presence, domain_version, revision, updated_at, content_hash + FROM ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} WHERE domain = ?` + ) + .get(AUTOMATION_RUNS_DOMAIN) + const meta = parseAutomationRunsMeta(row) + assertProfileStateDocumentRevision(meta.revision, actualRevision, AUTOMATION_RUNS_DOMAIN) + if (meta.presence === AUTOMATION_RUNS_DOCUMENT) { + assertNoNormalizedAutomationRuns(db) + } + return meta +} + +export function markAutomationRunsDocumentStorage(db: Database.Database): void { + db.prepare( + `INSERT INTO ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} + (domain, presence, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, 1, 0, 0, '') + ON CONFLICT(domain) DO UPDATE SET presence = excluded.presence, + domain_version = 1, revision = 0, updated_at = 0, content_hash = ''` + ).run(AUTOMATION_RUNS_DOMAIN, AUTOMATION_RUNS_DOCUMENT) +} + +/** Retain the key's JSON position without retaining a second history payload. */ +export function compactAutomationRunsDocument(db: Database.Database): void { + db.prepare( + `UPDATE profile_state_documents SET payload = 'null', content_hash = ? + WHERE domain = ? AND payload <> 'null'` + ).run(hashProfileStatePayload('null'), AUTOMATION_RUNS_DOMAIN) +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-validation.ts b/src/main/persistence/profile-state/profile-state-automation-runs-validation.ts new file mode 100644 index 000000000000..98d0f79cc4b4 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-validation.ts @@ -0,0 +1,154 @@ +import type Database from '../../sqlite/sync-database' +import { + hashProfileStatePayload, + isRecord, + ProfileStateDocumentCorruptionError +} from './profile-state-document-validation' +import { + AUTOMATION_RUNS_ABSENT, + AUTOMATION_RUNS_ARRAY, + AUTOMATION_RUNS_DOMAIN, + AUTOMATION_RUNS_DOCUMENT, + AUTOMATION_RUNS_NULL, + PROFILE_STATE_AUTOMATION_RUNS_TABLE, + type AutomationRunIdentity, + type AutomationRunsMeta, + type NormalizedAutomationRunRow +} from './profile-state-automation-runs-model' + +export function assertNoNormalizedAutomationRuns(db: Database.Database): void { + const row = db + .prepare(`SELECT COUNT(*) AS count FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE}`) + .get() + if (isRecord(row) && row.count !== 0) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns rows exist for an empty domain', + AUTOMATION_RUNS_DOMAIN + ) + } +} + +export function parseAutomationRunsMeta(row: unknown): AutomationRunsMeta { + if ( + !isRecord(row) || + row.domain !== AUTOMATION_RUNS_DOMAIN || + (row.presence !== AUTOMATION_RUNS_ABSENT && + row.presence !== AUTOMATION_RUNS_DOCUMENT && + row.presence !== AUTOMATION_RUNS_NULL && + row.presence !== AUTOMATION_RUNS_ARRAY) || + typeof row.domain_version !== 'number' || + typeof row.revision !== 'number' || + typeof row.updated_at !== 'number' || + typeof row.content_hash !== 'string' + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns metadata is malformed', + AUTOMATION_RUNS_DOMAIN + ) + } + if ( + !Number.isSafeInteger(row.domain_version) || + row.domain_version < 1 || + !Number.isSafeInteger(row.revision) || + row.revision < (row.presence === AUTOMATION_RUNS_DOCUMENT ? 0 : 1) || + !Number.isSafeInteger(row.updated_at) || + row.updated_at < 0 || + (row.presence === AUTOMATION_RUNS_ABSENT || row.presence === AUTOMATION_RUNS_DOCUMENT + ? row.content_hash !== '' + : !/^[a-f0-9]{64}$/.test(row.content_hash)) + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns metadata is invalid', + AUTOMATION_RUNS_DOMAIN + ) + } + if ( + row.presence === AUTOMATION_RUNS_DOCUMENT && + (row.revision !== 0 || row.updated_at !== 0 || row.domain_version !== 1) + ) { + throw new ProfileStateDocumentCorruptionError( + 'AutomationRuns document storage marker is invalid', + AUTOMATION_RUNS_DOMAIN + ) + } + return { + presence: row.presence, + domainVersion: row.domain_version, + revision: row.revision, + updatedAt: row.updated_at, + contentHash: row.content_hash + } +} + +export function parseNormalizedAutomationRunRow( + row: unknown, + retainParsedValue = false +): NormalizedAutomationRunRow & { value?: unknown } { + if ( + !isRecord(row) || + typeof row.run_id !== 'string' || + typeof row.ordinal !== 'number' || + typeof row.payload !== 'string' || + typeof row.content_hash !== 'string' || + typeof row.revision !== 'number' || + typeof row.updated_at !== 'number' || + !Number.isSafeInteger(row.ordinal) || + row.ordinal < 0 || + !Number.isSafeInteger(row.revision) || + row.revision < 1 || + !Number.isSafeInteger(row.updated_at) || + row.updated_at < 0 || + hashProfileStatePayload(row.payload) !== row.content_hash + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row is corrupt', + AUTOMATION_RUNS_DOMAIN + ) + } + // Individually invalid fragments can splice into a valid aggregate with matching IDs. + let parsed: unknown + try { + parsed = JSON.parse(row.payload) + } catch { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row is invalid JSON', + AUTOMATION_RUNS_DOMAIN + ) + } + if (!isRecord(parsed) || parsed.id !== row.run_id) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row identity is corrupt', + AUTOMATION_RUNS_DOMAIN + ) + } + return { + id: row.run_id, + ordinal: row.ordinal, + payload: row.payload, + contentHash: row.content_hash, + revision: row.revision, + updatedAt: row.updated_at, + ...(retainParsedValue ? { value: parsed } : {}) + } +} + +export function parseAutomationRunIdentity(row: unknown): AutomationRunIdentity { + if ( + !isRecord(row) || + typeof row.run_id !== 'string' || + typeof row.ordinal !== 'number' || + typeof row.content_hash !== 'string' || + !Number.isSafeInteger(row.ordinal) || + row.ordinal < 0 + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row is malformed', + AUTOMATION_RUNS_DOMAIN + ) + } + return { + id: row.run_id, + ordinal: row.ordinal, + contentHash: row.content_hash + } +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts b/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts new file mode 100644 index 000000000000..4627e915966a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts @@ -0,0 +1,170 @@ +import type Database from '../../sqlite/sync-database' +import { + AUTOMATION_RUNS_ARRAY, + AUTOMATION_RUNS_DOMAIN, + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE, + type AutomationRunsReplacement, + type AutomationRunIdentity, + type ParsedAutomationRunsReplacement +} from './profile-state-automation-runs-model' +import { + hashAutomationRunsReplacement, + parseAutomationRunValues, + parseAutomationRunsReplacement +} from './profile-state-automation-runs-payload' +import { parseAutomationRunIdentity } from './profile-state-automation-runs-validation' +import { + readCurrentAutomationRunsState, + compactAutomationRunsDocument +} from './profile-state-automation-runs-storage' + +export type AutomationRunsWritePreparation = { + changed: boolean + incoming: ParsedAutomationRunsReplacement + domainVersion: number + now?: () => number +} + +export function rebuildProfileStateAutomationRunsProjection( + db: Database.Database, + payload: string, + domainVersion: number, + updatedAt: number, + revision: number +): boolean { + const incoming = parseAutomationRunsReplacement(payload) + if (incoming === undefined) { + return false + } + const now = resolveAutomationRunsTimestamp(() => updatedAt) + applyIncomingAutomationRuns(db, incoming, domainVersion, now, revision) + return true +} + +export function prepareProfileStateAutomationRunsReplacement( + db: Database.Database, + replacement: AutomationRunsReplacement, + actualRevision: number +): AutomationRunsWritePreparation | undefined { + const current = readCurrentAutomationRunsState(db, actualRevision) + const incoming = parseAutomationRunsReplacement(replacement.payload) + if (incoming === undefined) { + return undefined + } + return { + changed: + current.presence !== incoming.presence || + current.contentHash !== hashAutomationRunsReplacement(incoming), + incoming, + domainVersion: replacement.domainVersion, + now: replacement.now + } +} + +export function prepareProfileStateAutomationRunsDelta( + db: Database.Database, + after: readonly unknown[], + domainVersion: number, + now: () => number, + actualRevision: number +): AutomationRunsWritePreparation | undefined { + const runs = parseAutomationRunValues(after) + if (runs === undefined) { + return undefined + } + const incoming: ParsedAutomationRunsReplacement = { + presence: AUTOMATION_RUNS_ARRAY, + payload: `[${runs.map((run) => run.payload).join(',')}]`, + runs + } + const current = readCurrentAutomationRunsState(db, actualRevision) + return { + changed: + current.presence !== incoming.presence || + current.contentHash !== hashAutomationRunsReplacement(incoming), + incoming, + domainVersion, + now + } +} + +export function applyProfileStateAutomationRuns( + db: Database.Database, + preparation: AutomationRunsWritePreparation, + nextRevision: number +): void { + const now = resolveAutomationRunsTimestamp(preparation.now ?? Date.now) + applyIncomingAutomationRuns( + db, + preparation.incoming, + preparation.domainVersion, + now, + nextRevision + ) +} + +function applyIncomingAutomationRuns( + db: Database.Database, + incoming: ParsedAutomationRunsReplacement, + domainVersion: number, + now: number, + nextRevision: number +): void { + const existingRows = new Map() + for (const row of db + .prepare(`SELECT run_id, ordinal, content_hash FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE}`) + .all()) { + const parsed = parseAutomationRunIdentity(row) + existingRows.set(parsed.id, parsed) + } + + const incomingIds = new Set() + const upsert = db.prepare( + `INSERT INTO ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} + (run_id, ordinal, payload, content_hash, revision, updated_at) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id) DO UPDATE SET ordinal = excluded.ordinal, + payload = excluded.payload, content_hash = excluded.content_hash, + revision = excluded.revision, updated_at = excluded.updated_at` + ) + if (incoming.presence === AUTOMATION_RUNS_ARRAY) { + for (const run of incoming.runs) { + incomingIds.add(run.id) + const existing = existingRows.get(run.id) + if (existing?.ordinal === run.ordinal && existing.contentHash === run.contentHash) { + continue + } + upsert.run(run.id, run.ordinal, run.payload, run.contentHash, nextRevision, now) + } + } + const remove = db.prepare(`DELETE FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} WHERE run_id = ?`) + for (const id of existingRows.keys()) { + if (!incomingIds.has(id)) { + remove.run(id) + } + } + + db.prepare( + `INSERT INTO ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} + (domain, presence, domain_version, revision, updated_at, content_hash) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(domain) DO UPDATE SET presence = excluded.presence, + domain_version = excluded.domain_version, revision = excluded.revision, + updated_at = excluded.updated_at, content_hash = excluded.content_hash` + ).run( + AUTOMATION_RUNS_DOMAIN, + incoming.presence, + domainVersion, + nextRevision, + now, + hashAutomationRunsReplacement(incoming) + ) + compactAutomationRunsDocument(db) +} + +function resolveAutomationRunsTimestamp(nowFactory: () => number): number { + const now = nowFactory() + if (!Number.isSafeInteger(now) || now < 0) { + throw new Error('Profile state domain update timestamp is invalid: automationRuns') + } + return now +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs.ts b/src/main/persistence/profile-state/profile-state-automation-runs.ts new file mode 100644 index 000000000000..2a44c79f210e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs.ts @@ -0,0 +1,44 @@ +import type Database from '../../sqlite/sync-database' +import { markAutomationRunsDocumentStorage } from './profile-state-automation-runs-storage' +import { + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE +} from './profile-state-automation-runs-model' + +export { + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE +} from './profile-state-automation-runs-model' +export type { AutomationRunPayload } from './profile-state-automation-runs-model' +export type { AutomationRunsWritePreparation } from './profile-state-automation-runs-writer' +export { + applyProfileStateAutomationRuns, + prepareProfileStateAutomationRunsDelta, + prepareProfileStateAutomationRunsReplacement, + rebuildProfileStateAutomationRunsProjection +} from './profile-state-automation-runs-writer' +export { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs-reader' + +export function createProfileStateAutomationRunsTablesSql(): string { + return `CREATE TABLE IF NOT EXISTS ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} ( + domain TEXT PRIMARY KEY NOT NULL, + presence TEXT NOT NULL, + domain_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + content_hash TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} ( + run_id TEXT PRIMARY KEY NOT NULL, + ordinal INTEGER NOT NULL, + payload TEXT NOT NULL, + content_hash TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL + );` +} + +export function clearProfileStateAutomationRuns(db: Database.Database): void { + db.prepare(`DELETE FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE}`).run() + markAutomationRunsDocumentStorage(db) +} diff --git a/src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts b/src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts new file mode 100644 index 000000000000..bb28c491f0e8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts @@ -0,0 +1,380 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { openProfileStateDatabase, profileStatePragmaNumber } from './profile-state-database' +import { PROFILE_STATE_DATABASE_SCHEMA_VERSION } from './profile-state-database-schema' +import { hashProfileStatePayload } from './profile-state-document-validation' +import { exportProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { readProfileStateDomain } from './profile-state-domain-reader' +import { writeProfileStateDomain } from './profile-state-domain-writes' + +const directories: string[] = [] +const connections: Database.Database[] = [] +const staleRuns = [{ id: 'deleted-run', status: 'running', output: 'stale history'.repeat(1_000) }] +const liveRuns = [{ id: 'live-run', status: 'completed', future: { retained: true } }] + +afterEach(() => { + for (const db of connections.splice(0)) { + db.close() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function databasePath(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-automation-upgrade-')) + directories.push(directory) + return join(directory, 'profile-state.db') +} + +function openDatabase(path: string): Database.Database { + const { db } = openProfileStateDatabase(path, 'profile-a') + connections.push(db) + return db +} + +function expectRejectedDatabaseUntouched(path: string, profileId = 'profile-a'): void { + const before = readFileSync(path) + expect(() => { + const { db } = openProfileStateDatabase(path, profileId) + connections.push(db) + }).toThrowError( + expect.objectContaining({ + code: profileId === 'profile-a' ? 'unreadable' : 'identity-mismatch' + }) + ) + expect(readFileSync(path)).toEqual(before) +} + +type LegacyProjection = { + presence: 'array' | 'null' | 'absent' + runs?: readonly { id: string; [key: string]: unknown }[] +} + +function seedLegacyDatabase( + version: 1 | 2, + root: Record, + projection?: LegacyProjection, + revision = projection ? 2 : 1 +): string { + const path = databasePath() + const db = new Database(path) + try { + db.exec(` + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL + ); + `) + db.prepare('INSERT INTO profile_state_meta VALUES (?, ?)').run('profile_id', 'profile-a') + db.prepare('INSERT INTO profile_state_meta VALUES (?, ?)').run('revision', String(revision)) + for (const [domain, value] of Object.entries(root)) { + const payload = JSON.stringify(value) + db.prepare('INSERT INTO profile_state_documents VALUES (?, ?, 1, 1, 100, ?)').run( + domain, + payload, + hashProfileStatePayload(payload) + ) + } + if (version === 2) { + db.exec(` + CREATE TABLE profile_state_automation_runs_meta ( + domain TEXT PRIMARY KEY NOT NULL, presence TEXT NOT NULL, + domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL + ); + CREATE TABLE profile_state_automation_runs ( + run_id TEXT PRIMARY KEY NOT NULL, ordinal INTEGER NOT NULL, + payload TEXT NOT NULL, content_hash TEXT NOT NULL, + revision INTEGER NOT NULL, updated_at INTEGER NOT NULL + ); + `) + if (projection) { + const runs = projection.runs ?? [] + const payload = projection.presence === 'array' ? JSON.stringify(runs) : 'null' + db.prepare( + 'INSERT INTO profile_state_automation_runs_meta VALUES (?, ?, 1, 2, 200, ?)' + ).run( + 'automationRuns', + projection.presence, + projection.presence === 'absent' ? '' : hashProfileStatePayload(payload) + ) + for (const [ordinal, run] of runs.entries()) { + const runPayload = JSON.stringify(run) + db.prepare('INSERT INTO profile_state_automation_runs VALUES (?, ?, ?, ?, 2, 200)').run( + run.id, + ordinal, + runPayload, + hashProfileStatePayload(runPayload) + ) + } + } + } + db.pragma(`user_version = ${version}`) + } finally { + db.close() + } + return path +} + +describe('automation storage schema upgrades', () => { + it.each([ + { label: 'supported runs', value: liveRuns }, + { label: 'explicit null', value: null }, + { label: 'unknown object', value: { futureFormat: [1, 2] } }, + { label: 'duplicate identifiers', value: [{ id: 'same' }, { id: 'same', future: true }] } + ])('preserves version 1 $label and domain ordering', ({ value }) => { + const root = { settings: { theme: 'dark' }, automationRuns: value, futureDomain: [2, 1] } + const db = openDatabase(seedLegacyDatabase(1, root)) + + expect(profileStatePragmaNumber(db, 'user_version')).toBe(PROFILE_STATE_DATABASE_SCHEMA_VERSION) + expect(exportProfileStateJson(db)).toBe(JSON.stringify(root)) + expect(db.prepare('SELECT presence FROM profile_state_automation_runs_meta').get()).toEqual({ + presence: 'document' + }) + expect(db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get()).toEqual( + { + count: 0 + } + ) + }) + + it('normalizes a version 1 document on replacement without duplicating its payload', () => { + const root = { settings: {}, automationRuns: liveRuns, futureDomain: [2, 1] } + const db = openDatabase(seedLegacyDatabase(1, root)) + + expect( + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify(liveRuns), + expectedRevision: 1 + }) + ).toEqual({ changed: true, revision: 2 }) + + expect(exportProfileStateJson(db)).toBe(JSON.stringify(root)) + expect( + db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual({ payload: 'null' }) + }) + + it.each([ + { presence: 'array', runs: liveRuns, expected: liveRuns }, + { presence: 'array', runs: [], expected: [] }, + { presence: 'null', expected: null }, + { presence: 'absent', expected: undefined } + ] as const)( + 'keeps version 2 $presence authority while removing stale retained history', + (value) => { + const root = { settings: {}, automationRuns: staleRuns, futureDomain: { kept: true } } + const path = seedLegacyDatabase(2, root, value) + const db = openDatabase(path) + const expected = { ...root, automationRuns: value.expected } + + expect(exportProfileStateJson(db)).toBe(JSON.stringify(expected)) + expect(readProfileStateDomain(path, 'profile-a', 'automationRuns')).toEqual( + value.expected === undefined + ? { kind: 'missing' } + : { kind: 'value', value: value.expected } + ) + expect( + db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual({ payload: 'null' }) + expect(profileStatePragmaNumber(db, 'user_version')).toBe( + PROFILE_STATE_DATABASE_SCHEMA_VERSION + ) + } + ) + + it.each([ + { label: 'stale run array', root: { automationRuns: staleRuns } }, + { label: 'empty array', root: { automationRuns: [] } }, + { label: 'explicit null', root: { automationRuns: null } }, + { label: 'unknown value', root: { automationRuns: { futureFormat: true } } }, + { label: 'absent domain', root: {} } + ])('refuses ambiguous version 2 $label without rewriting it', ({ root }) => { + const path = seedLegacyDatabase(2, root) + expectRejectedDatabaseUntouched(path) + const db = new Database(path) + connections.push(db) + expect(profileStatePragmaNumber(db, 'user_version')).toBe(2) + expect(db.prepare('SELECT domain FROM profile_state_automation_runs_meta').all()).toEqual([]) + expect( + db.prepare('SELECT domain, payload FROM profile_state_documents ORDER BY rowid').all() + ).toEqual( + Object.entries(root).map(([domain, value]) => ({ domain, payload: JSON.stringify(value) })) + ) + }) + + it('upgrades a genuinely empty version 2 database', () => { + const db = openDatabase(seedLegacyDatabase(2, {}, undefined, 0)) + expect(exportProfileStateJson(db)).toBe('{}') + expect(importProfileStateJson(db, JSON.stringify({ automationRuns: liveRuns }))).toBe(1) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ automationRuns: liveRuns }) + }) + + it.each(['legacy document', 'normalized row', 'projection metadata'])( + 'rolls back version 2 migration with corrupt %s', + (target) => { + const path = seedLegacyDatabase( + 2, + { automationRuns: staleRuns }, + { presence: 'array', runs: liveRuns } + ) + const tampered = new Database(path) + try { + if (target === 'legacy document') { + tampered.exec("UPDATE profile_state_documents SET content_hash = 'broken'") + } else if (target === 'normalized row') { + tampered.exec("UPDATE profile_state_automation_runs SET content_hash = 'broken'") + } else { + tampered.exec("UPDATE profile_state_automation_runs_meta SET content_hash = 'broken'") + } + } finally { + tampered.close() + } + + expectRejectedDatabaseUntouched(path) + const db = new Database(path) + connections.push(db) + expect(profileStatePragmaNumber(db, 'user_version')).toBe(2) + expect( + db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual({ payload: JSON.stringify(staleRuns) }) + } + ) +}) + +describe('rejected schema upgrades preserve source bytes', () => { + it.each([1, 2] as const)('rejects another profile in schema %s before migration', (version) => { + const path = seedLegacyDatabase( + version, + { automationRuns: staleRuns }, + { presence: 'array', runs: liveRuns } + ) + expectRejectedDatabaseUntouched(path, 'profile-b') + }) + + it.each([1, 2] as const)( + 'rejects an incompatible schema %s document column before migration', + (version) => { + const path = seedLegacyDatabase( + version, + { automationRuns: staleRuns }, + { presence: 'array', runs: liveRuns } + ) + const db = new Database(path) + try { + db.exec(` + ALTER TABLE profile_state_documents RENAME TO old_documents; + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, payload BLOB NOT NULL, + domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL + ); + INSERT INTO profile_state_documents SELECT * FROM old_documents; + DROP TABLE old_documents; + `) + } finally { + db.close() + } + expectRejectedDatabaseUntouched(path) + } + ) + + it.each([ + { label: 'negative schema version', sql: 'PRAGMA user_version = -1' }, + { label: 'missing run table', sql: 'DROP TABLE profile_state_automation_runs' }, + { label: 'missing metadata table', sql: 'DROP TABLE profile_state_automation_runs_meta' } + ])('rejects a version 2 database with $label before migration', ({ sql }) => { + const path = seedLegacyDatabase( + 2, + { automationRuns: staleRuns }, + { presence: 'array', runs: liveRuns } + ) + const db = new Database(path) + try { + db.exec(sql) + } finally { + db.close() + } + expectRejectedDatabaseUntouched(path) + }) +}) + +describe('required automation storage metadata', () => { + it.each([ + { label: 'cleared array', payload: '[]' }, + { label: 'explicit null', payload: 'null' }, + { label: 'removed domain', payload: null } + ])('refuses lost metadata after $label instead of resurrecting retained state', ({ payload }) => { + const path = databasePath() + const db = openDatabase(path) + importProfileStateJson(db, JSON.stringify({ automationRuns: staleRuns })) + writeProfileStateDomain(db, { domain: 'automationRuns', payload, expectedRevision: 1 }) + db.exec('DELETE FROM profile_state_automation_runs_meta') + + expect(() => exportProfileStateJson(db)).toThrow() + expect(readProfileStateDomain(path, 'profile-a', 'automationRuns')).toMatchObject({ + kind: 'unreadable' + }) + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify(liveRuns), + expectedRevision: 2 + }) + ).toThrow() + expect(db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get()).toEqual( + { count: 0 } + ) + }) + + it.each(['revision = 1', 'updated_at = 1', 'domain_version = 2', "content_hash = 'unexpected'"])( + 'rejects a malformed document marker (%s)', + (assignment) => { + const db = openDatabase(databasePath()) + importProfileStateJson(db, JSON.stringify({ automationRuns: { futureFormat: true } })) + db.exec(`UPDATE profile_state_automation_runs_meta SET ${assignment}`) + expect(() => exportProfileStateJson(db)).toThrow() + } + ) + + it('keeps one authority through complete imports of supported, unknown, and absent history', () => { + const db = openDatabase(databasePath()) + for (const root of [ + { settings: {}, automationRuns: liveRuns }, + { settings: {}, automationRuns: { futureFormat: [2, 1] } }, + { settings: {} }, + { settings: {}, automationRuns: null }, + { settings: {}, automationRuns: [] } + ]) { + importProfileStateJson(db, JSON.stringify(root)) + expect(exportProfileStateJson(db)).toBe(JSON.stringify(root)) + expect( + db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs_meta').get() + ).toEqual({ count: 1 }) + } + }) + + it('rejects reopening a current database with lost metadata before changing its bytes', () => { + const path = databasePath() + const { db } = openProfileStateDatabase(path, 'profile-a') + db.exec('DELETE FROM profile_state_automation_runs_meta') + db.close() + const before = readFileSync(path) + + expect(() => openDatabase(path)).toThrow() + expect(readFileSync(path)).toEqual(before) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-job.ts b/src/main/persistence/profile-state/profile-state-backup-job.ts new file mode 100644 index 000000000000..5adb541ee52c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-job.ts @@ -0,0 +1,31 @@ +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { readProfileStateParsedSnapshot } from './profile-state-documents' + +export type ProfileStateBackupJob = { + databasePath: string + profileId: string + targetPath: string +} + +/** Own every connection until the copy and its strict validation finish. */ +export async function writeProfileStateBackup(job: ProfileStateBackupJob): Promise { + const opened = openProfileStateDatabaseReadOnly(job.databasePath, job.profileId) + try { + await writeProfileStateDatabaseSnapshotAsync(opened.db, job.targetPath, { + validateStagedSnapshot: (stagingPath) => + validateProfileStateBackup(stagingPath, job.profileId) + }) + } finally { + opened.db.close() + } +} + +function validateProfileStateBackup(path: string, profileId: string): void { + const snapshot = openProfileStateDatabaseReadOnly(path, profileId) + try { + readProfileStateParsedSnapshot(snapshot.db) + } finally { + snapshot.db.close() + } +} diff --git a/src/main/persistence/profile-state/profile-state-backup-path.test.ts b/src/main/persistence/profile-state/profile-state-backup-path.test.ts new file mode 100644 index 000000000000..593d490ce115 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-path.test.ts @@ -0,0 +1,76 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupFiles, + profileStateDatabaseBackupPath, + profileStateDatabaseBackups +} from './profile-state-backup-path' + +const directories: string[] = [] +afterEach(() => { + for (const path of directories.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +function location(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-backup-path-')) + directories.push(directory) + return join(directory, 'profile-state.db') +} + +describe('profile state database backup discovery', () => { + it('lists immutable backup IDs newest-first without opening SQLite', () => { + const path = location() + const first = createProfileStateDatabaseBackupId(1_000) + const last = createProfileStateDatabaseBackupId(2_000) + writeFileSync(profileStateDatabaseBackupPath(path, first), 'first') + writeFileSync(profileStateDatabaseBackupPath(path, last), 'second') + writeFileSync(`${path}.backup.${last}.db.tmp`, 'incomplete staging') + writeFileSync(`${path}.backup.invalid.db`, 'unrelated') + expect(profileStateDatabaseBackups(path)).toEqual([ + { id: last, path: profileStateDatabaseBackupPath(path, last), createdAtMs: 2_000 }, + { id: first, path: profileStateDatabaseBackupPath(path, first), createdAtMs: 1_000 } + ]) + }) + + it('keeps reserved artifact names visible when their type needs recovery', () => { + const path = location() + const id = createProfileStateDatabaseBackupId() + mkdirSync(profileStateDatabaseBackupPath(path, id)) + expect(profileStateDatabaseBackups(path).map((backup) => backup.id)).toEqual([id]) + }) + + it('includes every existing backup sidecar in the recovery archive inventory', () => { + const path = location() + const backup = profileStateDatabaseBackupPath(path, createProfileStateDatabaseBackupId()) + const files = [backup, `${backup}-wal`, `${backup}-shm`, `${backup}-journal`] + for (const file of files) { + writeFileSync(file, 'recovery evidence') + } + expect(profileStateDatabaseBackupFiles(path)).toEqual(files) + }) + + it.each([ + '../profile-state.db', + '1-../../outside', + '0-00000000-0000-4000-8000-000000000000', + '9007199254740992-00000000-0000-4000-8000-000000000000' + ])('rejects invalid or escaping IDs: %s', (id) => + expect(() => profileStateDatabaseBackupPath(location(), id)).toThrow('ID is invalid') + ) + + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])('rejects unsafe backup times: %s', (time) => { + expect(() => createProfileStateDatabaseBackupId(time)).toThrow('positive safe integer') + }) + + it('treats only a missing directory as an empty recovery inventory', () => { + const path = location() + expect(profileStateDatabaseBackups(join(path, 'profile-state.db'))).toEqual([]) + writeFileSync(path, 'not a directory') + expect(() => profileStateDatabaseBackups(join(path, 'profile-state.db'))).toThrow() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-path.ts b/src/main/persistence/profile-state/profile-state-backup-path.ts new file mode 100644 index 000000000000..03a65e372293 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-path.ts @@ -0,0 +1,67 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { randomUUID } from 'node:crypto' +import { existsSync, readdirSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' + +const BACKUP_ID_PATTERN = + /^([1-9]\d*)-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/ + +export type ProfileStateDatabaseBackup = { + id: string + path: string + createdAtMs: number +} + +export function createProfileStateDatabaseBackupId(now = Date.now()): string { + if (!Number.isSafeInteger(now) || now <= 0) { + throw new Error('Profile state backup timestamp must be a positive safe integer') + } + return `${now}-${randomUUID()}` +} + +export function profileStateDatabaseBackupPath(databaseFile: string, id: string): string { + if (parseBackupCreatedAt(id) === undefined) { + throw new Error('Profile state backup ID is invalid') + } + return `${databaseFile}.backup.${id}.db` +} + +/** Enumerate immutable recovery artifacts without opening the possibly damaged primary. */ +export function profileStateDatabaseBackups( + databaseFile: string +): readonly ProfileStateDatabaseBackup[] { + const directory = dirname(databaseFile) + const prefix = `${basename(databaseFile)}.backup.` + let entries: string[] + try { + entries = readdirSync(directory) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return [] + } + throw error + } + return entries + .flatMap((name) => { + if (typeof name !== 'string' || !name.startsWith(prefix) || !name.endsWith('.db')) { + return [] + } + const id = name.slice(prefix.length, -3) + const createdAtMs = parseBackupCreatedAt(id) + return createdAtMs === undefined ? [] : [{ id, path: join(directory, name), createdAtMs }] + }) + .sort((left, right) => right.createdAtMs - left.createdAtMs || right.id.localeCompare(left.id)) +} + +function parseBackupCreatedAt(id: string): number | undefined { + const match = BACKUP_ID_PATTERN.exec(id) + const timestamp = match ? Number(match[1]) : 0 + return Number.isSafeInteger(timestamp) && timestamp > 0 ? timestamp : undefined +} + +/** Preserve sidecars too if an external writer has opened an otherwise immutable backup. */ +export function profileStateDatabaseBackupFiles(databaseFile: string): readonly string[] { + return profileStateDatabaseBackups(databaseFile).flatMap(({ path }) => + profileStateDatabaseFiles(path).filter(existsSync) + ) +} diff --git a/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts b/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts new file mode 100644 index 000000000000..df10d31f4be8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts @@ -0,0 +1,228 @@ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath, + profileStateDatabaseBackups +} from './profile-state-backup-path' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { importProfileStateJson, readProfileStateSnapshot } from './profile-state-documents' +import * as snapshots from './profile-state-database-snapshot' + +const directories: string[] = [] +const rotations: ProfileStateBackupRotation[] = [] +const databases: ReturnType[] = [] +const HOUR = 60 * 60 * 1000 + +afterEach(async () => { + for (const rotation of rotations.splice(0)) { + rotation.stop() + await rotation.drain() + } + for (const opened of databases.splice(0)) { + opened.db.close() + } + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-sqlite-backup-')) + directories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const opened = openProfileStateDatabase(databasePath, 'backup-profile') + databases.push(opened) + const clock = { now: Date.now() } + const rotation = createRotation(databasePath, () => clock.now) + const write = (generation: number) => { + importProfileStateJson(opened.db, JSON.stringify({ settings: { generation } })) + } + write(1) + return { directory, databasePath, opened, rotation, write, clock } +} + +function createRotation(databasePath: string, now: () => number = Date.now) { + const rotation = new ProfileStateBackupRotation(databasePath, 'backup-profile', now) + rotations.push(rotation) + return rotation +} + +function readBackup(path: string) { + const opened = openProfileStateDatabaseReadOnly(path, 'backup-profile') + try { + return JSON.parse(readProfileStateSnapshot(opened.db).json) + } finally { + opened.db.close() + } +} + +describe('automatic SQLite recovery generations', () => { + it('copies committed WAL state once, without a live JSON writer or snapshot sidecars', async () => { + const { directory, databasePath, rotation, write } = fixture() + write(2) + rotation.schedule() + rotation.schedule() + await rotation.drain() + const backups = profileStateDatabaseBackups(databasePath) + expect(backups).toHaveLength(1) + expect(readBackup(backups[0].path)).toEqual({ settings: { generation: 2 } }) + expect(existsSync(join(directory, 'orca-data.json'))).toBe(false) + expect(readdirSync(directory).filter((name) => name.includes('.backup.'))).toEqual([ + backups[0].path.slice(directory.length + 1) + ]) + }) + + it('keeps five hourly generations and remembers cadence across reopen', async () => { + const { databasePath, rotation, write, clock } = fixture() + const beginning = Date.now() + clock.now = beginning + for (let generation = 1; generation <= 7; generation++) { + clock.now = beginning + (generation - 1) * HOUR + write(generation) + rotation.schedule() + await rotation.drain() + } + const backups = profileStateDatabaseBackups(databasePath) + expect(backups).toHaveLength(5) + expect(backups.map(({ path }) => readBackup(path).settings.generation)).toEqual([7, 6, 5, 4, 3]) + const reopened = createRotation(databasePath, () => clock.now) + reopened.schedule() + await reopened.drain() + expect(profileStateDatabaseBackups(databasePath)).toEqual(backups) + clock.now = beginning + 7 * HOUR - 1 + reopened.schedule() + await reopened.drain() + expect(profileStateDatabaseBackups(databasePath)).toEqual(backups) + clock.now = beginning + 7 * HOUR + write(8) + reopened.schedule() + await reopened.drain() + expect( + profileStateDatabaseBackups(databasePath).map( + ({ path }) => readBackup(path).settings.generation + ) + ).toEqual([8, 7, 6, 5, 4]) + }) + + it('preserves all earlier backups on failure and retries without rejecting a committed write', async () => { + const { databasePath, rotation, opened, write, clock } = fixture() + const beginning = Date.now() + clock.now = beginning + rotation.schedule() + await rotation.drain() + const retained = profileStateDatabaseBackups(databasePath) + const snapshot = vi + .spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync') + .mockRejectedValueOnce(new Error('disk full')) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + clock.now = beginning + HOUR + write(2) + rotation.schedule() + await expect(rotation.drain()).resolves.toBeUndefined() + expect(JSON.parse(readProfileStateSnapshot(opened.db).json).settings.generation).toBe(2) + expect(profileStateDatabaseBackups(databasePath)).toEqual(retained) + expect(log).toHaveBeenCalledOnce() + rotation.schedule() + await rotation.drain() + expect(snapshot).toHaveBeenCalledOnce() + clock.now = beginning + HOUR + 60_000 + rotation.schedule() + await rotation.drain() + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(2) + }) + + it('does not prune previous generations when the new copy fails strict validation', async () => { + const { databasePath, rotation, clock } = fixture() + const beginning = Date.now() + clock.now = beginning + rotation.schedule() + await rotation.drain() + const retained = profileStateDatabaseBackups(databasePath) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockRejectedValueOnce( + new Error('staged validation failed') + ) + clock.now = beginning + HOUR + rotation.schedule() + await rotation.drain() + expect(profileStateDatabaseBackups(databasePath)).toEqual(retained) + }) + + it('cancels a queued backup before opening a source after Store close', async () => { + const { databasePath, rotation } = fixture() + rotation.schedule() + rotation.stop() + await rotation.drain() + expect(profileStateDatabaseBackups(databasePath)).toEqual([]) + }) + + it('owns an in-flight source until completion and blocks synchronous quarantine', async () => { + const { databasePath, rotation, opened } = fixture() + const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync + let begin: () => void = () => {} + let release: () => void = () => {} + const started = new Promise((resolve) => { + begin = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockImplementationOnce( + async (source, target) => { + begin() + await gate + await realSnapshot(source, target) + } + ) + rotation.schedule() + await started + rotation.stop() + opened.db.close() + databases.splice(databases.indexOf(opened), 1) + expect(() => rotation.assertIdle()).toThrow('Flush pending') + release() + await rotation.drain() + expect(() => rotation.assertIdle()).not.toThrow() + expect(readBackup(profileStateDatabaseBackups(databasePath)[0].path)).toEqual({ + settings: { generation: 1 } + }) + }) + + it('does not treat a reserved-name directory as a recent successful backup', async () => { + const { databasePath, rotation } = fixture() + const invalid = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + mkdirSync(invalid) + rotation.schedule() + await rotation.drain() + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(2) + expect(existsSync(invalid)).toBe(true) + }) + + it('preserves a backup with sidecars without counting it toward cadence or retention', async () => { + const { databasePath, rotation, clock } = fixture() + rotation.schedule() + await rotation.drain() + const original = profileStateDatabaseBackups(databasePath)[0].path + writeFileSync(`${original}-journal`, 'external unfinished write') + const reopened = createRotation(databasePath, () => clock.now) + for (let generation = 0; generation < 6; generation++) { + reopened.schedule() + await reopened.drain() + clock.now += HOUR + } + expect(existsSync(original)).toBe(true) + expect(existsSync(`${original}-journal`)).toBe(true) + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(6) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-rotation.ts b/src/main/persistence/profile-state/profile-state-backup-rotation.ts new file mode 100644 index 000000000000..dcd3196ac655 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.ts @@ -0,0 +1,128 @@ +import { lstat, rm } from 'node:fs/promises' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath, + profileStateDatabaseBackups +} from './profile-state-backup-path' +import { runProfileStateBackup } from './profile-state-backup-worker' + +const BACKUP_COUNT = 5 +const BACKUP_INTERVAL_MS = 60 * 60 * 1000 +const BACKUP_RETRY_MS = 60 * 1000 + +/** Immutable generations keep every previous recovery point until publication succeeds. */ +export class ProfileStateBackupRotation { + private pending: Promise | undefined + private stopped = false + private copying = false + private nextAttemptAt = 0 + + constructor( + private readonly databasePath: string, + private readonly profileId: string, + private readonly now: () => number = Date.now + ) {} + + schedule(): void { + if (this.stopped || this.pending || this.now() < this.nextAttemptAt) { + return + } + const pending = Promise.resolve() + .then(() => this.rotate()) + .catch((error: unknown) => { + this.nextAttemptAt = this.now() + BACKUP_RETRY_MS + if (this.stopped && isMissingPath(error)) { + return + } + console.error('[persistence] Failed to back up profile state database:', error) + }) + .finally(() => { + if (this.pending === pending) { + this.pending = undefined + } + }) + this.pending = pending + } + + async drain(): Promise { + await this.pending + } + + stop(): void { + this.stopped = true + } + + assertIdle(): void { + if (this.copying) { + throw new Error('Flush pending profile state backups before quarantining the database') + } + } + + private async rotate(): Promise { + if (this.stopped) { + return + } + const now = this.now() + const latest = (await this.regularBackups())[0] + if (this.stopped) { + return + } + if (latest && now - latest.createdAtMs < BACKUP_INTERVAL_MS) { + this.nextAttemptAt = Math.min(latest.createdAtMs, now) + BACKUP_INTERVAL_MS + return + } + const target = profileStateDatabaseBackupPath( + this.databasePath, + createProfileStateDatabaseBackupId(now) + ) + this.copying = true + try { + await runProfileStateBackup({ + databasePath: this.databasePath, + profileId: this.profileId, + targetPath: target + }) + } finally { + this.copying = false + } + this.nextAttemptAt = this.now() + BACKUP_INTERVAL_MS + for (const backup of (await this.regularBackups()).slice(BACKUP_COUNT)) { + await rm(backup.path, { force: true }) + } + } + + private async regularBackups(): Promise> { + const backups = profileStateDatabaseBackups(this.databasePath) + const candidates = await Promise.all( + backups.map(async (backup) => { + try { + if (!(await lstat(backup.path)).isFile()) { + return undefined + } + for (const suffix of ['-wal', '-shm', '-journal']) { + const sidecar = await lstat(`${backup.path}${suffix}`).catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + }) + if (sidecar !== undefined) { + return undefined + } + } + return backup + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + } + }) + ) + return candidates.filter((backup) => backup !== undefined) + } +} + +function isMissingPath(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} diff --git a/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts b/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts new file mode 100644 index 000000000000..cfcb8f75fdea --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts @@ -0,0 +1,28 @@ +import { parentPort, workerData } from 'node:worker_threads' +import { writeProfileStateBackup } from './profile-state-backup-job' +import { isRecord } from './profile-state-document-validation' + +if (!parentPort) { + throw new Error('Profile state backup must run on a worker thread') +} +const port = parentPort +const request: unknown = workerData +if ( + !isRecord(request) || + typeof request.databasePath !== 'string' || + typeof request.profileId !== 'string' || + typeof request.targetPath !== 'string' +) { + throw new Error('Invalid profile state backup request') +} + +void writeProfileStateBackup({ + databasePath: request.databasePath, + profileId: request.profileId, + targetPath: request.targetPath +}) + .then( + () => port.postMessage({ ok: true }), + (error: unknown) => port.postMessage({ ok: false, error: String(error) }) + ) + .finally(() => port.close()) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts new file mode 100644 index 000000000000..ad4f91477533 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -0,0 +1,203 @@ +import { build } from 'esbuild' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { profileStateDatabaseBackups } from './profile-state-backup-path' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateSnapshot +} from './profile-state-documents' +import { + runProfileStateBackupWorker, + resolveProfileStateBackupWorkerPath +} from './profile-state-backup-worker' +import * as backupWorker from './profile-state-backup-worker' + +const directories: string[] = [] +let workerDirectory: string +let workerPath: string + +beforeAll(async () => { + workerDirectory = mkdtempSync(join(tmpdir(), 'orca-backup-worker-entry-')) + workerPath = join(workerDirectory, 'profile-state-backup-worker-entry.js') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outfile: workerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +afterAll(() => rmSync(workerDirectory, { recursive: true, force: true })) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-backup-worker-')) + directories.push(directory) + const job = { + databasePath: join(directory, 'profile-state.db'), + targetPath: join(directory, 'backup.db'), + profileId: 'worker-test' + } + const opened = openProfileStateDatabase(job.databasePath, job.profileId) + importProfileStateJson(opened.db, '{"settings":{"theme":"dark"}}') + opened.db.close() + return { directory, job } +} + +function script(directory: string, source: string): string { + const path = join(directory, 'worker.cjs') + writeFileSync(path, source) + return path +} + +describe('profile state backup worker', () => { + it('runs the built entry and releases every handle before recovery can move its files', async () => { + const { directory, job } = fixture() + await runProfileStateBackupWorker(job, { workerPath }) + const snapshot = openProfileStateDatabaseReadOnly(job.targetPath, job.profileId) + try { + expect(JSON.parse(readProfileStateSnapshot(snapshot.db).json)).toEqual({ + settings: { theme: 'dark' } + }) + } finally { + snapshot.db.close() + } + expect(readdirSync(directory).filter((name) => name.startsWith('backup.db'))).toEqual([ + 'backup.db' + ]) + rmSync(directory, { recursive: true }) + expect(existsSync(directory)).toBe(false) + }) + + it.each([ + [ + 'a mismatched stored hash', + (db: ReturnType['db']) => { + db.prepare('UPDATE profile_state_documents SET content_hash = ?').run('0'.repeat(64)) + }, + 'hash mismatch' + ], + [ + 'an independently hashed invalid domain fragment', + (db: ReturnType['db']) => { + db.prepare( + "UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = 'settings'" + ).run('{', hashProfileStateJson('{')) + }, + 'invalid JSON' + ], + [ + 'an independently hashed invalid normalized history fragment', + (db: ReturnType['db']) => { + importProfileStateJson(db, '{"automationRuns":[{"id":"run-1","output":"ok"}]}') + db.prepare( + 'UPDATE profile_state_automation_runs SET payload = ?, content_hash = ? WHERE run_id = ?' + ).run('{', hashProfileStateJson('{'), 'run-1') + }, + 'invalid JSON' + ] + ] as const)('%s', async (_description, corrupt, expectedError) => { + const { job } = fixture() + const primary = openProfileStateDatabase(job.databasePath, job.profileId) + corrupt(primary.db) + primary.db.close() + const before = readFileSync(job.databasePath) + const retained = `${job.targetPath}.prior` + writeFileSync(retained, 'previous recovery point') + + await expect(runProfileStateBackupWorker(job, { workerPath })).rejects.toThrow(expectedError) + expect(existsSync(job.targetPath)).toBe(false) + expect(readFileSync(retained, 'utf8')).toBe('previous recovery point') + expect(readFileSync(job.databasePath)).toEqual(before) + }) + + it.each(['true', 'false'])('waits for actual exit after an ok=%s response', async (ok) => { + const { directory, job } = fixture() + const delayedWorker = script( + directory, + ` + const { parentPort, workerData } = require('node:worker_threads') + parentPort.postMessage({ ok: ${ok}, error: 'backup failed' }) + setTimeout(() => { + require('node:fs').writeFileSync(workerData.targetPath, 'handles released') + parentPort.close() + }, 50) + ` + ) + const result = runProfileStateBackupWorker(job, { workerPath: delayedWorker }) + await (ok === 'true' ? result : expect(result).rejects.toThrow('backup failed')) + expect(readFileSync(job.targetPath, 'utf8')).toBe('handles released') + }) + + it.each([ + ['throw new Error("worker boot failed")', 'worker boot failed'], + ['process.exit(0)', 'without completion'], + ['require("node:worker_threads").parentPort.postMessage({ wrong: true })', 'Invalid profile'], + ['setInterval(() => {}, 1000)', 'timed out'] + ])('fails closed for worker failure: %s', async (source, error) => { + const { directory, job } = fixture() + const failedWorker = script(directory, source) + await expect( + runProfileStateBackupWorker(job, { workerPath: failedWorker, timeoutMs: 500 }) + ).rejects.toThrow(error) + expect(existsSync(job.targetPath)).toBe(false) + rmSync(directory, { recursive: true }) + }) + + it('reports a missing bundle and leaves the primary untouched', async () => { + const { directory, job } = fixture() + const before = readFileSync(job.databasePath) + await expect( + runProfileStateBackupWorker(job, { workerPath: join(directory, 'missing.js') }) + ).rejects.toThrow() + expect(readFileSync(job.databasePath)).toEqual(before) + }) + + it('coalesces desktop work and drains a started backup before allowing quarantine', async () => { + const { directory, job } = fixture() + let started: () => void = () => {} + const beginning = new Promise((resolve) => { + started = resolve + }) + const dispatch = vi + .spyOn(backupWorker, 'runProfileStateBackup') + .mockImplementation((request) => { + started() + return runProfileStateBackupWorker(request, { workerPath }) + }) + const rotation = new ProfileStateBackupRotation(job.databasePath, job.profileId) + rotation.schedule() + rotation.schedule() + await beginning + rotation.stop() + expect(() => rotation.assertIdle()).toThrow('Flush pending') + await rotation.drain() + expect(() => rotation.assertIdle()).not.toThrow() + expect(dispatch).toHaveBeenCalledOnce() + expect(profileStateDatabaseBackups(job.databasePath)).toHaveLength(1) + rmSync(directory, { recursive: true }) + }) + + it('finds entries beside the launcher and above Rollup shared chunks', () => { + expect(resolveProfileStateBackupWorkerPath(workerDirectory)).toBe(workerPath) + expect(resolveProfileStateBackupWorkerPath(join(workerDirectory, 'chunks'))).toBe(workerPath) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.ts b/src/main/persistence/profile-state/profile-state-backup-worker.ts new file mode 100644 index 000000000000..8593d7dd496b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-worker.ts @@ -0,0 +1,64 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { + currentWorkerEntryLayout, + resolveWorkerThreadEntryPath +} from '../../worker-thread-entry-path' +import { type ProfileStateBackupJob, writeProfileStateBackup } from './profile-state-backup-job' +import { isRecord } from './profile-state-document-validation' + +const WORKER_FILENAME = 'profile-state-backup-worker-entry.js' +const BACKUP_TIMEOUT_MS = 10 * 60_000 + +export function resolveProfileStateBackupWorkerPath(moduleDir = __dirname): string { + const entry = resolveWorkerThreadEntryPath(currentWorkerEntryLayout(moduleDir), WORKER_FILENAME) + // Rollup can place this launcher in a shared chunk beside the worker entries. + return [entry, join(dirname(entry), '..', WORKER_FILENAME)].find(existsSync) ?? entry +} + +/** Desktop validation runs off the UI thread; plain-Node backups retain the native async path. */ +export function runProfileStateBackup(job: ProfileStateBackupJob): Promise { + return process.versions.electron ? runProfileStateBackupWorker(job) : writeProfileStateBackup(job) +} + +export function runProfileStateBackupWorker( + job: ProfileStateBackupJob, + options: { workerPath?: string; timeoutMs?: number } = {} +): Promise { + return new Promise((resolve, reject) => { + const workerPath = options.workerPath ?? resolveProfileStateBackupWorkerPath() + const worker = new Worker(workerPath, { workerData: job, execArgv: [] }) + let completed = false + let failure: Error | undefined + const timer = setTimeout(() => { + failure = new Error('Profile state backup worker timed out') + void worker.terminate().catch((error: unknown) => { + failure = error instanceof Error ? error : new Error(String(error)) + }) + }, options.timeoutMs ?? BACKUP_TIMEOUT_MS) + worker.on('message', (response: unknown) => { + if (!isRecord(response) || typeof response.ok !== 'boolean') { + failure = new Error('Invalid profile state backup worker response') + } else if (!response.ok) { + failure = new Error(String(response.error)) + } else { + completed = true + } + }) + worker.on('error', (error) => { + failure = error instanceof Error ? error : new Error(String(error)) + }) + // Even an error response leaves handles open until the worker actually exits. + worker.once('exit', (code) => { + clearTimeout(timer) + if (failure || code !== 0 || !completed) { + reject( + failure ?? new Error(`Profile state backup worker exited without completion (${code})`) + ) + } else { + resolve() + } + }) + }) +} diff --git a/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts b/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts new file mode 100644 index 000000000000..e182ae9d975b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts @@ -0,0 +1,91 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import type { ProfileStateAuthority } from '../loading-store/profile-state-authority' + +vi.mock('node:fs', async (original) => ({ ...(await original()) })) +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const roots: string[] = [] +const authorities: ProfileStateAuthority[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const authority of authorities.splice(0)) { + authority.close?.() + } + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +describe('first database publication with competing startup', () => { + it.each(['empty', 'legacy'])('cannot replace an acknowledged competing %s profile', (kind) => { + const root = fs.mkdtempSync(join(tmpdir(), 'orca-bootstrap-publication-race-')) + roots.push(root) + const options = { + dataFile: join(root, 'orca-data.json'), + databaseFile: join(root, 'profile-state.db'), + profileId: 'publication-race', + allowEmptyProfileState: true + } + if (kind === 'legacy') { + fs.writeFileSync(options.dataFile, '{"settings":{"theme":"dark"}}') + } + const committed = { + settings: { theme: 'light' }, + extension: { acknowledged: true, value: null } + } + let competing = false + const open = () => { + const result = bootstrapProfileStateAuthority(options) + if (result.authority) { + authorities.push(result.authority) + } + return result + } + const race = (target: fs.PathLike) => { + if (competing || target !== options.databaseFile) { + return + } + competing = true + const winner = open().authority + if (!winner) { + throw new Error('Competing startup did not establish SQLite') + } + winner.writeSerializedState(Buffer.from(JSON.stringify(committed))) + expect(JSON.parse(winner.readSerializedState() ?? 'null')).toEqual(committed) + winner.close?.() + authorities.splice(authorities.indexOf(winner), 1) + } + const rename = fs.renameSync + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + race(to) + rename(from, to) + }) + const link = fs.linkSync + vi.spyOn(fs, 'linkSync').mockImplementation((from, to) => { + race(to) + link(from, to) + }) + + let publicationError: unknown + try { + open() + } catch (error) { + publicationError = error + } + expect(competing).toBe(true) + expect(JSON.parse(open().authority?.readSerializedState() ?? 'null')).toEqual(committed) + expect(publicationError).toMatchObject({ message: expect.stringContaining('storage changed') }) + expect(fs.readdirSync(root).some((name) => name.endsWith('.tmp'))).toBe(false) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts b/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts new file mode 100644 index 000000000000..ba94e37b1519 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts @@ -0,0 +1,121 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + openProfileStateDatabaseReadOnly, + openProfileStateDatabase +} from './profile-state-database' +import { readProfileStateRevision } from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' + +const fixtures: { authority: ProfileStateSqliteAuthority; directory: string }[] = [] + +function fixture(established: boolean) { + const directory = mkdtempSync(join(tmpdir(), 'orca-complete-domain-write-')) + const databasePath = join(directory, 'state.db') + openProfileStateDatabase(databasePath, 'profile').db.close() + const authority = new ProfileStateSqliteAuthority(databasePath, 'profile') + fixtures.push({ authority, directory }) + if (established) { + authority.writeSerializedState( + Buffer.from('{"settings":{"theme":"light"},"automationRuns":[{"id":"old"}],"removeMe":true}') + ) + } + const readRevision = () => { + const { db } = openProfileStateDatabaseReadOnly(databasePath, 'profile') + try { + return readProfileStateRevision(db) + } finally { + db.close() + } + } + return { authority, databasePath, readRevision } +} + +afterEach(() => { + for (const { authority, directory } of fixtures.splice(0)) { + authority.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +const invalidReplacements = [ + { + name: 'sibling-key injection', + value: [{ domain: 'extension', payload: 'null,"settings":{"theme":"dark"}' }] + }, + { + name: 'duplicate domains', + value: [ + { domain: 'settings', payload: '{}' }, + { domain: 'settings', payload: 'null' } + ] + }, + { name: 'non-string payload', value: [{ domain: 'settings', payload: true }] }, + { name: 'missing payload', value: [{ domain: 'settings' }] }, + { name: 'empty domain', value: [{ domain: '', payload: '{}' }] }, + { name: 'non-array replacements', value: { settings: '{}' } } +] + +describe.each([false, true])('complete domain writes (established: %s)', (established) => { + it.each(invalidReplacements)('rejects $name without changing state or revision', ({ value }) => { + const { authority, readRevision } = fixture(established) + const before = authority.readSerializedState() + const revision = readRevision() + + expect(() => + Reflect.apply(authority.writeCompleteSerializedDomains, authority, [value]) + ).toThrow() + + expect(authority.readSerializedState()).toBe(before) + expect(readRevision()).toBe(revision) + }) + + it('preserves null, history order and unknown fields while deleting omitted domains', () => { + const { authority } = fixture(established) + const future = { '3': 3, '1': 1, unicode: '雪 🐋\ud800', nested: { z: null, a: [] } } + const runs = [{ id: 'z', extension: future }, { id: 'a' }] + + authority.writeCompleteSerializedDomains([ + { domain: 'settings', payload: 'null' }, + { domain: 'automationRuns', payload: JSON.stringify(runs) }, + { domain: 'future', payload: JSON.stringify(future) }, + { domain: 'deleted', payload: null } + ]) + + expect(authority.readSerializedState()).toBe( + JSON.stringify({ settings: null, automationRuns: runs, future }) + ) + authority.writeCompleteSerializedDomains([]) + expect(authority.readSerializedState()).toBe('{}') + }) + + it('accepts an empty complete profile', () => { + const { authority } = fixture(established) + + authority.writeCompleteSerializedDomains([]) + + expect(authority.readSerializedState()).toBe('{}') + }) +}) + +describe('complete domain revision fencing', () => { + it('rejects an older complete replacement after a concurrent writer commits', () => { + const { authority, databasePath } = fixture(true) + authority.readSerializedState() + const other = new ProfileStateSqliteAuthority(databasePath, 'profile') + try { + other.writeCompleteSerializedDomains([{ domain: 'settings', payload: '{"theme":"dark"}' }]) + } finally { + other.close() + } + + expect(() => + authority.writeCompleteSerializedDomains([ + { domain: 'settings', payload: '{"theme":"light"}' } + ]) + ).toThrow(expect.objectContaining({ code: 'profile-state-revision-conflict' })) + expect(authority.readSerializedState()).toBe('{"settings":{"theme":"dark"}}') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-crash-recovery.test.ts b/src/main/persistence/profile-state/profile-state-crash-recovery.test.ts new file mode 100644 index 000000000000..e9d6a54e0e58 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-crash-recovery.test.ts @@ -0,0 +1,210 @@ +import { spawnProcess } from '../../../shared/child-process/run-process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from '../profile-state-cutover-fixture' +import { + exportProfileStateJson, + importProfileStateJson, + readProfileStateRevision +} from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +const crashDuringWriteScript = ` + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const db = new DatabaseSync(process.argv[1]) + db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; BEGIN IMMEDIATE') + db.prepare('DELETE FROM profile_state_documents').run() + db.prepare('DELETE FROM profile_state_automation_runs').run() + db.prepare('DELETE FROM profile_state_automation_runs_meta').run() + db.prepare(\` + UPDATE profile_state_meta + SET value = ? + WHERE key = 'revision' + \`).run('999') + process.stdout.write('transaction-ready\\n') + setInterval(() => {}, 1_000) +` + +const crashAfterCommittedWriteScript = ` + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const { createHash } = require('node:crypto') + const db = new DatabaseSync(process.argv[1]) + const payload = '{"theme":"committed"}' + const hash = createHash('sha256').update(payload).digest('hex') + db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; BEGIN IMMEDIATE') + db.prepare('DELETE FROM profile_state_documents').run() + db.prepare('DELETE FROM profile_state_automation_runs').run() + db.prepare("UPDATE profile_state_automation_runs_meta SET presence = 'document', domain_version = 1, revision = 0, updated_at = 0, content_hash = ''").run() + db.prepare(\` + INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, ?, ?, ?, ?)\` + ).run('settings', payload, 1, 999, 999, hash) + db.prepare(\` + UPDATE profile_state_meta + SET value = ? + WHERE key = 'revision' + \`).run('999') + db.exec('COMMIT') + process.stdout.write('transaction-committed\\n') + setInterval(() => {}, 1_000) +` + +const crashDuringCheckpointScript = ` + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const dbPath = process.argv[1] + const writer = new DatabaseSync(dbPath, { timeout: 5_000 }) + const reader = new DatabaseSync(dbPath, { timeout: 5_000 }) + writer.exec('PRAGMA wal_autocheckpoint = 0') + writer.exec('BEGIN IMMEDIATE') + writer.prepare( + \`UPDATE profile_state_meta SET value = value WHERE key = 'revision'\` + ).run() + writer.exec('COMMIT') + reader.exec('BEGIN') + reader.prepare("SELECT value FROM profile_state_meta WHERE key = 'revision'").get() + // SQLITE_PRAGMA is action code 19; the reader keeps TRUNCATE checkpointing active after this callback returns. + writer.setAuthorizer((action) => { + if (action === 19) { + process.stdout.write('checkpoint-started\\n') + } + return 0 + }) + writer.prepare('PRAGMA wal_checkpoint(TRUNCATE)').all() + process.stdout.write('checkpoint-complete\\n') +` + +async function killAfterChildReady(dbPath: string, script: string, marker: string): Promise { + const child = spawnProcess({ + program: process.execPath, + args: ['-e', script, dbPath], + timeoutMs: null + }) + for (const stream of [child.stdin, child.stdout, child.stderr]) { + stream?.on('error', () => {}) + } + + try { + await new Promise((resolve, reject) => { + let output = '' + const onData = (chunk: Buffer | string): void => { + output += String(chunk) + if (output.includes(marker)) { + resolve() + } + } + child.stdout.on('data', onData) + child.once('error', reject) + }) + child.kill('SIGKILL') + await new Promise((resolve, reject) => { + child.once('close', () => resolve()) + child.once('error', reject) + }) + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + } + } +} + +async function killAfterUncommittedWrite(dbPath: string): Promise { + await killAfterChildReady(dbPath, crashDuringWriteScript, 'transaction-ready') +} + +describe('profile state crash recovery', () => { + it('rolls back an uncommitted SQLite write and accepts the next import', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-crash-')) + temporaryDirectories.push(directory) + const dbPath = profileStateDatabaseFile(directory) + const fixture = buildProfileStateCutoverFixture() + + const initial = openProfileStateDatabase(dbPath, 'profile-a') + importProfileStateJson(initial.db, JSON.stringify(fixture), { now: () => 100 }) + initial.db.close() + + await killAfterUncommittedWrite(dbPath) + + const recovered = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(recovered.readOnly).toBe(false) + expect(readProfileStateRevision(recovered.db)).toBe(1) + expect(canonicalProfileStateJson(JSON.parse(exportProfileStateJson(recovered.db)))).toBe( + canonicalProfileStateJson(fixture) + ) + + const replacement = { + ...fixture, + futureTopLevelExtension: { keep: 'replacement', nullable: null } + } + expect( + importProfileStateJson(recovered.db, JSON.stringify(replacement), { now: () => 200 }) + ).toBe(2) + expect(canonicalProfileStateJson(JSON.parse(exportProfileStateJson(recovered.db)))).toBe( + canonicalProfileStateJson(replacement) + ) + } finally { + recovered.db.close() + } + }) + + it('preserves a committed SQLite write after process termination', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-crash-')) + temporaryDirectories.push(directory) + const dbPath = profileStateDatabaseFile(directory) + const initial = openProfileStateDatabase(dbPath, 'profile-a') + importProfileStateJson(initial.db, JSON.stringify(buildProfileStateCutoverFixture()), { + now: () => 100 + }) + initial.db.close() + + await killAfterChildReady(dbPath, crashAfterCommittedWriteScript, 'transaction-committed') + + const recovered = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(recovered.readOnly).toBe(false) + expect(readProfileStateRevision(recovered.db)).toBe(999) + expect(JSON.parse(exportProfileStateJson(recovered.db))).toEqual({ + settings: { theme: 'committed' } + }) + } finally { + recovered.db.close() + } + }) + + it('recovers a committed database when checkpointing is interrupted', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-checkpoint-crash-')) + temporaryDirectories.push(directory) + const dbPath = profileStateDatabaseFile(directory) + const fixture = buildProfileStateCutoverFixture() + const initial = openProfileStateDatabase(dbPath, 'profile-a') + importProfileStateJson(initial.db, JSON.stringify(fixture), { now: () => 100 }) + initial.db.close() + + await killAfterChildReady(dbPath, crashDuringCheckpointScript, 'checkpoint-started') + + const recovered = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(recovered.readOnly).toBe(false) + expect(readProfileStateRevision(recovered.db)).toBe(1) + expect(canonicalProfileStateJson(JSON.parse(exportProfileStateJson(recovered.db)))).toBe( + canonicalProfileStateJson(fixture) + ) + } finally { + recovered.db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-errors.ts b/src/main/persistence/profile-state/profile-state-database-errors.ts new file mode 100644 index 000000000000..2123147c5a8b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-errors.ts @@ -0,0 +1,16 @@ +export type ProfileStateDatabaseOpenErrorCode = + | 'unreadable' + | 'identity-mismatch' + | 'invalid-profile-id' + +export class ProfileStateDatabaseOpenError extends Error { + readonly code: ProfileStateDatabaseOpenErrorCode + readonly cause: unknown + + constructor(code: ProfileStateDatabaseOpenErrorCode, message: string, cause?: unknown) { + super(message) + this.name = 'ProfileStateDatabaseOpenError' + this.code = code + this.cause = cause + } +} diff --git a/src/main/persistence/profile-state/profile-state-database-export-crash.test.ts b/src/main/persistence/profile-state/profile-state-database-export-crash.test.ts new file mode 100644 index 000000000000..4f82cd61a465 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-export-crash.test.ts @@ -0,0 +1,112 @@ +import { spawnProcess } from '../../../shared/child-process/run-process' +import { mkdirSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { build } from 'esbuild' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { importProfileStateJson } from './profile-state-documents' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile +} from './profile-state-database' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +const interruptedExportScript = ` + import { openProfileStateDatabaseReadOnly } from './profile-state-database' + import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' + const { db } = openProfileStateDatabaseReadOnly(process.argv[2], 'profile-a') + writeProfileStateDatabaseSnapshotAsync(db, process.argv[3], { + validateStagedSnapshot: async (path) => { + process.stdout.write(path + '\\n') + await new Promise(() => setInterval(() => {}, 1_000)) + } + }).catch((error) => { console.error(error); process.exit(1) }) +` + +async function killBeforePublication(sourcePath: string, targetPath: string): Promise { + const childEntry = join(dirname(sourcePath), 'interrupted-export.cjs') + await build({ + stdin: { + contents: interruptedExportScript, + resolveDir: resolve('src/main/persistence/profile-state'), + loader: 'ts' + }, + outfile: childEntry, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) + const child = spawnProcess({ + program: process.execPath, + args: [childEntry, sourcePath, targetPath], + timeoutMs: 10_000 + }) + for (const stream of [child.stdin, child.stdout, child.stderr]) { + stream?.on('error', () => {}) + } + const exited = new Promise((resolve, reject) => { + child.once('close', () => resolve()) + child.once('error', reject) + }) + const temporaryPath = await new Promise((resolve, reject) => { + let output = '' + child.stdout.on('data', (chunk: Buffer | string) => { + output += String(chunk) + if (output.includes('\n')) { + resolve(output.trim()) + } + }) + child.once('close', () => reject(new Error('Export exited before staging completed'))) + child.once('error', reject) + }) + child.kill('SIGKILL') + await exited + return temporaryPath +} + +describe('profile state database export crash recovery', () => { + it('leaves the destination intact when the production backup dies before publication', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-export-crash-')) + temporaryDirectories.push(directory) + const sourcePath = profileStateDatabaseFile(directory) + const source = openProfileStateDatabase(sourcePath, 'profile-a') + importProfileStateJson(source.db, JSON.stringify({ settings: { theme: 'dark' } }), { + now: () => 100 + }) + source.db.close() + + const targetPath = join(directory, 'recovery', 'profile-state.db') + mkdirSync(join(directory, 'recovery'), { recursive: true }) + writeFileSync(targetPath, 'known-good-destination') + const interruptedPath = await killBeforePublication(sourcePath, targetPath) + + expect(readFileSync(targetPath, 'utf8')).toBe('known-good-destination') + expect(existsSync(interruptedPath)).toBe(true) + rmSync(interruptedPath, { force: true }) + + const recoveredSource = openProfileStateDatabase(sourcePath, 'profile-a') + try { + await writeProfileStateDatabaseSnapshotAsync(recoveredSource.db, targetPath) + } finally { + recoveredSource.db.close() + } + const snapshot = openProfileStateDatabaseReadOnly(targetPath, 'profile-a') + try { + expect( + snapshot.db.prepare('SELECT value FROM profile_state_meta WHERE key = ?').get('revision') + ).toEqual({ value: '1' }) + } finally { + snapshot.db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-quarantine.ts b/src/main/persistence/profile-state/profile-state-database-quarantine.ts new file mode 100644 index 000000000000..b59c7a1c37e5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-quarantine.ts @@ -0,0 +1,79 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { randomUUID } from 'node:crypto' +import { copyFileSync, existsSync, mkdirSync, rmSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' + +export type ProfileStateDatabaseQuarantine = { + directory: string + manifestPath: string + copiedFiles: readonly string[] +} + +/** + * Preserve a damaged profile database family before a caller attempts repair or fallback. + * Originals remain in place so this operation cannot turn a recovery failure into data loss. + */ +export function quarantineProfileStateDatabase( + databasePath: string, + profileId: string, + quarantineRoot = dirname(databasePath), + reason = 'profile-state-database-recovery', + recoveryFiles: readonly string[] = [] +): ProfileStateDatabaseQuarantine { + if (databasePath.length === 0 || databasePath.includes('\0') || profileId.length === 0) { + throw new Error('Profile state quarantine arguments are invalid') + } + const sourceFiles = profileStateDatabaseFiles(databasePath).filter(existsSync) + if (sourceFiles.length === 0 && recoveryFiles.length === 0) { + throw new Error('Profile state database family does not exist') + } + + const directory = join(quarantineRoot, `profile-state-corrupt-${Date.now()}-${randomUUID()}`) + const copiedFiles: string[] = [] + mkdirSync(directory, { recursive: true, mode: 0o700 }) + try { + for (const sourcePath of sourceFiles) { + const targetName = + sourcePath === databasePath + ? 'profile-state.db' + : `profile-state.db${sourcePath.slice(databasePath.length)}` + const targetPath = join(directory, targetName) + copyFileSync(sourcePath, targetPath) + hardenSqliteDatabaseFiles(targetPath) + fsyncFileSync(targetPath) + copiedFiles.push(targetPath) + } + for (const sourcePath of new Set(recoveryFiles)) { + const targetPath = join(directory, basename(sourcePath)) + if (existsSync(targetPath) || basename(sourcePath) === 'manifest.json') { + throw new Error('Profile recovery artifact name conflicts with the quarantine manifest') + } + copyFileSync(sourcePath, targetPath) + hardenSqliteDatabaseFiles(targetPath) + fsyncFileSync(targetPath) + copiedFiles.push(targetPath) + } + hardenSqliteDatabaseFiles(join(directory, 'profile-state.db')) + const manifestPath = join(directory, 'manifest.json') + writeFileDurableSync( + durableWriteTempPath(manifestPath), + manifestPath, + JSON.stringify({ + schemaVersion: 1, + profileId, + reason, + capturedAt: new Date().toISOString(), + sourceFiles: sourceFiles.map((sourcePath) => sourcePath.slice(databasePath.length)), + recoveryFiles: [...new Set(recoveryFiles)].map((sourcePath) => basename(sourcePath)) + }) + ) + bestEffortFsyncDirectorySync(directory) + return { directory, manifestPath, copiedFiles } + } catch (error) { + rmSync(directory, { recursive: true, force: true }) + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-database-recovery.test.ts b/src/main/persistence/profile-state/profile-state-database-recovery.test.ts new file mode 100644 index 000000000000..188442525c5a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-recovery.test.ts @@ -0,0 +1,275 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Database from '../../sqlite/sync-database' +import * as durableFileWrite from '../../durable-file-write' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { exportProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import { restoreProfileStateJsonExport } from './profile-state-recovery' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { profileStateJsonExportPath } from './profile-state-export-path' + +const directories: string[] = [] +const profileId = 'profile-recovery-test' +const savedJson = JSON.stringify({ + settings: { theme: 'dark', httpProxyUrl: 'safe-storage-sealed-ciphertext' }, + ui: { unknownField: { keep: true } }, + opaqueExtension: { sequence: 71 } +}) + +afterEach(() => { + vi.restoreAllMocks() + for (const path of directories.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +async function fixture(options: { profileId?: string; empty?: boolean } = {}) { + const root = mkdtempSync(join(tmpdir(), 'orca-database-recovery-')) + directories.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + const maintenance = acquireProfileStateMaintenance(root) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const exportPath = profileStateJsonExportPath(dataFile, 1) + const backupPath = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + const source = openProfileStateDatabase( + join(directory, 'source.db'), + options.profileId ?? profileId + ) + try { + if (!options.empty) { + importProfileStateJson(source.db, savedJson) + } + await writeProfileStateDatabaseSnapshotAsync(source.db, backupPath) + } finally { + source.db.close() + } + writeFileSync(dataFile, '{"settings":{"theme":"stale"}}') + writeFileSync(exportPath, '{"settings":{"theme":"migration"}}') + for (const suffix of ['', '-wal', '-shm', '-journal']) { + writeFileSync(`${databasePath}${suffix}`, `damaged ${suffix || 'primary'}`) + } + return { databasePath, dataFile, backupPath, exportPath, profileId, maintenance } +} + +function readRestored(databasePath: string): string { + const opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + return exportProfileStateJson(opened.db) + } finally { + opened.db.close() + } +} + +function expectOriginals(options: Awaited>): void { + expect(readFileSync(options.databasePath, 'utf8')).toBe('damaged primary') + expect(readFileSync(options.dataFile, 'utf8')).toContain('stale') + expect(existsSync(options.backupPath)).toBe(true) + expect(readdirSync(dirname(options.databasePath)).some((name) => name.endsWith('.tmp'))).toBe( + false + ) +} + +describe('profile state database backup recovery', () => { + it.each([true, false])( + 'restores SQLite authority with the old database present=%s', + async (hasDatabase) => { + const options = await fixture() + const backupBytes = readFileSync(options.backupPath) + if (!hasDatabase) { + for (const suffix of ['', '-wal', '-shm', '-journal']) { + rmSync(`${options.databasePath}${suffix}`) + } + } + const beforeRestore = vi.fn() + + const result = restoreProfileStateDatabaseBackup({ ...options, beforeRestore }) + + expect(result.revision).toBe(1) + expect(beforeRestore).toHaveBeenCalledOnce() + expect(readRestored(options.databasePath)).toBe(savedJson) + expect(existsSync(options.dataFile)).toBe(false) + expect(readFileSync(options.backupPath)).toEqual(backupBytes) + expect(existsSync(options.exportPath)).toBe(false) + expect(readFileSync(join(result.quarantine.directory, basename(options.backupPath)))).toEqual( + backupBytes + ) + expect( + readFileSync(join(result.quarantine.directory, basename(options.dataFile)), 'utf8') + ).toContain('stale') + expect(existsSync(join(result.quarantine.directory, basename(options.exportPath)))).toBe(true) + if (hasDatabase) { + for (const suffix of ['', '-wal', '-shm', '-journal']) { + expect( + readFileSync(join(result.quarantine.directory, `profile-state.db${suffix}`), 'utf8') + ).toBe(`damaged ${suffix || 'primary'}`) + } + } + for (const suffix of ['-wal', '-shm', '-journal']) { + expect(existsSync(`${options.databasePath}${suffix}`)).toBe(false) + } + } + ) + + it.each(['foreign identity', 'empty profile'])( + 'rejects a %s backup before touching recovery state', + async (kind) => { + const options = await fixture( + kind === 'foreign identity' ? { profileId: 'other-profile' } : { empty: true } + ) + const beforeRestore = vi.fn() + expect(() => restoreProfileStateDatabaseBackup({ ...options, beforeRestore })).toThrow() + expect(beforeRestore).not.toHaveBeenCalled() + expectOriginals(options) + } + ) + + it.each(['corrupt hash', 'future schema', 'WAL mode'])( + 'rejects a backup with %s', + async (kind) => { + const options = await fixture() + const backup = new Database(options.backupPath) + try { + if (kind === 'corrupt hash') { + backup.exec("UPDATE profile_state_documents SET payload = '{}' WHERE domain = 'settings'") + } + if (kind === 'future schema') { + backup.pragma('user_version = 999') + } + if (kind === 'WAL mode') { + backup.pragma('journal_mode = WAL') + } + } finally { + backup.close() + } + const beforeRestore = vi.fn() + expect(() => restoreProfileStateDatabaseBackup({ ...options, beforeRestore })).toThrow() + expect(beforeRestore).not.toHaveBeenCalled() + expectOriginals(options) + } + ) + + it.each(['-wal', '-shm', '-journal'])( + 'rejects a selected backup with a %s sidecar', + async (suffix) => { + const options = await fixture() + writeFileSync(`${options.backupPath}${suffix}`, 'external writer evidence') + expect(() => restoreProfileStateDatabaseBackup(options)).toThrow('not self-contained') + expectOriginals(options) + } + ) + + it.skipIf(process.platform === 'win32')( + 'rejects a reserved-name symlink to a valid snapshot', + async () => { + const options = await fixture() + const alias = profileStateDatabaseBackupPath( + options.databasePath, + createProfileStateDatabaseBackupId() + ) + symlinkSync(options.backupPath, alias) + expect(() => restoreProfileStateDatabaseBackup({ ...options, backupPath: alias })).toThrow( + 'regular file' + ) + expectOriginals(options) + } + ) + + it('refuses a backup path outside the retained profile inventory', async () => { + const options = await fixture() + expect(() => + restoreProfileStateDatabaseBackup({ ...options, backupPath: options.databasePath }) + ).toThrow('not retained') + expectOriginals(options) + }) + + it('keeps live state untouched when an older artifact cannot be archived', async () => { + const options = await fixture() + mkdirSync(profileStateJsonExportPath(options.dataFile, 2)) + expect(() => restoreProfileStateDatabaseBackup(options)).toThrow() + expectOriginals(options) + }) + + it('keeps live state untouched when the pre-restore cache invalidation fails', async () => { + const options = await fixture() + expect(() => + restoreProfileStateDatabaseBackup({ + ...options, + beforeRestore: () => { + throw new Error('injected pre-restore failure') + } + }) + ).toThrow('injected pre-restore failure') + expectOriginals(options) + }) + + it('preserves recoverable evidence when publication fails after removing a damaged family', async () => { + const options = await fixture() + const renameDurableSync = durableFileWrite.renameDurableSync + vi.spyOn(durableFileWrite, 'renameDurableSync').mockImplementation((source, target) => { + if (target === options.databasePath) { + throw new Error('injected snapshot publication failure') + } + return renameDurableSync(source, target) + }) + expect(() => restoreProfileStateDatabaseBackup(options)).toThrow( + 'injected snapshot publication failure' + ) + expect(existsSync(options.databasePath)).toBe(false) + expect(existsSync(options.backupPath)).toBe(true) + const archives = readdirSync(dirname(options.databasePath)).filter((name) => + name.startsWith('profile-state-corrupt-') + ) + expect(archives).toHaveLength(1) + expect( + readFileSync(join(dirname(options.databasePath), archives[0], 'profile-state.db-wal'), 'utf8') + ).toBe('damaged -wal') + vi.restoreAllMocks() + options.maintenance.release() + options.maintenance = acquireProfileStateMaintenance( + dirname(dirname(dirname(options.dataFile))) + ) + restoreProfileStateDatabaseBackup(options) + expect(readRestored(options.databasePath)).toBe(savedJson) + }) + + it('archives and removes SQLite backups and sidecars when explicitly rolling back to JSON', async () => { + const options = await fixture() + writeFileSync(`${options.backupPath}-journal`, 'backup recovery evidence') + const recovered = restoreProfileStateJsonExport(options) + expect(existsSync(options.databasePath)).toBe(false) + expect(existsSync(options.backupPath)).toBe(false) + expect(existsSync(`${options.backupPath}-journal`)).toBe(false) + expect( + readFileSync( + join(recovered.quarantine.directory, `${basename(options.backupPath)}-journal`), + 'utf8' + ) + ).toBe('backup recovery evidence') + expect(readFileSync(options.dataFile, 'utf8')).toContain('migration') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-recovery.ts b/src/main/persistence/profile-state/profile-state-database-recovery.ts new file mode 100644 index 000000000000..d2beb1370434 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-recovery.ts @@ -0,0 +1,112 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { constants, copyFileSync, existsSync, lstatSync, mkdirSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import { durableWriteTempPath, renameDurableSync } from '../../durable-file-write' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { readProfileStateSnapshot } from './profile-state-documents' +import { + profileStateDatabaseBackups, + profileStateDatabaseBackupFiles +} from './profile-state-backup-path' +import { profileStateJsonExportPaths } from './profile-state-export-path' +import { assertProfileStateMaintenance, type ProfileStateMaintenance } from './profile-state-access' +import { + quarantineProfileStateDatabase, + type ProfileStateDatabaseQuarantine +} from './profile-state-database-quarantine' + +export type ProfileStateDatabaseRecoveryOptions = { + maintenance: ProfileStateMaintenance + databasePath: string + dataFile: string + backupPath: string + profileId: string + quarantineRoot?: string + reason?: string + beforeRestore?: () => void +} + +export type ProfileStateDatabaseRecovery = { + revision: number + quarantine: ProfileStateDatabaseQuarantine + removedDatabaseFiles: readonly string[] +} + +/** Replace the database family only while startup and offline access are excluded. */ +export function restoreProfileStateDatabaseBackup( + options: ProfileStateDatabaseRecoveryOptions +): ProfileStateDatabaseRecovery { + assertProfileStateMaintenance(options.maintenance, options) + const backups = profileStateDatabaseBackups(options.databasePath) + if (!backups.some((backup) => backup.path === options.backupPath)) { + throw new Error('Selected profile state database backup is not retained by this profile') + } + if (!lstatSync(options.backupPath).isFile()) { + throw new Error('Profile state database backup must be a regular file') + } + if (['-wal', '-shm', '-journal'].some((suffix) => existsSync(`${options.backupPath}${suffix}`))) { + throw new Error('Profile state database backup has sidecars and is not self-contained') + } + + mkdirSync(dirname(options.databasePath), { recursive: true }) + const stagingPath = durableWriteTempPath(options.databasePath) + try { + copyFileSync(options.backupPath, stagingPath, constants.COPYFILE_EXCL) + hardenSqliteDatabaseFiles(stagingPath) + const revision = validateRecoverySnapshot(stagingPath, options.profileId) + fsyncFileSync(stagingPath) + const recoveryFiles = [ + ...profileStateDatabaseBackupFiles(options.databasePath), + ...profileStateJsonExportPaths(options.dataFile), + ...(existsSync(options.dataFile) ? [options.dataFile] : []) + ] + const quarantine = quarantineProfileStateDatabase( + options.databasePath, + options.profileId, + options.quarantineRoot, + options.reason ?? 'profile-state-database-rollback', + recoveryFiles + ) + options.beforeRestore?.() + + // JSON exports are revisioned for the legacy authority. Remove them after + // archiving so a later SQLite revision can publish a fresh export at the + // same number without colliding with an older divergent payload. + const retainedJsonExports = profileStateJsonExportPaths(options.dataFile) + for (const exportPath of retainedJsonExports) { + rmSync(exportPath) + } + + const removedDatabaseFiles = profileStateDatabaseFiles(options.databasePath).filter(existsSync) + // Remove the primary first: interruption must fail closed on retained backups, never replay old WAL. + for (const path of removedDatabaseFiles) { + rmSync(path) + } + rmSync(options.dataFile, { force: true }) + bestEffortFsyncDirectorySync(dirname(options.databasePath)) + renameDurableSync(stagingPath, options.databasePath) + return { revision, quarantine, removedDatabaseFiles } + } finally { + for (const path of profileStateDatabaseFiles(stagingPath)) { + rmSync(path, { force: true }) + } + } +} + +function validateRecoverySnapshot(path: string, profileId: string): number { + const opened = openProfileStateDatabaseReadOnly(path, profileId) + try { + if (opened.db.pragma('journal_mode', { simple: true }) !== 'delete') { + throw new Error('Profile state database backup must use a self-contained journal mode') + } + const snapshot = readProfileStateSnapshot(opened.db) + if (snapshot.revision === 0) { + throw new Error('Profile state database backup contains no committed profile state') + } + return snapshot.revision + } finally { + opened.db.close() + } +} diff --git a/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts b/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts new file mode 100644 index 000000000000..e4b057edd3f5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts @@ -0,0 +1,157 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { Store } from '../loading-store/store' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { profileStateDatabaseBackups } from './profile-state-backup-path' +import { profileStateJsonExportPath } from './profile-state-export-path' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { restoreProfileStateJsonExport } from './profile-state-recovery' +import { openProfileStateDatabase } from './profile-state-database' +import { + bootstrapProfileStateAuthority, + ProfileStateAuthorityBootstrapError +} from './profile-state-authority-bootstrap' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`), + decryptString: (value: Buffer) => value.toString().slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +it('can publish an updater JSON export at a reused revision after SQLite rollback', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-database-rollback-export-')) + const directory = join(root, 'profiles', 'rollback-export') + mkdirSync(directory, { recursive: true }) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const profileId = 'rollback-export' + const stores: Store[] = [] + try { + const original = new Store({ + dataFile, + profileStateAuthority: new ProfileStateSqliteAuthority(databasePath, profileId) + }) + stores.push(original) + original.updateSettings({ theme: 'light' }) + await original.flushPendingOrThrowAsync() + const backup = profileStateDatabaseBackups(databasePath)[0] + expect(backup).toBeDefined() + original.updateSettings({ theme: 'dark' }) + const formerRevision = original.writeLatestProfileStateJsonExport() + expect(formerRevision).toBeTypeOf('number') + if (!backup || formerRevision === undefined) { + throw new Error('Missing recovery fixture') + } + const exportPath = profileStateJsonExportPath(dataFile, formerRevision) + const previousExport = readFileSync(exportPath) + original.freezeWrites() + await original.flushAsync() + + const restored = restoreProfileStateDatabaseBackup({ + maintenance: acquireProfileStateMaintenance(root), + databasePath, + dataFile, + profileId, + backupPath: backup.path + }) + expect(existsSync(exportPath)).toBe(false) + expect(readFileSync(join(restored.quarantine.directory, basename(exportPath)))).toEqual( + previousExport + ) + const recovered = new Store({ + dataFile, + profileStateAuthority: new ProfileStateSqliteAuthority(databasePath, profileId) + }) + stores.push(recovered) + recovered.updateSettings({ theme: 'system' }) + const newRevision = recovered.writeLatestProfileStateJsonExport() + + expect(newRevision).toBe(formerRevision) + expect(JSON.parse(readFileSync(exportPath, 'utf8')).settings.theme).toBe('system') + expect(readFileSync(exportPath)).not.toEqual(previousExport) + } finally { + for (const store of stores) { + store.freezeWrites() + await store.flushAsync() + } + rmSync(root, { recursive: true, force: true }) + } +}) + +it('leaves an explicit rollback path if compatibility publication fails before acceptance', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-compat-failure-')) + const directory = join(root, 'profiles', 'profile-authority-test') + mkdirSync(directory, { recursive: true }) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'light' } }), 'utf8') + + const authority = new ProfileStateSqliteAuthority(databasePath, 'profile-authority-test') + const store = new Store({ dataFile, profileStateAuthority: authority }) + try { + store.updateSettings({ theme: 'dark' }) + store.flushOrThrow() + const revisionOne = store.writeLatestProfileStateJsonExport() + expect(revisionOne).toBe(1) + + const opened = openProfileStateDatabase(databasePath, 'profile-authority-test') + opened.db.exec( + `CREATE TRIGGER fail_compatibility_acceptance + BEFORE INSERT ON profile_state_meta + WHEN NEW.key = 'legacy_json_acceptance' + BEGIN SELECT RAISE(ABORT, 'injected compatibility failure'); END` + ) + opened.db.close() + + store.updateSettings({ theme: 'light' }) + expect(() => store.writeLatestProfileStateJsonCompatibilityExport()).toThrow( + 'injected compatibility failure' + ) + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') + expect(() => + bootstrapProfileStateAuthority({ + dataFile, + databaseFile: databasePath, + profileId: 'profile-authority-test' + }) + ).toThrow(ProfileStateAuthorityBootstrapError) + + store.freezeWrites() + await store.flushAsync() + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(root), + databasePath, + dataFile, + exportPath: profileStateJsonExportPath(dataFile, revisionOne ?? 1), + profileId: 'profile-authority-test' + }) + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('dark') + } finally { + store.freezeWrites() + await store.flushAsync() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/persistence/profile-state/profile-state-database-schema.ts b/src/main/persistence/profile-state/profile-state-database-schema.ts new file mode 100644 index 000000000000..84b63c17a2d8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-schema.ts @@ -0,0 +1,23 @@ +// Schema 3 requires explicit automation storage metadata even when history is empty. + +import { createProfileStateAutomationRunsTablesSql } from './profile-state-automation-runs' + +export const PROFILE_STATE_DATABASE_SCHEMA_VERSION = 3 +export const PROFILE_STATE_DOCUMENT_VERSION = 1 + +export const PROFILE_STATE_META_PROFILE_ID = 'profile_id' +export const PROFILE_STATE_META_REVISION = 'revision' +/** Records the legacy JSON bytes accepted by the SQLite authority bootstrap. */ +export const PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE = 'legacy_json_acceptance' + +export function createProfileStateTablesSql(): string { + return `CREATE TABLE IF NOT EXISTS profile_state_meta ( + key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL + ); + ${createProfileStateAutomationRunsTablesSql()}` +} diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts new file mode 100644 index 000000000000..77218e392caf --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts @@ -0,0 +1,314 @@ +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile +} from './profile-state-database' +import { exportProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import type Database from '../../sqlite/sync-database' +import * as durableFileWrite from '../../durable-file-write' +import * as fsPromises from 'node:fs/promises' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual } +}) + +const directories: string[] = [] +const databases: Database.Database[] = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const db of databases.splice(0)) { + db.close() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-async-snapshot-')) + directories.push(directory) + const databasePath = profileStateDatabaseFile(directory) + const { db } = openProfileStateDatabase(databasePath, 'profile-a') + databases.push(db) + const originalJson = JSON.stringify({ settings: { theme: 'light' } }) + importProfileStateJson(db, originalJson) + return { directory, databasePath, db, targetPath: join(directory, 'snapshot.db'), originalJson } +} + +function readSnapshot(path: string): string { + const { db } = openProfileStateDatabaseReadOnly(path, 'profile-a') + try { + return exportProfileStateJson(db) + } finally { + db.close() + } +} + +function expectNoTemporaryFiles(directory: string): void { + expect(readdirSync(directory).filter((name) => name.includes('.tmp'))).toEqual([]) +} + +describe('asynchronous profile-state database snapshots', () => { + it('publishes a hardened self-contained snapshot including committed WAL pages', async () => { + const { directory, databasePath, db, targetPath, originalJson } = fixture() + expect(existsSync(`${databasePath}-wal`)).toBe(true) + const sourceFile = readFileSync(databasePath) + + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + + expect(readSnapshot(targetPath)).toBe(originalJson) + expect(existsSync(`${targetPath}-wal`)).toBe(false) + expect(existsSync(`${targetPath}-shm`)).toBe(false) + if (process.platform !== 'win32') { + expect(statSync(targetPath).mode & 0o777).toBe(0o600) + } + expect(exportProfileStateJson(db)).toBe(originalJson) + expect(db.pragma('journal_mode', { simple: true })).toBe('wal') + expect(readFileSync(databasePath)).toEqual(sourceFile) + importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) + expect(readSnapshot(targetPath)).toBe(originalJson) + expectNoTemporaryFiles(directory) + }) + + it.each(['same connection', 'another connection'] as const)( + 'keeps a consistent complete revision while writes occur from %s', + async (connection) => { + const { directory, databasePath, db, targetPath } = fixture() + const writer = + connection === 'same connection' + ? db + : openProfileStateDatabase(databasePath, 'profile-a').db + if (writer !== db) { + databases.push(writer) + } + const padding = 'x'.repeat(256_000) + importProfileStateJson( + db, + JSON.stringify({ settings: { value: 1 }, ui: { value: 1 }, padding }) + ) + const nextJson = JSON.stringify({ settings: { value: 2 }, ui: { value: 2 }, padding }) + const nativeBackup = db.backup.bind(db) + let wroteDuringBackup = false + vi.spyOn(db, 'backup').mockImplementation((path) => + nativeBackup(path, { + rate: 1, + progress: ({ remainingPages }) => { + if (remainingPages > 0 && !wroteDuringBackup) { + wroteDuringBackup = true + importProfileStateJson(writer, nextJson) + } + } + }) + ) + + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + + expect(wroteDuringBackup).toBe(true) + expect(readSnapshot(targetPath)).toBe(nextJson) + expect(exportProfileStateJson(db)).toBe(nextJson) + expectNoTemporaryFiles(directory) + } + ) + + it('preserves the previous destination and removes staging after native backup fails', async () => { + const { directory, db, targetPath, originalJson } = fixture() + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + const previous = readFileSync(targetPath) + vi.spyOn(db, 'backup').mockImplementation(async (path) => { + writeFileSync(path, 'incomplete backup') + writeFileSync(`${path}-journal`, 'incomplete journal') + throw new Error('injected native backup failure') + }) + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'injected native backup failure' + ) + + expect(readFileSync(targetPath)).toEqual(previous) + expect(exportProfileStateJson(db)).toBe(originalJson) + expectNoTemporaryFiles(directory) + }) + + it('validates staged content before replacing the previous destination', async () => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + + await expect( + writeProfileStateDatabaseSnapshotAsync(db, targetPath, { + validateStagedSnapshot: () => { + throw new Error('staged validation failed') + } + }) + ).rejects.toThrow('staged validation failed') + + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expectNoTemporaryFiles(directory) + }) + + it('preserves the previous destination when publication fails', async () => { + const { directory, db, targetPath } = fixture() + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + const previous = readFileSync(targetPath) + importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) + vi.spyOn(durableFileWrite, 'renameDurable').mockRejectedValue( + new Error('injected rename failure') + ) + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'injected rename failure' + ) + + expect(readFileSync(targetPath)).toEqual(previous) + expectNoTemporaryFiles(directory) + }) + + it('rejects active transactions without publishing uncommitted state', async () => { + const { directory, db, targetPath, originalJson } = fixture() + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + const previous = readFileSync(targetPath) + db.exec('BEGIN IMMEDIATE') + try { + db.exec("UPDATE profile_state_documents SET payload = '{}' WHERE domain = 'settings'") + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'idle database connection' + ) + } finally { + db.exec('ROLLBACK') + } + + expect(readFileSync(targetPath)).toEqual(previous) + expect(exportProfileStateJson(db)).toBe(originalJson) + expectNoTemporaryFiles(directory) + }) + + it('checks again if a transaction starts while the backup is staging', async () => { + const { directory, db, targetPath } = fixture() + const nativeBackup = db.backup.bind(db) + vi.spyOn(db, 'backup').mockImplementation((path) => { + db.exec('BEGIN IMMEDIATE') + return nativeBackup(path) + }) + try { + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'idle database connection' + ) + } finally { + db.exec('ROLLBACK') + } + expect(existsSync(targetPath)).toBe(false) + expectNoTemporaryFiles(directory) + }) + + it('leaves the previous destination intact when the staged file cannot be fsynced', async () => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + const nativeOpen = fsPromises.open + vi.spyOn(fsPromises, 'open').mockImplementation(async (...args) => { + const file = await nativeOpen(...args) + if (args[1] === 'r+') { + vi.spyOn(file, 'sync').mockRejectedValue(new Error('injected fsync failure')) + } + return file + }) + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'injected fsync failure' + ) + + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expectNoTemporaryFiles(directory) + }) + + it('fails clearly when native backup is unsupported without replacing the destination', async () => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + const getBuiltinModule = process.getBuiltinModule.bind(process) + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => + id === 'node:sqlite' ? {} : getBuiltinModule(id) + ) + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'Asynchronous SQLite backup is unavailable' + ) + + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expectNoTemporaryFiles(directory) + }) + + it.each(['', 'invalid\0path'])('rejects the invalid target %j', async (path) => { + const { db } = fixture() + await expect(writeProfileStateDatabaseSnapshotAsync(db, path)).rejects.toThrow( + 'snapshot path is invalid' + ) + }) + + it.each(['', '-wal', '-shm', '-journal'])( + 'refuses to replace the source database%s', + async (suffix) => { + const { databasePath, db, originalJson } = fixture() + await expect( + writeProfileStateDatabaseSnapshotAsync(db, `${databasePath}${suffix}`) + ).rejects.toThrow('cannot replace a source database') + expect(exportProfileStateJson(db)).toBe(originalJson) + } + ) + + it('rejects a source database reached through a directory alias', async () => { + const { directory, db, originalJson } = fixture() + const alias = join(directory, 'alias') + symlinkSync(directory, alias, 'junction') + await expect( + writeProfileStateDatabaseSnapshotAsync(db, join(alias, 'profile-state.db')) + ).rejects.toThrow('cannot replace a source database') + expect(exportProfileStateJson(db)).toBe(originalJson) + }) + + it.for(['', '-WAL', '-SHM', '-JOURNAL'])( + 'refuses source database%s case aliases on case-insensitive filesystems', + async (suffix, { skip }) => { + const { directory, db, originalJson } = fixture() + const alias = join(directory, 'PROFILE-STATE.DB') + if (!existsSync(alias)) { + skip() + return + } + await expect(writeProfileStateDatabaseSnapshotAsync(db, `${alias}${suffix}`)).rejects.toThrow( + 'cannot replace a source database' + ) + expect(exportProfileStateJson(db)).toBe(originalJson) + } + ) + + it.each(['-wal', '-shm', '-journal'])( + 'preserves a destination with an existing %s sidecar', + async (suffix) => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + writeFileSync(`${targetPath}${suffix}`, 'retained SQLite sidecar') + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'destination has SQLite sidecars' + ) + + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expect(readFileSync(`${targetPath}${suffix}`, 'utf8')).toBe('retained SQLite sidecar') + expectNoTemporaryFiles(directory) + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.ts new file mode 100644 index 000000000000..67b00f894221 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.ts @@ -0,0 +1,114 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { access, mkdir, open, realpath, rm, stat } from 'node:fs/promises' +import { basename, dirname, resolve } from 'node:path' +import Database from '../../sqlite/sync-database' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import { durableWriteTempPath, renameDurable } from '../../durable-file-write' + +/** The caller owns the destination and keeps the source open until this backup settles. */ +export async function writeProfileStateDatabaseSnapshotAsync( + db: Database.Database, + targetPath: string, + options: { validateStagedSnapshot?: (stagingPath: string) => Promise | void } = {} +): Promise { + if (targetPath.length === 0 || targetPath.includes('\0')) { + throw new Error('Profile state snapshot path is invalid') + } + if (db.isTransaction) { + throw new Error('Profile state database snapshot requires an idle database connection') + } + await mkdir(dirname(targetPath), { recursive: true }) + await assertSnapshotTargetIsSeparate(db, targetPath) + await assertNoSnapshotSidecars(targetPath) + const temporaryPath = durableWriteTempPath(targetPath) + let published = false + try { + // Pre-create privately: the native backup otherwise creates a world-readable temporary file. + const temporary = await open(temporaryPath, 'wx', 0o600) + await temporary.close() + await db.backup(temporaryPath) + // The native copy preserves WAL mode; snapshots must not create sidecars when opened read-only. + const snapshot = new Database(temporaryPath, { fileMustExist: true }) + try { + if (snapshot.pragma('journal_mode = DELETE', { simple: true }) !== 'delete') { + throw new Error('Profile state snapshot could not become a self-contained database') + } + } finally { + snapshot.close() + } + hardenSqliteDatabaseFiles(temporaryPath) + const completed = await open(temporaryPath, 'r+') + try { + await completed.sync() + } finally { + await completed.close() + } + await options.validateStagedSnapshot?.(temporaryPath) + await assertNoSnapshotSidecars(targetPath) + await renameDurable(temporaryPath, targetPath) + published = true + } finally { + if (!published) { + await rm(temporaryPath, { force: true }) + } + await Promise.all( + ['-wal', '-shm', '-journal'].map((suffix) => rm(`${temporaryPath}${suffix}`, { force: true })) + ) + } +} + +async function assertSnapshotTargetIsSeparate( + db: Database.Database, + targetPath: string +): Promise { + const target = await realpath(targetPath).catch(async (error: unknown) => { + if (!isMissingPath(error)) { + throw error + } + return resolve(await realpath(dirname(targetPath)), basename(targetPath)) + }) + const databases = db.prepare('PRAGMA database_list').all() + for (const database of databases) { + if (typeof database.file !== 'string' || database.file.length === 0) { + continue + } + const source = await realpath(database.file) + if (profileStateDatabaseFiles(source).includes(target)) { + throw new Error('Profile state snapshot cannot replace a source database or its sidecars') + } + // realpath preserves case aliases on macOS; file identity also protects absent sidecar names. + const sourceInfo = await stat(source, { bigint: true }) + for (const candidate of new Set([target, target.replace(/-(?:wal|shm|journal)$/i, '')])) { + const targetInfo = await stat(candidate, { bigint: true }).catch((error: unknown) => { + if (!isMissingPath(error)) { + throw error + } + return undefined + }) + if (targetInfo?.dev === sourceInfo.dev && targetInfo.ino === sourceInfo.ino) { + throw new Error('Profile state snapshot cannot replace a source database or its sidecars') + } + } + } +} + +async function assertNoSnapshotSidecars(targetPath: string): Promise { + for (const suffix of ['-wal', '-shm', '-journal']) { + const exists = await access(`${targetPath}${suffix}`).then( + () => true, + (error: unknown) => { + if (!isMissingPath(error)) { + throw error + } + return false + } + ) + if (exists) { + throw new Error('Profile state snapshot destination has SQLite sidecars') + } + } +} + +function isMissingPath(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} diff --git a/src/main/persistence/profile-state/profile-state-database-validation.ts b/src/main/persistence/profile-state/profile-state-database-validation.ts new file mode 100644 index 000000000000..3bd744219659 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-validation.ts @@ -0,0 +1,146 @@ +import { withProfileStateReadSnapshot } from './profile-state-read-snapshot' +import type Database from '../../sqlite/sync-database' +import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' +import { readProfileStateRevision } from './profile-state-revision' +import { + PROFILE_STATE_META_PROFILE_ID, + PROFILE_STATE_DATABASE_SCHEMA_VERSION +} from './profile-state-database-schema' +import { + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE +} from './profile-state-automation-runs' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' + +export function verifyProfileStateSchema( + db: Database.Database, + profileId: string, + schemaVersion = PROFILE_STATE_DATABASE_SCHEMA_VERSION +): void { + withProfileStateReadSnapshot(db, () => { + const tables = profileStateTableNames(db) + if ( + !tables.has('profile_state_meta') || + !tables.has('profile_state_documents') || + (schemaVersion >= 2 && + (!tables.has(PROFILE_STATE_AUTOMATION_RUNS_META_TABLE) || + !tables.has(PROFILE_STATE_AUTOMATION_RUNS_TABLE))) + ) { + throw new Error('Profile state database schema is incomplete') + } + + verifyProfileStateColumns(db, 'profile_state_meta', { + key: { type: 'TEXT', notNull: true, primaryKey: true }, + value: { type: 'TEXT', notNull: true, primaryKey: false } + }) + verifyProfileStateColumns(db, 'profile_state_documents', { + domain: { type: 'TEXT', notNull: true, primaryKey: true }, + payload: { type: 'TEXT', notNull: true, primaryKey: false }, + domain_version: { type: 'INTEGER', notNull: true, primaryKey: false }, + revision: { type: 'INTEGER', notNull: true, primaryKey: false }, + updated_at: { type: 'INTEGER', notNull: true, primaryKey: false }, + content_hash: { type: 'TEXT', notNull: true, primaryKey: false } + }) + if (schemaVersion >= 2) { + verifyProfileStateColumns(db, PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, { + domain: { type: 'TEXT', notNull: true, primaryKey: true }, + presence: { type: 'TEXT', notNull: true, primaryKey: false }, + domain_version: { type: 'INTEGER', notNull: true, primaryKey: false }, + revision: { type: 'INTEGER', notNull: true, primaryKey: false }, + updated_at: { type: 'INTEGER', notNull: true, primaryKey: false }, + content_hash: { type: 'TEXT', notNull: true, primaryKey: false } + }) + verifyProfileStateColumns(db, PROFILE_STATE_AUTOMATION_RUNS_TABLE, { + run_id: { type: 'TEXT', notNull: true, primaryKey: true }, + ordinal: { type: 'INTEGER', notNull: true, primaryKey: false }, + payload: { type: 'TEXT', notNull: true, primaryKey: false }, + content_hash: { type: 'TEXT', notNull: true, primaryKey: false }, + revision: { type: 'INTEGER', notNull: true, primaryKey: false }, + updated_at: { type: 'INTEGER', notNull: true, primaryKey: false } + }) + } + + verifyProfileStateIdentity(db, profileId) + if (schemaVersion >= 3) { + readCurrentAutomationRunsState(db, readProfileStateRevision(db)) + } + }) +} + +export function verifyEmptyProfileStateSchema(db: Database.Database): void { + if (profileStateTableNames(db).size > 0) { + throw new Error('Profile state database has an unexpected version-0 schema') + } +} + +function verifyProfileStateColumns( + db: Database.Database, + table: string, + expected: Readonly> +): void { + const rows = db.pragma(`table_info(${table})`) + if (!Array.isArray(rows)) { + throw new Error(`Profile state table has no readable columns: ${table}`) + } + const columns = new Map() + for (const row of rows) { + if ( + !isRecord(row) || + typeof row.name !== 'string' || + typeof row.type !== 'string' || + typeof row.notnull !== 'number' || + typeof row.pk !== 'number' + ) { + throw new Error(`Profile state table has malformed column metadata: ${table}`) + } + columns.set(row.name, { + type: row.type.toUpperCase(), + notNull: row.notnull === 1, + primaryKey: row.pk === 1 + }) + } + for (const [name, definition] of Object.entries(expected)) { + const actual = columns.get(name) + if ( + actual === undefined || + actual.type !== definition.type || + actual.notNull !== definition.notNull || + actual.primaryKey !== definition.primaryKey + ) { + throw new Error(`Profile state table has an incompatible column: ${table}.${name}`) + } + } +} + +function profileStateTableNames(db: Database.Database): Set { + return new Set( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => (isRecord(row) && typeof row.name === 'string' ? row.name : undefined)) + .filter((name): name is string => name !== undefined) + ) +} + +function verifyProfileStateIdentity(db: Database.Database, profileId: string): void { + const storedProfileIdRow = db + .prepare('SELECT value FROM profile_state_meta WHERE key = ?') + .get(PROFILE_STATE_META_PROFILE_ID) + const storedProfileId = + isRecord(storedProfileIdRow) && typeof storedProfileIdRow.value === 'string' + ? storedProfileIdRow.value + : undefined + if (storedProfileId === undefined) { + throw new Error('Profile state database is missing its profile identity') + } + if (storedProfileId !== profileId) { + throw new ProfileStateDatabaseOpenError( + 'identity-mismatch', + 'Profile state database belongs to a different profile' + ) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence/profile-state/profile-state-database.test.ts b/src/main/persistence/profile-state/profile-state-database.test.ts new file mode 100644 index 000000000000..c3cb63fb114e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database.test.ts @@ -0,0 +1,339 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile, + profileStatePragmaNumber, + PROFILE_STATE_BUSY_TIMEOUT_MS, + PROFILE_STATE_DATABASE_FILE_NAME +} from './profile-state-database' +import { PROFILE_STATE_DATABASE_SCHEMA_VERSION } from './profile-state-database-schema' +import { importProfileStateJson } from './profile-state-documents' +import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-db-')) + temporaryDirectories.push(directory) + return directory +} + +describe('profile state database', () => { + it('creates an isolated per-profile schema with durable pragmas', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const opened = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(dbPath).toBe(join(directory, PROFILE_STATE_DATABASE_FILE_NAME)) + expect(opened.readOnly).toBe(false) + expect(opened.profileId).toBe('profile-a') + expect(profileStatePragmaNumber(opened.db, 'user_version')).toBe( + PROFILE_STATE_DATABASE_SCHEMA_VERSION + ) + expect(opened.db.pragma('journal_mode', { simple: true })).toBe('wal') + expect(profileStatePragmaNumber(opened.db, 'synchronous')).toBe(2) + expect(profileStatePragmaNumber(opened.db, 'busy_timeout')).toBe( + PROFILE_STATE_BUSY_TIMEOUT_MS + ) + expect(profileStatePragmaNumber(opened.db, 'foreign_keys')).toBe(1) + expect( + opened.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all() + ).toEqual([ + { name: 'profile_state_automation_runs' }, + { name: 'profile_state_automation_runs_meta' }, + { name: 'profile_state_documents' }, + { name: 'profile_state_meta' } + ]) + expect( + opened.db.prepare('SELECT value FROM profile_state_meta WHERE key = ?').get('profile_id') + ).toEqual({ value: 'profile-a' }) + } finally { + opened.db.close() + } + }) + + it('migrates the version-1 document schema by adding normalized run tables', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = new Database(dbPath) + seeded.exec(` + PRAGMA user_version = 1; + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, + payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + content_hash TEXT NOT NULL + ); + INSERT INTO profile_state_meta (key, value) VALUES ('profile_id', 'profile-a'); + INSERT INTO profile_state_meta (key, value) VALUES ('revision', '1'); + INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES ('settings', '{"theme":"dark"}', 1, 1, 100, '0f4f87db4567232a7f1756aa1534ec1314777b39c3bf5209f87cf9739321cddc'); + `) + seeded.close() + + const opened = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(profileStatePragmaNumber(opened.db, 'user_version')).toBe( + PROFILE_STATE_DATABASE_SCHEMA_VERSION + ) + expect( + opened.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all() + ).toEqual([ + { name: 'profile_state_automation_runs' }, + { name: 'profile_state_automation_runs_meta' }, + { name: 'profile_state_documents' }, + { name: 'profile_state_meta' } + ]) + expect( + opened.db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('settings') + ).toEqual({ payload: '{"theme":"dark"}' }) + } finally { + opened.db.close() + } + }) + + it('validates normalized tables created during migration', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = new Database(dbPath) + seeded.exec(` + PRAGMA user_version = 1; + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, + payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + content_hash TEXT NOT NULL + ); + CREATE TABLE profile_state_automation_runs ( + run_id TEXT PRIMARY KEY NOT NULL, + ordinal INTEGER NOT NULL, + payload BLOB NOT NULL, + content_hash TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO profile_state_meta (key, value) VALUES ('profile_id', 'profile-a'); + `) + seeded.close() + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + }) + + it('latches a future schema read-only without changing the database file', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = openProfileStateDatabase(dbPath, 'profile-a') + seeded.db.pragma(`user_version = ${PROFILE_STATE_DATABASE_SCHEMA_VERSION + 9}`) + seeded.db.close() + const before = statSync(dbPath) + const beforeBytes = readFileSync(dbPath) + + const opened = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(opened.readOnly).toBe(true) + expect(profileStatePragmaNumber(opened.db, 'user_version')).toBe( + PROFILE_STATE_DATABASE_SCHEMA_VERSION + 9 + ) + expect(() => opened.db.exec("INSERT INTO profile_state_meta VALUES ('x', 'y')")).toThrow() + } finally { + opened.db.close() + } + const after = statSync(dbPath) + expect(after.size).toBe(before.size) + expect(readFileSync(dbPath)).toEqual(beforeBytes) + }) + + it('opens the current schema read-only without changing its bytes', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = openProfileStateDatabase(dbPath, 'profile-a') + seeded.db.close() + const before = readFileSync(dbPath) + + const opened = openProfileStateDatabaseReadOnly(dbPath, 'profile-a') + try { + expect(opened.readOnly).toBe(true) + expect(() => opened.db.exec("INSERT INTO profile_state_meta VALUES ('x', 'y')")).toThrow() + } finally { + opened.db.close() + } + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('rejects a database whose profile identity does not match', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const opened = openProfileStateDatabase(dbPath, 'profile-a') + opened.db.close() + const before = readFileSync(dbPath) + + expect(() => openProfileStateDatabase(dbPath, 'profile-b')).toThrowError( + expect.objectContaining({ code: 'identity-mismatch' }) + ) + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('rejects malformed database bytes without replacing them', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const bytes = Buffer.from('not a sqlite database') + writeFileSync(dbPath, bytes) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + expect(readFileSync(dbPath)).toEqual(bytes) + }) + + it('quarantines the database family without touching live recovery sources', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const opened = openProfileStateDatabase(dbPath, 'profile-a') + importProfileStateJson(opened.db, JSON.stringify({ settings: { theme: 'dark' } })) + opened.db.close() + writeFileSync(`${dbPath}-wal`, 'wal-preservation-sentinel') + + const sourceBytes = new Map( + [dbPath, `${dbPath}-wal`].map((path) => [path, readFileSync(path).toString('hex')]) + ) + const result = quarantineProfileStateDatabase( + dbPath, + 'profile-a', + join(directory, 'quarantine'), + 'test-corruption' + ) + + expect(result.copiedFiles).toHaveLength(2) + expect(JSON.parse(readFileSync(result.manifestPath, 'utf8'))).toMatchObject({ + schemaVersion: 1, + profileId: 'profile-a', + reason: 'test-corruption', + sourceFiles: expect.arrayContaining(['', '-wal']) + }) + expect(readFileSync(join(result.directory, 'profile-state.db')).toString('hex')).toBe( + sourceBytes.get(dbPath) + ) + expect(readFileSync(join(result.directory, 'profile-state.db-wal')).toString('hex')).toBe( + sourceBytes.get(`${dbPath}-wal`) + ) + for (const [path, bytes] of sourceBytes) { + expect(readFileSync(path).toString('hex')).toBe(bytes) + } + }) + + it('rejects an unexpected version-0 schema without mutating it', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = new Database(dbPath) + seeded.exec('CREATE TABLE unrelated (value TEXT)') + seeded.close() + const before = readFileSync(dbPath) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('rejects an incomplete current schema without mutating it', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = openProfileStateDatabase(dbPath, 'profile-a') + seeded.db.exec('DROP TABLE profile_state_documents') + seeded.db.close() + const before = readFileSync(dbPath) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('rejects current-version tables with incompatible columns without mutating them', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = new Database(dbPath) + seeded.exec(` + PRAGMA user_version = ${PROFILE_STATE_DATABASE_SCHEMA_VERSION}; + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY, + payload BLOB NOT NULL, + revision INTEGER NOT NULL + ); + `) + seeded.close() + const before = readFileSync(dbPath) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('does not create an empty database when the parent directory is absent', () => { + const directory = createDirectory() + const dbPath = join(directory, 'missing', PROFILE_STATE_DATABASE_FILE_NAME) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + }) + + it('rejects an empty profile identity before opening SQLite', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + + expect(() => openProfileStateDatabase(dbPath, '')).toThrowError( + expect.objectContaining({ code: 'invalid-profile-id' }) + ) + }) + + it('restricts the database and WAL sidecars on POSIX', () => { + if (process.platform === 'win32') { + return + } + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const opened = openProfileStateDatabase(dbPath, 'profile-a') + opened.db + .prepare('INSERT INTO profile_state_documents VALUES (?, ?, ?, ?, ?, ?)') + .run('settings', '{}', 1, 1, Date.now(), 'hash') + try { + for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { + expect(statSync(path).mode & 0o777).toBe(0o600) + } + } finally { + opened.db.close() + } + }) +}) + +describe('profile state database does not reuse orchestration state', () => { + it('uses a profile-local filename', () => { + const directory = createDirectory() + expect(profileStateDatabaseFile(directory)).not.toBe(join(directory, 'orchestration.db')) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database.ts b/src/main/persistence/profile-state/profile-state-database.ts new file mode 100644 index 000000000000..a05df51d7a49 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database.ts @@ -0,0 +1,251 @@ +import { withProfileStateWriteTransaction } from './profile-state-write-transaction' +import Database from '../../sqlite/sync-database' +import { migrateAutomationRunsStorage } from './profile-state-automation-runs-migration' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import { + createProfileStateTablesSql, + PROFILE_STATE_DATABASE_SCHEMA_VERSION, + PROFILE_STATE_META_PROFILE_ID +} from './profile-state-database-schema' +import { existsSync } from 'node:fs' +import { + PROFILE_STATE_DATABASE_FILE_NAME, + profileStateDatabaseFile +} from '../../../shared/profile-state-storage-paths' +import { isRecord } from './profile-state-document-validation' +import { + ProfileStateDatabaseOpenError, + type ProfileStateDatabaseOpenErrorCode +} from './profile-state-database-errors' +import { + verifyEmptyProfileStateSchema, + verifyProfileStateSchema +} from './profile-state-database-validation' + +export const PROFILE_STATE_BUSY_TIMEOUT_MS = 5_000 + +/** + * Probe SQLite without importing the builtin at module evaluation time. + * + * The packaged orcad runtime still supports Node 18, where `node:sqlite` does + * not exist. Keeping this probe beside the opener gives every authority + * selector the same capability decision and keeps that runtime's module graph + * safe to load. + */ +export function isProfileStateSqliteAvailable(): boolean { + if (typeof process.getBuiltinModule !== 'function') { + return false + } + try { + const sqlite: unknown = process.getBuiltinModule('node:sqlite') + return ( + isRecord(sqlite) && + typeof sqlite.DatabaseSync === 'function' && + typeof sqlite.backup === 'function' + ) + } catch { + return false + } +} + +export { ProfileStateDatabaseOpenError } +export type { ProfileStateDatabaseOpenErrorCode } + +export type OpenProfileStateDatabase = { + db: Database.Database + readOnly: boolean + profileId: string +} + +export { PROFILE_STATE_DATABASE_FILE_NAME, profileStateDatabaseFile } + +/** + * Open the database belonging to one profile. + * + * The schema version is read before WAL, busy-timeout, or DDL configuration so + * a newer build can leave the database byte-for-byte untouched and read it + * without accidentally writing through an unknown schema. + */ +export function openProfileStateDatabase( + dbPath: string, + profileId: string +): OpenProfileStateDatabase { + if (profileId.length === 0) { + throw new ProfileStateDatabaseOpenError('invalid-profile-id', 'Profile ID cannot be empty') + } + + let probe: Database.Database + try { + probe = new Database(dbPath) + } catch (error) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to open profile state database: ${dbPath}`, + error + ) + } + + let transferred = false + try { + const storedVersion = profileStatePragmaNumber(probe, 'user_version') + verifyProfileStateIntegrity(probe) + if (storedVersion > PROFILE_STATE_DATABASE_SCHEMA_VERSION) { + probe.close() + transferred = true + try { + return { + db: new Database(dbPath, { + readonly: true, + fileMustExist: true, + timeout: PROFILE_STATE_BUSY_TIMEOUT_MS + }), + readOnly: true, + profileId + } + } catch (error) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to open future profile state database read-only: ${dbPath}`, + error + ) + } + } + + if (storedVersion > 0) { + // Validate the complete current shape before WAL setup. A structurally + // valid but incomplete database should fail without changing its header. + verifyProfileStateSchema(probe, profileId, storedVersion) + } else if (storedVersion === 0) { + verifyEmptyProfileStateSchema(probe) + } else { + throw new Error(`Unsupported profile state database schema: ${storedVersion}`) + } + + // The journal-mode pragma can update the SQLite header even when no state + // row is written, so all known-shape validation happens before this point. + migrateProfileStateSchema(probe, storedVersion, profileId) + configureProfileStatePragmas(probe) + hardenSqliteDatabaseFiles(dbPath) + transferred = true + return { db: probe, readOnly: false, profileId } + } catch (error) { + if (error instanceof ProfileStateDatabaseOpenError) { + throw error + } + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to initialize profile state database: ${dbPath}`, + error + ) + } finally { + if (!transferred) { + probe.close() + } + } +} + +/** + * Open an existing profile database without applying migrations or changing + * its journal mode. Callers use this for best-effort reads during GC, where a + * present database is authoritative and any failure must fail closed. + */ +export function openProfileStateDatabaseReadOnly( + dbPath: string, + profileId: string +): OpenProfileStateDatabase { + if (profileId.length === 0) { + throw new ProfileStateDatabaseOpenError('invalid-profile-id', 'Profile ID cannot be empty') + } + if (!existsSync(dbPath)) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Profile state database does not exist: ${dbPath}` + ) + } + + let db: Database.Database + try { + db = new Database(dbPath, { + readonly: true, + fileMustExist: true, + timeout: PROFILE_STATE_BUSY_TIMEOUT_MS + }) + } catch (error) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to open profile state database read-only: ${dbPath}`, + error + ) + } + + try { + const storedVersion = profileStatePragmaNumber(db, 'user_version') + verifyProfileStateIntegrity(db) + if (storedVersion > PROFILE_STATE_DATABASE_SCHEMA_VERSION) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Profile state database schema is newer than this runtime: ${storedVersion}` + ) + } + if (storedVersion !== PROFILE_STATE_DATABASE_SCHEMA_VERSION) { + throw new Error(`Unsupported profile state database schema: ${storedVersion}`) + } + verifyProfileStateSchema(db, profileId) + return { db, readOnly: true, profileId } + } catch (error) { + db.close() + if (error instanceof ProfileStateDatabaseOpenError) { + throw error + } + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to read profile state database: ${dbPath}`, + error + ) + } +} + +export function profileStatePragmaNumber(db: Database.Database, name: string): number { + return Number(db.pragma(name, { simple: true }) ?? 0) +} + +function verifyProfileStateIntegrity(db: Database.Database): void { + const result = db.pragma('quick_check', { simple: true }) + if (result !== 'ok') { + throw new Error(`Profile state database integrity check failed: ${String(result)}`) + } +} + +function configureProfileStatePragmas(db: Database.Database): void { + const journalMode = db.pragma('journal_mode = WAL', { simple: true }) + if (typeof journalMode !== 'string' || journalMode.toLowerCase() !== 'wal') { + throw new Error(`Profile state database does not support WAL (mode: ${String(journalMode)})`) + } + db.pragma(`busy_timeout = ${PROFILE_STATE_BUSY_TIMEOUT_MS}`) + db.pragma('foreign_keys = ON') + // Profile commits are user-visible state. Keep the same power-loss contract + // as the current temp-file + fsync writer rather than the orchestration DB's + // cache-oriented NORMAL setting. + db.pragma('synchronous = FULL') +} + +function migrateProfileStateSchema( + db: Database.Database, + storedVersion: number, + profileId: string +): void { + if (storedVersion >= PROFILE_STATE_DATABASE_SCHEMA_VERSION) { + return + } + + return withProfileStateWriteTransaction(db, () => { + db.exec(createProfileStateTablesSql()) + db.prepare('INSERT OR IGNORE INTO profile_state_meta (key, value) VALUES (?, ?)').run( + PROFILE_STATE_META_PROFILE_ID, + profileId + ) + migrateAutomationRunsStorage(db, storedVersion) + verifyProfileStateSchema(db, profileId) + db.pragma(`user_version = ${PROFILE_STATE_DATABASE_SCHEMA_VERSION}`) + }) +} diff --git a/src/main/persistence/profile-state/profile-state-document-reader.ts b/src/main/persistence/profile-state/profile-state-document-reader.ts new file mode 100644 index 000000000000..b28c0eba245c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-document-reader.ts @@ -0,0 +1,78 @@ +import type Database from '../../sqlite/sync-database' +import { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs' +import { + assertProfileStateDocumentRevision, + readProfileStateRevision +} from './profile-state-revision' +import { + ProfileStateDocumentCorruptionError, + validateProfileStateDocumentRow, + type ProfileStateDocument, + type ProfileStateParsedDocument +} from './profile-state-document-validation' + +export type ReadProfileStateDocumentsOptions = { + /** The profile revision already read by a surrounding snapshot. */ + profileRevision?: number +} + +/** Read the authoritative rows after checking their hash, shape, and JSON payload. */ +export function readProfileStateDocuments( + db: Database.Database, + options: ReadProfileStateDocumentsOptions & { representation: 'parsed' } +): readonly ProfileStateParsedDocument[] +export function readProfileStateDocuments( + db: Database.Database, + options?: ReadProfileStateDocumentsOptions +): readonly ProfileStateDocument[] +export function readProfileStateDocuments( + db: Database.Database, + options: ReadProfileStateDocumentsOptions & { representation?: 'parsed' } = {} +): readonly (ProfileStateDocument | ProfileStateParsedDocument)[] { + const profileRevision = options.profileRevision ?? readProfileStateRevision(db) + const normalized = + options.representation === 'parsed' + ? readProfileStateAutomationRunsDocument(db, profileRevision, 'parsed') + : readProfileStateAutomationRunsDocument(db, profileRevision) + const rows = db + .prepare( + `SELECT domain, payload, domain_version, revision, updated_at, content_hash + FROM profile_state_documents ORDER BY rowid` + ) + .all() + const documents = rows.map((row): ProfileStateDocument | ProfileStateParsedDocument => { + const document = validateProfileStateDocumentRow(row, { + retainParsedValue: options.representation === 'parsed' + }) + assertProfileStateDocumentRevision(document.revision, profileRevision, document.domain) + if ( + normalized !== undefined && + document.domain === 'automationRuns' && + document.payload !== 'null' + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns placeholder is invalid', + document.domain + ) + } + if (options.representation === 'parsed') { + const { payload: _payload, ...parsedDocument } = document + return { ...parsedDocument, value: document.value } + } + return document + }) + if (normalized === undefined) { + return documents + } + const withoutAutomationRuns = documents.filter((document) => document.domain !== 'automationRuns') + if (normalized === null) { + return withoutAutomationRuns + } + const originalIndex = documents.findIndex((document) => document.domain === 'automationRuns') + withoutAutomationRuns.splice( + originalIndex === -1 ? withoutAutomationRuns.length : originalIndex, + 0, + normalized + ) + return withoutAutomationRuns +} diff --git a/src/main/persistence/profile-state/profile-state-document-validation.ts b/src/main/persistence/profile-state/profile-state-document-validation.ts new file mode 100644 index 000000000000..5ead20cd8d09 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-document-validation.ts @@ -0,0 +1,116 @@ +import { createHash } from 'node:crypto' + +export type ProfileStateDocument = { + domain: string + payload: string + domainVersion: number + revision: number + updatedAt: number + contentHash: string +} + +export type ProfileStateParsedDocument = Omit & { value: unknown } + +export class ProfileStateDocumentCorruptionError extends Error { + readonly code = 'corrupt-document' as const + readonly domain: string | null + + constructor(message: string, domain: string | null = null) { + super(message) + this.name = 'ProfileStateDocumentCorruptionError' + this.domain = domain + } +} + +export class ProfileStateRevisionConflictError extends Error { + readonly code = 'profile-state-revision-conflict' as const + readonly expectedRevision: number + readonly actualRevision: number + + constructor(expectedRevision: number, actualRevision: number) { + super( + `Profile state revision changed while importing a document (expected ${expectedRevision}, found ${actualRevision})` + ) + this.name = 'ProfileStateRevisionConflictError' + this.expectedRevision = expectedRevision + this.actualRevision = actualRevision + } +} + +export function hashProfileStatePayload(payload: string): string { + return createHash('sha256').update(payload, 'utf8').digest('hex') +} + +export function parseProfileStateRoot(rawJson: string): Record { + let parsed: unknown + try { + parsed = JSON.parse(rawJson) + } catch { + throw new ProfileStateDocumentCorruptionError('Profile state JSON is invalid', null) + } + if (!isRecord(parsed)) { + throw new ProfileStateDocumentCorruptionError('Profile state JSON root must be an object', null) + } + return parsed +} + +export function validateProfileStateDocumentRow( + row: unknown, + options: { validateJson?: boolean; retainParsedValue?: boolean } = {} +): ProfileStateDocument & { value?: unknown } { + if ( + !isRecord(row) || + typeof row.domain !== 'string' || + typeof row.payload !== 'string' || + typeof row.domain_version !== 'number' || + typeof row.revision !== 'number' || + typeof row.updated_at !== 'number' || + typeof row.content_hash !== 'string' + ) { + throw new ProfileStateDocumentCorruptionError('Profile state document row has invalid fields') + } + if ( + !Number.isSafeInteger(row.domain_version) || + row.domain_version < 1 || + !Number.isSafeInteger(row.revision) || + row.revision < 1 || + !Number.isSafeInteger(row.updated_at) || + row.updated_at < 0 || + !/^[a-f0-9]{64}$/.test(row.content_hash) + ) { + throw new ProfileStateDocumentCorruptionError( + `Profile state document row metadata is invalid: ${row.domain}`, + row.domain + ) + } + if (hashProfileStatePayload(row.payload) !== row.content_hash) { + throw new ProfileStateDocumentCorruptionError( + `Profile state document hash mismatch: ${row.domain}`, + row.domain + ) + } + let value: unknown + if (options.retainParsedValue || (options.validateJson ?? true)) { + try { + value = JSON.parse(row.payload) + } catch { + throw new ProfileStateDocumentCorruptionError( + `Profile state document payload is invalid JSON: ${row.domain}`, + row.domain + ) + } + } + return { + domain: row.domain, + payload: row.payload, + domainVersion: row.domain_version, + revision: row.revision, + updatedAt: row.updated_at, + contentHash: row.content_hash, + ...(options.retainParsedValue ? { value } : {}) + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence/profile-state/profile-state-documents.test.ts b/src/main/persistence/profile-state/profile-state-documents.test.ts new file mode 100644 index 000000000000..1afdae3d60c0 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-documents.test.ts @@ -0,0 +1,370 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from '../profile-state-cutover-fixture' +import { + exportProfileStateJson, + hashProfileStateJson, + importProfileStateJson, + readProfileStateJsonAcceptance, + readProfileStateDocuments, + readProfileStateRevision, + readProfileStateSnapshot, + readProfileStateParsedSnapshot +} from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import { readProfileStateDomains } from './profile-state-domain-reader' +import { writeProfileStateDomain } from './profile-state-domain-writes' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function openTestDatabase(): { + directory: string + db: ReturnType['db'] +} { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-documents-')) + temporaryDirectories.push(directory) + return { + directory, + db: openProfileStateDatabase(profileStateDatabaseFile(directory), 'profile-a').db + } +} + +describe('profile state document adapter', () => { + it('round-trips every top-level domain, including unknown keys, nulls, arrays, and sealed bytes', () => { + const { db } = openTestDatabase() + try { + const fixture = buildProfileStateCutoverFixture() + const raw = JSON.stringify(fixture) + expect(importProfileStateJson(db, raw, { now: () => 123 })).toBe(1) + const exported = exportProfileStateJson(db) + + expect(canonicalProfileStateJson(JSON.parse(exported))).toBe( + canonicalProfileStateJson(fixture) + ) + expect(JSON.parse(exported).settings.opencodeSessionCookie).toBe( + fixture.settings.opencodeSessionCookie + ) + expect(readProfileStateRevision(db)).toBe(1) + expect(readProfileStateDocuments(db)).toHaveLength(Object.keys(fixture).length) + expect(readProfileStateDocuments(db).find((row) => row.domain === 'settings')).toMatchObject({ + revision: 1, + updatedAt: 123 + }) + } finally { + db.close() + } + }) + + it('preserves missing domains versus explicit null values and array order', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ + explicitNull: null, + ordered: ['first', 'second'], + unknown: { keep: true } + }) + ) + const exported = JSON.parse(exportProfileStateJson(db)) + expect(exported).toEqual({ + explicitNull: null, + ordered: ['first', 'second'], + unknown: { keep: true } + }) + expect(Object.hasOwn(exported, 'missing')).toBe(false) + } finally { + db.close() + } + }) + + it('advances revision and replaces the complete document set atomically', () => { + const { db } = openTestDatabase() + try { + expect(importProfileStateJson(db, JSON.stringify({ first: 1, old: 2 }))).toBe(1) + expect(importProfileStateJson(db, JSON.stringify({ second: 3 }), { now: () => 456 })).toBe(2) + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ second: 3 })) + expect(readProfileStateRevision(db)).toBe(2) + expect(readProfileStateDocuments(db)[0]).toMatchObject({ + domain: 'second', + revision: 2, + updatedAt: 456 + }) + } finally { + db.close() + } + }) + + it('rejects a stale complete-document replacement before deleting rows', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ keep: true })) + expect(() => + importProfileStateJson(db, JSON.stringify({ replacement: true }), { + expectedRevision: 0 + }) + ).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 0, + actualRevision: 1 + }) + ) + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ keep: true })) + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) + + it('commits the legacy JSON acceptance marker with the imported revision', () => { + const { db } = openTestDatabase() + try { + const raw = JSON.stringify({ settings: { theme: 'dark' } }) + expect( + importProfileStateJson(db, raw, { + acceptedLegacyJsonHash: hashProfileStateJson(raw), + now: () => 123 + }) + ).toBe(1) + expect(readProfileStateJsonAcceptance(db)).toEqual({ + jsonHash: hashProfileStateJson(raw), + acceptedRevision: 1 + }) + } finally { + db.close() + } + }) + + it('rolls back every row and the revision when one insert fails', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ keep: { value: 1 } }), { now: () => 10 }) + db.exec( + `CREATE TRIGGER fail_profile_state_insert + BEFORE INSERT ON profile_state_documents + WHEN NEW.domain = 'second' + BEGIN SELECT RAISE(ABORT, 'injected document failure'); END` + ) + + expect(() => importProfileStateJson(db, JSON.stringify({ first: 1, second: 2 }))).toThrow( + 'injected document failure' + ) + db.exec('DROP TRIGGER fail_profile_state_insert') + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ keep: { value: 1 } })) + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) + + it('does not roll back a transaction owned by the caller', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ keep: true })) + db.exec('BEGIN IMMEDIATE') + + expect(() => importProfileStateJson(db, JSON.stringify({ replacement: true }))).toThrow( + 'requires an idle database connection' + ) + expect(db.isTransaction).toBe(true) + db.exec('ROLLBACK') + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ keep: true })) + } finally { + if (db.isTransaction) { + db.exec('ROLLBACK') + } + db.close() + } + }) + + it('rejects a tampered payload when its hash no longer matches', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ ui: { active: 'terminal' } })) + db.prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?').run( + JSON.stringify({ active: 'tasks' }), + 'ui' + ) + + expect(() => readProfileStateDocuments(db)).toThrowError( + expect.objectContaining({ domain: 'ui' }) + ) + expect(() => exportProfileStateJson(db)).toThrowError(/hash mismatch: ui/) + } finally { + db.close() + } + }) + + it('rejects invalid domain JSON even when its hash is correct', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run('{invalid', hashProfileStateJson('{invalid'), 'settings') + + expect(() => readProfileStateDocuments(db)).toThrow(/invalid JSON: settings/) + expect(() => readProfileStateSnapshot(db)).toThrow(/invalid JSON: settings/) + expect(() => readProfileStateParsedSnapshot(db)).toThrow(/invalid JSON: settings/) + } finally { + db.close() + } + }) + + it.each(['[]', ' null '])( + 'rejects the noncanonical normalized history placeholder %s', + (payload) => { + const { db } = openTestDatabase() + try { + const original = { + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }], + ui: { sidebarWidth: 280 } + } + importProfileStateJson(db, JSON.stringify(original)) + expect( + db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual({ payload: 'null' }) + expect(readProfileStateSnapshot(db).json).toBe(JSON.stringify(original)) + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run(payload, hashProfileStateJson(payload), 'automationRuns') + expect(() => readProfileStateSnapshot(db)).toThrow(/placeholder is invalid/) + expect(() => readProfileStateParsedSnapshot(db)).toThrow(/placeholder is invalid/) + } finally { + db.close() + } + } + ) + + it('rejects a retained legacy document whose revision is ahead of the profile', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + db.prepare('UPDATE profile_state_documents SET revision = ? WHERE domain = ?').run( + 999, + 'automationRuns' + ) + + expect(() => readProfileStateDocuments(db)).toThrow( + /document revision 999 exceeds profile revision 1/ + ) + expect(() => readProfileStateParsedSnapshot(db)).toThrow( + /document revision 999 exceeds profile revision 1/ + ) + } finally { + db.close() + } + }) + + it.each(['null', 'absent'] as const)( + 'rejects normalized %s metadata whose revision is ahead of the profile', + (presence) => { + const { directory, db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: presence === 'null' ? 'null' : null, + expectedRevision: 1 + }) + expect(db.prepare('SELECT presence FROM profile_state_automation_runs_meta').get()).toEqual( + { + presence + } + ) + db.prepare('UPDATE profile_state_automation_runs_meta SET revision = ?').run(999) + + expect(() => readProfileStateSnapshot(db)).toThrow( + /document revision 999 exceeds profile revision 2/ + ) + expect(() => readProfileStateParsedSnapshot(db)).toThrow( + /document revision 999 exceeds profile revision 2/ + ) + expect( + readProfileStateDomains(profileStateDatabaseFile(directory), 'profile-a', [ + 'automationRuns' + ]) + ).toMatchObject({ + kind: 'unreadable' + }) + } finally { + db.close() + } + } + ) + + it.each(['missing', 'absent', 'null'] as const)( + 'rejects %s automation metadata with remaining normalized runs on every read path', + (presence) => { + const { directory, db } = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ automationRuns: [{ id: 'run-1', status: 'pending' }] }) + ) + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1 + }) + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ + automationRuns: [{ id: 'run-1', status: 'completed' }] + }) + if (presence === 'missing') { + db.exec('DELETE FROM profile_state_automation_runs_meta') + } else { + db.prepare( + 'UPDATE profile_state_automation_runs_meta SET presence = ?, content_hash = ?' + ).run(presence, presence === 'absent' ? '' : hashProfileStateJson('null')) + } + + const expectedError = + presence === 'missing' + ? 'Normalized automationRuns metadata is malformed' + : 'Normalized automationRuns rows exist for an empty domain' + expect(() => readProfileStateSnapshot(db)).toThrow(expectedError) + expect(() => readProfileStateParsedSnapshot(db)).toThrow(expectedError) + const databasePath = profileStateDatabaseFile(directory) + const authority = new ProfileStateSqliteAuthority(databasePath, 'profile-a') + expect(() => authority.readSerializedState()).toThrow( + presence === 'missing' ? /Unable to read profile state database/ : expectedError + ) + expect( + readProfileStateDomains(databasePath, 'profile-a', ['automationRuns']) + ).toMatchObject({ + kind: 'unreadable' + }) + } finally { + db.close() + } + } + ) + + it('rejects malformed input without changing an existing revision', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ keep: true })) + expect(() => importProfileStateJson(db, '{invalid')).toThrow('Profile state JSON is invalid') + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ keep: true })) + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-documents.ts b/src/main/persistence/profile-state/profile-state-documents.ts new file mode 100644 index 000000000000..4ecf701f3c8c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-documents.ts @@ -0,0 +1,223 @@ +import { withProfileStateWriteTransaction } from './profile-state-write-transaction' +import { withProfileStateReadSnapshot } from './profile-state-read-snapshot' +import type Database from '../../sqlite/sync-database' +import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' +import { + PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE, + PROFILE_STATE_DOCUMENT_VERSION, + PROFILE_STATE_META_REVISION +} from './profile-state-database-schema' +import { + hashProfileStatePayload, + parseProfileStateRoot, + ProfileStateDocumentCorruptionError, + ProfileStateRevisionConflictError, + type ProfileStateDocument +} from './profile-state-document-validation' +import { + clearProfileStateAutomationRuns, + rebuildProfileStateAutomationRunsProjection +} from './profile-state-automation-runs' + +import { readProfileStateDocuments } from './profile-state-document-reader' + +import { readProfileStateRevision } from './profile-state-revision' +import { readProfileStateJsonAcceptance } from './profile-state-json-acceptance' + +export { + acceptProfileStateJsonCompatibility, + readProfileStateJsonAcceptance, + stageProfileStateJsonCompatibility, + type ProfileStateJsonAcceptance +} from './profile-state-json-acceptance' + +export { readProfileStateRevision } from './profile-state-revision' +export { readProfileStateDocuments } from './profile-state-document-reader' +export type { ReadProfileStateDocumentsOptions } from './profile-state-document-reader' +export { ProfileStateDocumentCorruptionError, ProfileStateRevisionConflictError } +export type { ProfileStateDocument } from './profile-state-document-validation' + +export type ImportProfileStateOptions = { + now?: () => number + /** Hash of the exact legacy JSON bytes accepted by this import. */ + acceptedLegacyJsonHash?: string + /** Revision observed by the caller before constructing this replacement. */ + expectedRevision?: number +} + +export type ProfileStateSnapshot = { + revision: number + documents: readonly ProfileStateDocument[] + json: string +} + +export type ProfileStateParsedSnapshot = { + revision: number + state: Record +} + +/** Import a complete JSON document set atomically into one profile database. */ +export function importProfileStateJson( + db: Database.Database, + rawJson: string, + options: ImportProfileStateOptions = {} +): number { + const parsed = parseProfileStateRoot(rawJson) + const entries = Object.entries(parsed).map(([domain, value]) => { + const payload = JSON.stringify(value) + if (payload === undefined) { + throw new ProfileStateDocumentCorruptionError( + `Profile state domain cannot be serialized: ${domain}`, + domain + ) + } + return { domain, payload } + }) + + const now = options.now ?? Date.now + if ( + options.acceptedLegacyJsonHash !== undefined && + !/^[a-f0-9]{64}$/.test(options.acceptedLegacyJsonHash) + ) { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance hash is invalid') + } + if ( + options.expectedRevision !== undefined && + (!Number.isSafeInteger(options.expectedRevision) || options.expectedRevision < 0) + ) { + throw new ProfileStateDocumentCorruptionError('Expected profile state revision is invalid') + } + const updatedAt = now() + if (!Number.isSafeInteger(updatedAt) || updatedAt < 0) { + throw new ProfileStateDocumentCorruptionError('Profile state update timestamp is invalid') + } + return withProfileStateWriteTransaction(db, () => { + const actualRevision = readProfileStateRevision(db) + if (options.expectedRevision !== undefined && actualRevision !== options.expectedRevision) { + throw new ProfileStateRevisionConflictError(options.expectedRevision, actualRevision) + } + readCurrentAutomationRunsState(db, actualRevision) + const revision = actualRevision + 1 + clearProfileStateAutomationRuns(db) + db.exec('DELETE FROM profile_state_documents') + const automationRuns = entries.find((entry) => entry.domain === 'automationRuns') + const normalizedRuns = + automationRuns !== undefined && + rebuildProfileStateAutomationRunsProjection( + db, + automationRuns.payload, + PROFILE_STATE_DOCUMENT_VERSION, + updatedAt, + revision + ) + const insert = db.prepare( + `INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, ?, ?, ?, ?)` + ) + for (const entry of entries) { + const payload = normalizedRuns && entry.domain === 'automationRuns' ? 'null' : entry.payload + insert.run( + entry.domain, + payload, + PROFILE_STATE_DOCUMENT_VERSION, + revision, + updatedAt, + hashProfileStatePayload(payload) + ) + } + db.prepare( + `INSERT INTO profile_state_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(PROFILE_STATE_META_REVISION, String(revision)) + if (options.acceptedLegacyJsonHash !== undefined) { + db.prepare( + `INSERT INTO profile_state_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run( + PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE, + JSON.stringify({ jsonHash: options.acceptedLegacyJsonHash, acceptedRevision: revision }) + ) + } + return revision + }) +} + +export function hashProfileStateJson(rawJson: string): string { + return hashProfileStatePayload(rawJson) +} + +/** Validate that retained legacy JSON is the exact export accepted by this database. */ +export function profileStateJsonMatchesAcceptance(db: Database.Database, rawJson: string): boolean { + return readAcceptedProfileStateSnapshot(db, rawJson) !== undefined +} + +/** Validate the retained JSON and return the same database snapshot used for acceptance. */ +export function readAcceptedProfileStateSnapshot( + db: Database.Database, + rawJson: string +): ProfileStateSnapshot | undefined { + return readAcceptedSnapshot(db, rawJson, () => readProfileStateSnapshot(db)) +} + +export function readAcceptedProfileStateParsedSnapshot( + db: Database.Database, + rawJson: string +): ProfileStateParsedSnapshot | undefined { + return readAcceptedSnapshot(db, rawJson, () => readProfileStateParsedSnapshot(db)) +} + +function readAcceptedSnapshot( + db: Database.Database, + rawJson: string, + read: () => T +): T | undefined { + return withProfileStateReadSnapshot(db, () => { + const marker = readProfileStateJsonAcceptance(db) + const snapshot = read() + const jsonHash = hashProfileStateJson(rawJson) + return marker !== undefined && + (marker.jsonHash === jsonHash || marker.pending?.jsonHash === jsonHash) && + snapshot.revision >= (marker.pending?.acceptedRevision ?? marker.acceptedRevision) + ? snapshot + : undefined + }) +} + +/** Transfer independently validated values without constructing another whole-profile string. */ +export function readProfileStateParsedSnapshot(db: Database.Database): ProfileStateParsedSnapshot { + return withProfileStateReadSnapshot(db, () => { + const revision = readProfileStateRevision(db) + const documents = readProfileStateDocuments(db, { + profileRevision: revision, + representation: 'parsed' + }) + return { + revision, + state: Object.fromEntries(documents.map((document) => [document.domain, document.value])) + } + }) +} + +/** Export the row set as JSON accepted by the current loader. */ +export function exportProfileStateJson(db: Database.Database): string { + return readProfileStateSnapshot(db).json +} + +/** Read revision, rows, and their JSON projection under one SQLite snapshot. */ +export function readProfileStateSnapshot(db: Database.Database): ProfileStateSnapshot { + return withProfileStateReadSnapshot(db, () => { + const revision = readProfileStateRevision(db) + const documents = readProfileStateDocuments(db, { profileRevision: revision }) + return { + revision, + documents, + // Every payload was already hash- and JSON-validated above. Reusing the + // validated fragments avoids parsing and stringifying the full profile a + // second time before Store parses it at its domain boundary. + json: `{${documents + .map((document) => `${JSON.stringify(document.domain)}:${document.payload}`) + .join(',')}}` + } + }) +} diff --git a/src/main/persistence/profile-state/profile-state-domain-equality.test.ts b/src/main/persistence/profile-state/profile-state-domain-equality.test.ts new file mode 100644 index 000000000000..b58c1d555c5b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-equality.test.ts @@ -0,0 +1,128 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateRevision, + readProfileStateSnapshot +} from './profile-state-documents' +import { writeProfileStateDomains } from './profile-state-domain-writes' + +const databases: { db: ReturnType['db']; directory: string }[] = [] + +function fixture(value: unknown = { future: { content: '雪 🐋', nullable: null } }) { + const directory = mkdtempSync(join(tmpdir(), 'orca-domain-equality-')) + const { db } = openProfileStateDatabase(join(directory, 'state.db'), 'profile') + importProfileStateJson(db, JSON.stringify({ settings: {}, extension: value })) + databases.push({ db, directory }) + return { db, payload: JSON.stringify(value) } +} + +afterEach(() => { + for (const { db, directory } of databases.splice(0)) { + db.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('validated domain replacement equality', () => { + it.each([{ nested: { content: '雪 🐋', nullable: null } }, null])( + 'preserves equal payload, revision and timestamp for %j', + (value) => { + const { db, payload } = fixture(value) + const before = readProfileStateSnapshot(db) + const rows = db.prepare('SELECT * FROM profile_state_documents').all() + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { + domain: 'extension', + payload, + now: () => { + throw new Error('An unchanged replacement must not request a timestamp') + } + } + ] + }) + ).toEqual({ changed: false, revision: 1, changedDomains: [] }) + expect(readProfileStateSnapshot(db)).toEqual(before) + expect(db.prepare('SELECT * FROM profile_state_documents').all()).toEqual(rows) + + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'extension', payload: null }] + }) + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ settings: {} }) + } + ) + + it.each([ + ["content_hash = 'invalid'", /metadata is invalid/], + [`content_hash = '${'a'.repeat(64)}'`, /hash mismatch/], + ['domain_version = 0', /metadata is invalid/], + ['updated_at = -1', /metadata is invalid/], + ['revision = 0', /metadata is invalid/], + ['revision = 2', /exceeds profile revision/] + ] as const)('rejects equal payload with corrupt %s', (assignment, error) => { + const { db, payload } = fixture() + db.exec(`UPDATE profile_state_documents SET ${assignment} WHERE domain = 'extension'`) + const before = db.prepare('SELECT * FROM profile_state_documents').all() + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'settings', payload: '{"changed":true}' }, + { domain: 'extension', payload } + ] + }) + ).toThrow(error) + expect(readProfileStateRevision(db)).toBe(1) + expect(db.isTransaction).toBe(false) + expect(db.prepare('SELECT * FROM profile_state_documents').all()).toEqual(before) + }) + + it.each(['{', '{"valid":true}', null])( + 'rejects malformed stored JSON with a matching hash on replacement %j', + (payload) => { + const { db } = fixture() + db.prepare( + "UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = 'extension'" + ).run('{', hashProfileStateJson('{')) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'extension', payload }] + }) + ).toThrow(/invalid JSON: extension/) + expect(readProfileStateRevision(db)).toBe(1) + expect(db.isTransaction).toBe(false) + expect( + db.prepare("SELECT payload FROM profile_state_documents WHERE domain = 'extension'").get() + ).toEqual({ payload: '{' }) + } + ) + + it('fences a stale writer even when its payload remains equal', () => { + const { db, payload } = fixture() + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'settings', payload: '{"changed":true}' }] + }) + const before = readProfileStateSnapshot(db) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'extension', payload }] + }) + ).toThrow(expect.objectContaining({ code: 'profile-state-revision-conflict' })) + expect(readProfileStateSnapshot(db)).toEqual(before) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-domain-reader.test.ts b/src/main/persistence/profile-state/profile-state-domain-reader.test.ts new file mode 100644 index 000000000000..fc34c14fe490 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-reader.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { importProfileStateJson } from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import { readProfileStateDomains } from './profile-state-domain-reader' +import { writeProfileStateDomains } from './profile-state-domain-writes' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createDatabase(): { directory: string; databasePath: string } { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-domain-reader-')) + temporaryDirectories.push(directory) + return { directory, databasePath: profileStateDatabaseFile(directory) } +} + +describe('profile state domain reader', () => { + it('does not parse unrelated domains', () => { + const { databasePath } = createDatabase() + const opened = openProfileStateDatabase(databasePath, 'profile-a') + importProfileStateJson( + opened.db, + JSON.stringify({ settings: { theme: 'dark' }, unrelated: { keep: true } }) + ) + opened.db + .prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?') + .run('{invalid', 'unrelated') + opened.db.close() + + const result = readProfileStateDomains(databasePath, 'profile-a', ['settings']) + expect(result).toEqual({ + kind: 'values', + revision: 1, + values: new Map([['settings', { theme: 'dark' }]]) + }) + }) + + it('fails closed when a selected domain is corrupt', () => { + const { databasePath } = createDatabase() + const opened = openProfileStateDatabase(databasePath, 'profile-a') + importProfileStateJson(opened.db, JSON.stringify({ settings: { theme: 'dark' } })) + opened.db + .prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?') + .run('{invalid', 'settings') + opened.db.close() + + const result = readProfileStateDomains(databasePath, 'profile-a', ['settings']) + expect(result.kind).toBe('unreadable') + }) + + it('reads the normalized automation projection when selected', () => { + const { databasePath } = createDatabase() + const opened = openProfileStateDatabase(databasePath, 'profile-a') + importProfileStateJson( + opened.db, + JSON.stringify({ automationRuns: [{ id: 'run-1', status: 'pending' }] }) + ) + opened.db.close() + + const result = readProfileStateDomains(databasePath, 'profile-a', ['automationRuns']) + expect(result).toEqual({ + kind: 'values', + revision: 1, + values: new Map([['automationRuns', [{ id: 'run-1', status: 'pending' }]]]) + }) + }) + + it('preserves missing versus explicit null automation runs', () => { + const missing = createDatabase() + const missingOpened = openProfileStateDatabase(missing.databasePath, 'profile-a') + importProfileStateJson(missingOpened.db, JSON.stringify({ settings: { theme: 'dark' } })) + missingOpened.db.close() + const missingResult = readProfileStateDomains(missing.databasePath, 'profile-a', [ + 'automationRuns' + ]) + expect(missingResult.kind).toBe('values') + expect( + missingResult.kind === 'values' ? missingResult.values.has('automationRuns') : true + ).toBe(false) + + const explicitNull = createDatabase() + const nullOpened = openProfileStateDatabase(explicitNull.databasePath, 'profile-a') + importProfileStateJson( + nullOpened.db, + JSON.stringify({ settings: { theme: 'dark' }, automationRuns: null }) + ) + nullOpened.db.close() + const nullResult = readProfileStateDomains(explicitNull.databasePath, 'profile-a', [ + 'automationRuns' + ]) + expect(nullResult).toEqual({ + kind: 'values', + revision: 1, + values: new Map([['automationRuns', null]]) + }) + }) + + it('does not resurrect a stale legacy automation row after normalized deletion', () => { + const { databasePath } = createDatabase() + const opened = openProfileStateDatabase(databasePath, 'profile-a') + importProfileStateJson( + opened.db, + JSON.stringify({ automationRuns: [{ id: 'run-1', status: 'pending' }] }) + ) + writeProfileStateDomains(opened.db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload: null }] + }) + opened.db.close() + + const result = readProfileStateDomains(databasePath, 'profile-a', ['automationRuns']) + expect(result.kind).toBe('values') + expect(result.kind === 'values' ? result.values.has('automationRuns') : true).toBe(false) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-domain-reader.ts b/src/main/persistence/profile-state/profile-state-domain-reader.ts new file mode 100644 index 000000000000..bd194e1edca9 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-reader.ts @@ -0,0 +1,110 @@ +import { withProfileStateReadSnapshot } from './profile-state-read-snapshot' +import { + assertProfileStateDocumentRevision, + readProfileStateRevision +} from './profile-state-revision' +import { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs' +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { + validateProfileStateDocumentRow, + type ProfileStateDocument +} from './profile-state-document-validation' + +export type ProfileStateDomainReadResult = + | { kind: 'value'; value: unknown } + | { kind: 'missing' } + | { kind: 'unreadable'; error: unknown } + +export type ProfileStateDomainsReadResult = + | { kind: 'values'; revision: number; values: ReadonlyMap } + | { kind: 'unreadable'; error: unknown } + +/** + * Read one domain from an existing profile database without opening a write + * handle, applying migrations, or creating the database. Any malformed selected + * row makes the whole read unusable so callers that prune state can fail closed. + */ +export function readProfileStateDomain( + databasePath: string, + profileId: string, + domain: string +): ProfileStateDomainReadResult { + const result = readProfileStateDomains(databasePath, profileId, [domain]) + if (result.kind === 'unreadable') { + return result + } + if (!result.values.has(domain)) { + return { kind: 'missing' } + } + return { kind: 'value', value: result.values.get(domain) } +} + +/** Read several domains and the fencing revision under one SQLite snapshot. */ +export function readProfileStateDomains( + databasePath: string, + profileId: string, + domains: readonly string[] +): ProfileStateDomainsReadResult { + let opened: ReturnType | undefined + try { + opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + return readProfileStateDomainsWithRevisionFromDatabase(opened.db, domains) + } catch (error) { + return { kind: 'unreadable', error } + } finally { + opened?.db.close() + } +} + +/** Read several domains from an already-open database, keeping the caller's handle alive. */ +export function readProfileStateDomainsWithRevisionFromDatabase( + db: Parameters[0], + domains: readonly string[] +): ProfileStateDomainsReadResult { + const wanted = new Set(domains) + const values = new Map() + try { + return withProfileStateReadSnapshot(db, () => { + const revision = readProfileStateRevision(db) + if (wanted.size === 0) { + return { kind: 'values', revision, values } + } + + const normalized = wanted.has('automationRuns') + ? readProfileStateAutomationRunsDocument(db, revision) + : undefined + const legacyDomains = [...wanted].filter( + (domain) => domain !== 'automationRuns' || normalized === undefined + ) + for (const document of readSelectedDocuments(db, legacyDomains)) { + assertProfileStateDocumentRevision(document.revision, revision, document.domain) + values.set(document.domain, JSON.parse(document.payload)) + } + if (normalized !== undefined && normalized !== null) { + values.set(normalized.domain, JSON.parse(normalized.payload)) + } + return { kind: 'values', revision, values } + }) + } catch (error) { + return { kind: 'unreadable', error } + } +} + +function readSelectedDocuments( + db: Parameters[0], + domains: readonly string[] +): readonly ProfileStateDocument[] { + if (domains.length === 0) { + return [] + } + const placeholders = domains.map(() => '?').join(',') + const rows = db + .prepare( + `SELECT domain, payload, domain_version, revision, updated_at, content_hash + FROM profile_state_documents + WHERE domain IN (${placeholders}) + ORDER BY rowid` + ) + .all(...domains) + return rows.map((row) => validateProfileStateDocumentRow(row)) +} diff --git a/src/main/persistence/profile-state/profile-state-domain-write-validation.ts b/src/main/persistence/profile-state/profile-state-domain-write-validation.ts new file mode 100644 index 000000000000..44d982161cf7 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-write-validation.ts @@ -0,0 +1,72 @@ +import { PROFILE_STATE_DOCUMENT_VERSION } from './profile-state-database-schema' +import { hashProfileStateJson } from './profile-state-documents' +import type { AutomationRunsWritePreparation } from './profile-state-automation-runs' +import type { + ProfileStateDomainMutation, + ProfileStateDomainTransaction +} from './profile-state-domain-writes' + +export type PreparedProfileStateMutation = ProfileStateDomainMutation & { + domainVersion: number + payloadHash: string | null + automationRuns?: AutomationRunsWritePreparation +} + +export function validateProfileStateDomainTransaction( + transaction: ProfileStateDomainTransaction +): void { + if (!Number.isSafeInteger(transaction.expectedRevision) || transaction.expectedRevision < 0) { + throw new Error('Profile state expected revision is invalid') + } + if ( + !Array.isArray(transaction.replacements) || + (transaction.replacements.length === 0 && transaction.automationRunsAfter === undefined) + ) { + throw new Error('Profile state domain transaction requires at least one replacement') + } + const domains = new Set() + for (const replacement of transaction.replacements) { + validateProfileStateDomainMutation(replacement) + if (domains.has(replacement.domain)) { + throw new Error(`Profile state domain transaction repeats domain: ${replacement.domain}`) + } + domains.add(replacement.domain) + } + if (transaction.automationRunsAfter !== undefined && domains.has('automationRuns')) { + throw new Error('Profile state automationRuns delta repeats domain: automationRuns') + } +} + +export function prepareProfileStateDomainMutation( + replacement: ProfileStateDomainMutation +): PreparedProfileStateMutation { + const payloadHash = + replacement.payload === null ? null : hashProfileStateJson(replacement.payload) + if (replacement.payload !== null) { + try { + JSON.parse(replacement.payload) + } catch { + throw new Error(`Profile state domain payload is invalid JSON: ${replacement.domain}`) + } + } + return { + ...replacement, + domainVersion: replacement.domainVersion ?? PROFILE_STATE_DOCUMENT_VERSION, + payloadHash + } +} + +function validateProfileStateDomainMutation(replacement: ProfileStateDomainMutation): void { + if (typeof replacement.domain !== 'string' || replacement.domain.length === 0) { + throw new Error('Profile state domain name cannot be empty') + } + if (replacement.payload !== null && typeof replacement.payload !== 'string') { + throw new Error(`Profile state domain payload is invalid: ${replacement.domain}`) + } + if ( + replacement.domainVersion !== undefined && + (!Number.isSafeInteger(replacement.domainVersion) || replacement.domainVersion < 1) + ) { + throw new Error('Profile state domain version is invalid') + } +} diff --git a/src/main/persistence/profile-state/profile-state-domain-writes.test.ts b/src/main/persistence/profile-state/profile-state-domain-writes.test.ts new file mode 100644 index 000000000000..8c54ee2360f1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-writes.test.ts @@ -0,0 +1,585 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + exportProfileStateJson, + importProfileStateJson, + readProfileStateDocuments, + readProfileStateRevision +} from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import { + ProfileStateRevisionConflictError, + writeProfileStateDomain, + writeProfileStateDomains +} from './profile-state-domain-writes' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function openTestDatabase(): ReturnType['db'] { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-domain-write-')) + temporaryDirectories.push(directory) + return openProfileStateDatabase(profileStateDatabaseFile(directory), 'profile-a').db +} + +describe('profile state dirty-domain writes', () => { + it('retains logical history order when existing runs change position', () => { + const db = openTestDatabase() + const runs = [{ id: 'first' }, { id: 'second' }, { id: 'third' }] as const + const reordered = [runs[2], runs[0], runs[1]] + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: runs })) + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify(reordered), + expectedRevision: 1 + }) + + expect( + db.prepare('SELECT run_id FROM profile_state_automation_runs ORDER BY rowid').all() + ).toEqual(runs.map((run) => ({ run_id: run.id }))) + expect(JSON.parse(exportProfileStateJson(db)).automationRuns).toEqual(reordered) + + db.prepare('UPDATE profile_state_automation_runs SET ordinal = 0 WHERE run_id = ?').run( + 'first' + ) + expect(() => exportProfileStateJson(db)).toThrow( + 'Normalized automationRuns ordering is corrupt' + ) + } finally { + db.close() + } + }) + + it('updates automationRuns without rewriting unrelated domains', () => { + const db = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }] + }), + { now: () => 100 } + ) + const settingsBefore = readProfileStateDocuments(db).find( + (document) => document.domain === 'settings' + ) + + const result = writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1, + now: () => 200 + }) + + expect(result).toEqual({ changed: true, revision: 2 }) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'completed' }] + }) + expect(readProfileStateDocuments(db)).toEqual([ + expect.objectContaining({ domain: 'settings', revision: 1, updatedAt: 100 }), + expect.objectContaining({ domain: 'automationRuns', revision: 2, updatedAt: 200 }) + ]) + const settingsAfter = readProfileStateDocuments(db).find( + (document) => document.domain === 'settings' + ) + expect(settingsAfter).toMatchObject({ + payload: settingsBefore?.payload, + contentHash: settingsBefore?.contentHash, + updatedAt: settingsBefore?.updatedAt + }) + } finally { + db.close() + } + }) + + it('commits changed automation runs as rows without rewriting the legacy document blob', () => { + const db = openTestDatabase() + try { + const fixtureRun = buildProfileStateCutoverFixture().automationRuns[0] + if (!fixtureRun) { + throw new Error('Expected automation run fixture') + } + const initialRuns = [ + { ...fixtureRun, id: 'run-1', status: 'pending' as const }, + { ...fixtureRun, id: 'run-2', status: 'completed' as const } + ] + importProfileStateJson( + db, + JSON.stringify({ + settings: { theme: 'dark' }, + automationRuns: initialRuns + }), + { now: () => 100 } + ) + const legacyDocument = db + .prepare('SELECT payload, content_hash FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + + const result = writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [], + automationRunsAfter: [{ ...initialRuns[0], status: 'dispatched' as const }, initialRuns[1]] + }) + + expect(result).toEqual({ + changed: true, + revision: 2, + changedDomains: ['automationRuns'] + }) + expect( + db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get() + ).toEqual({ count: 2 }) + expect( + db + .prepare('SELECT payload, content_hash FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual(legacyDocument) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'dark' }, + automationRuns: [{ ...initialRuns[0], status: 'dispatched' }, initialRuns[1]] + }) + } finally { + db.close() + } + }) + + it('rolls back a direct automation-run delta and revision when normalized metadata rejects it', () => { + const db = openTestDatabase() + try { + const fixtureRun = buildProfileStateCutoverFixture().automationRuns[0] + if (!fixtureRun) { + throw new Error('Expected automation run fixture') + } + const initialRuns = [{ ...fixtureRun, id: 'run-1', status: 'pending' as const }] + importProfileStateJson(db, JSON.stringify({ automationRuns: initialRuns }), { + now: () => 100 + }) + const normalizedRuns = [{ ...initialRuns[0], status: 'dispatched' as const }] + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [], + automationRunsAfter: normalizedRuns + }) + ).toEqual({ changed: true, revision: 2, changedDomains: ['automationRuns'] }) + db.exec( + `CREATE TRIGGER fail_profile_state_automation_run_delta + BEFORE UPDATE ON profile_state_automation_runs_meta + WHEN NEW.domain = 'automationRuns' + BEGIN SELECT RAISE(ABORT, 'injected automation delta failure'); END` + ) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 2, + replacements: [], + automationRunsAfter: [{ ...normalizedRuns[0], status: 'completed' as const }] + }) + ).toThrow('injected automation delta failure') + db.exec('DROP TRIGGER fail_profile_state_automation_run_delta') + expect(readProfileStateRevision(db)).toBe(2) + expect(JSON.parse(exportProfileStateJson(db)).automationRuns).toEqual(normalizedRuns) + } finally { + db.close() + } + }) + + it('fails closed when a normalized automation-run row is corrupt', () => { + const db = openTestDatabase() + try { + const fixtureRun = buildProfileStateCutoverFixture().automationRuns[0] + if (!fixtureRun) { + throw new Error('Expected automation run fixture') + } + const runs = [{ ...fixtureRun, id: 'run-1', status: 'pending' as const }] + importProfileStateJson(db, JSON.stringify({ automationRuns: runs })) + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [], + automationRunsAfter: runs + }) + db.prepare('UPDATE profile_state_automation_runs SET payload = ? WHERE run_id = ?').run( + JSON.stringify({ ...runs[0], status: 'tampered' }), + 'run-1' + ) + + expect(() => exportProfileStateJson(db)).toThrow('Normalized automationRuns row is corrupt') + } finally { + db.close() + } + }) + + it('retains unchanged run row revisions while updating the aggregate projection', () => { + const db = openTestDatabase() + try { + const fixtureRuns = buildProfileStateCutoverFixture().automationRuns + const first = fixtureRuns[0] + if (!first) { + throw new Error('Expected automation run fixture') + } + const initialRuns = [ + { ...first, id: 'run-1', status: 'pending' as const }, + { ...first, id: 'run-2', status: 'dispatched' as const } + ] + importProfileStateJson(db, JSON.stringify({ automationRuns: initialRuns })) + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [], + automationRunsAfter: [{ ...initialRuns[0], status: 'completed' as const }, initialRuns[1]] + }) + writeProfileStateDomains(db, { + expectedRevision: 2, + replacements: [], + automationRunsAfter: [ + { ...initialRuns[0], status: 'dispatch_failed' as const }, + initialRuns[1] + ] + }) + + expect( + db + .prepare('SELECT revision FROM profile_state_automation_runs WHERE run_id = ?') + .get('run-2') + ).toEqual({ revision: 1 }) + expect(readProfileStateRevision(db)).toBe(3) + expect(JSON.parse(exportProfileStateJson(db)).automationRuns).toEqual([ + { ...initialRuns[0], status: 'dispatch_failed' }, + initialRuns[1] + ]) + } finally { + db.close() + } + }) + + it('commits several changed domains at one shared revision', () => { + const db = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }], + ui: { activeView: 'terminal' } + }), + { now: () => 100 } + ) + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { + domain: 'settings', + payload: JSON.stringify({ theme: 'light' }), + now: () => 200 + }, + { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + now: () => 201 + } + ] + }) + ).toEqual({ + changed: true, + revision: 2, + changedDomains: ['settings', 'automationRuns'] + }) + expect(readProfileStateRevision(db)).toBe(2) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'light' }, + automationRuns: [{ id: 'run-1', status: 'completed' }], + ui: { activeView: 'terminal' } + }) + expect(readProfileStateDocuments(db)).toEqual([ + expect.objectContaining({ domain: 'settings', revision: 2, updatedAt: 200 }), + expect.objectContaining({ domain: 'automationRuns', revision: 2, updatedAt: 201 }), + expect.objectContaining({ domain: 'ui', revision: 1, updatedAt: 100 }) + ]) + } finally { + db.close() + } + }) + + it('rolls back every domain when a later mutation fails', () => { + const db = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }] + }), + { now: () => 100 } + ) + db.exec( + `CREATE TRIGGER fail_second_profile_state_domain_update + BEFORE UPDATE ON profile_state_automation_runs_meta + WHEN NEW.domain = 'automationRuns' + BEGIN SELECT RAISE(ABORT, 'injected domain failure'); END` + ) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'settings', payload: JSON.stringify({ theme: 'light' }) }, + { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]) + } + ] + }) + ).toThrow('injected domain failure') + db.exec('DROP TRIGGER fail_second_profile_state_domain_update') + expect(readProfileStateRevision(db)).toBe(1) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }] + }) + } finally { + db.close() + } + }) + + it('fences a stale multi-domain transaction before changing either row', () => { + const db = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ settings: { theme: 'dark' }, ui: { activeView: 'terminal' } }) + ) + writeProfileStateDomain(db, { + domain: 'settings', + payload: JSON.stringify({ theme: 'light' }), + expectedRevision: 1 + }) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'settings', payload: JSON.stringify({ theme: 'blue' }) }, + { domain: 'ui', payload: JSON.stringify({ activeView: 'browser' }) } + ] + }) + ).toThrowError(ProfileStateRevisionConflictError) + expect(readProfileStateRevision(db)).toBe(2) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'light' }, + ui: { activeView: 'terminal' } + }) + } finally { + db.close() + } + }) + + it('rejects duplicate domains before opening a transaction', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'settings', payload: JSON.stringify({ theme: 'light' }) }, + { domain: 'settings', payload: JSON.stringify({ theme: 'blue' }) } + ] + }) + ).toThrow('repeats domain: settings') + expect(readProfileStateRevision(db)).toBe(1) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ settings: { theme: 'dark' } }) + } finally { + db.close() + } + }) + + it('fences a stale Store and leaves the database unchanged', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1 + }) + + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'failed' }]), + expectedRevision: 1 + }) + ).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 1, + actualRevision: 2 + }) + ) + expect(readProfileStateRevision(db)).toBe(2) + expect(JSON.parse(exportProfileStateJson(db)).automationRuns).toEqual([ + { id: 'run-1', status: 'completed' } + ]) + } finally { + db.close() + } + }) + + it('fences two independently opened database writers with the shared revision', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-domain-writer-fence-')) + temporaryDirectories.push(directory) + const databasePath = profileStateDatabaseFile(directory) + const first = openProfileStateDatabase(databasePath, 'profile-a').db + const second = openProfileStateDatabase(databasePath, 'profile-a').db + try { + importProfileStateJson(first, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + expect(readProfileStateRevision(second)).toBe(1) + writeProfileStateDomain(first, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1 + }) + + expect(() => + writeProfileStateDomain(second, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'failed' }]), + expectedRevision: 1 + }) + ).toThrowError(ProfileStateRevisionConflictError) + expect(readProfileStateRevision(second)).toBe(2) + } finally { + first.close() + second.close() + } + }) + + it('does not advance revision for an identical replacement or absent delete', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + expect( + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1' }]), + expectedRevision: 1 + }) + ).toEqual({ changed: false, revision: 1 }) + expect( + writeProfileStateDomain(db, { + domain: 'missingDomain', + payload: null, + expectedRevision: 1 + }) + ).toEqual({ changed: false, revision: 1 }) + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) + + it('distinguishes explicit JSON null from deleting a domain', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + expect( + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: 'null', + expectedRevision: 1 + }) + ).toEqual({ changed: true, revision: 2 }) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ automationRuns: null }) + + expect( + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: null, + expectedRevision: 2 + }) + ).toEqual({ changed: true, revision: 3 }) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({}) + } finally { + db.close() + } + }) + + it('rolls back the row and revision when SQLite rejects the replacement', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + db.exec( + `CREATE TRIGGER fail_profile_state_domain_update + BEFORE UPDATE ON profile_state_automation_runs_meta + WHEN NEW.domain = 'automationRuns' + BEGIN SELECT RAISE(ABORT, 'injected domain failure'); END` + ) + + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'failed' }]), + expectedRevision: 1 + }) + ).toThrow('injected domain failure') + db.exec('DROP TRIGGER fail_profile_state_domain_update') + expect(readProfileStateRevision(db)).toBe(1) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + automationRuns: [{ id: 'run-1' }] + }) + } finally { + db.close() + } + }) + + it('rejects malformed payloads before opening a transaction', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: '{invalid', + expectedRevision: 1 + }) + ).toThrow('Profile state domain payload is invalid JSON: automationRuns') + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) + + it('fails closed when the target row hash is already corrupt', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + db.prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?').run( + JSON.stringify([{ id: 'tampered' }]), + 'automationRuns' + ) + + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1 + }) + ).toThrow('Profile state document hash mismatch: automationRuns') + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-domain-writes.ts b/src/main/persistence/profile-state/profile-state-domain-writes.ts new file mode 100644 index 000000000000..646f4ddbfb94 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-writes.ts @@ -0,0 +1,232 @@ +import { withProfileStateWriteTransaction } from './profile-state-write-transaction' +import type Database from '../../sqlite/sync-database' +import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' +import { + PROFILE_STATE_DOCUMENT_VERSION, + PROFILE_STATE_META_REVISION +} from './profile-state-database-schema' +import { + ProfileStateRevisionConflictError, + readProfileStateRevision +} from './profile-state-documents' +import { + applyProfileStateAutomationRuns, + clearProfileStateAutomationRuns, + prepareProfileStateAutomationRunsDelta, + prepareProfileStateAutomationRunsReplacement +} from './profile-state-automation-runs' +import type { AutomationRun } from '../../../shared/automations-types' +import { isRecord, validateProfileStateDocumentRow } from './profile-state-document-validation' +import { assertProfileStateDocumentRevision } from './profile-state-revision' +import { + prepareProfileStateDomainMutation, + validateProfileStateDomainTransaction, + type PreparedProfileStateMutation +} from './profile-state-domain-write-validation' + +export { ProfileStateRevisionConflictError } from './profile-state-documents' + +/** A storage-form replacement for one top-level profile-state domain. */ +export type ProfileStateDomainReplacement = { + /** Non-empty top-level domain name, for example `automationRuns`. */ + domain: string + /** Canonical JSON payload, or null to remove the domain row. */ + payload: string | null + /** Revision observed by the caller before it built this replacement. */ + expectedRevision: number + domainVersion?: number + now?: () => number +} + +/** One row mutation inside a transaction that may update several domains. */ +export type ProfileStateDomainMutation = Omit + +/** + * A set of domain mutations guarded by one profile revision. + * + * A profile revision is shared by every row, so checking it once at the start + * of the transaction gives callers an atomic cross-domain compare-and-swap. + */ +export type ProfileStateDomainTransaction = { + expectedRevision: number + replacements: readonly ProfileStateDomainMutation[] + /** Changed run projection supplied by Store for selective row updates. */ + automationRunsAfter?: readonly AutomationRun[] +} + +export type ProfileStateDomainWriteResult = { + changed: boolean + revision: number +} + +export type ProfileStateDomainTransactionResult = ProfileStateDomainWriteResult & { + changedDomains: readonly string[] +} + +/** + * Replace one domain without serializing or rewriting the other domains. + * + * The caller supplies the revision it read with the domain. SQLite's + * `BEGIN IMMEDIATE` plus the exact revision check fences stale Store instances + * before the row and profile revision are changed. A null payload deletes the + * row; the JSON literal `null` remains an explicit domain value. + */ +export function writeProfileStateDomain( + db: Database.Database, + replacement: ProfileStateDomainReplacement +): ProfileStateDomainWriteResult { + const result = writeProfileStateDomains(db, { + expectedRevision: replacement.expectedRevision, + replacements: [ + { + domain: replacement.domain, + payload: replacement.payload, + domainVersion: replacement.domainVersion, + now: replacement.now + } + ] + }) + return { changed: result.changed, revision: result.revision } +} + +/** + * Replace one or more domains in one SQLite transaction. + * + * Every changed row receives the same next profile revision. If any row + * validation, payload write, or commit step fails, SQLite rolls back all row + * changes and the profile revision remains unchanged. + */ +export function writeProfileStateDomains( + db: Database.Database, + transaction: ProfileStateDomainTransaction +): ProfileStateDomainTransactionResult { + validateProfileStateDomainTransaction(transaction) + + const prepared = transaction.replacements.map(prepareProfileStateDomainMutation) + + return withProfileStateWriteTransaction(db, () => { + const actualRevision = readProfileStateRevision(db) + if (actualRevision !== transaction.expectedRevision) { + throw new ProfileStateRevisionConflictError(transaction.expectedRevision, actualRevision) + } + const currentAutomationRuns = readCurrentAutomationRunsState(db, actualRevision) + + const updates: PreparedProfileStateMutation[] = [] + for (const mutation of prepared) { + const existing = db + .prepare( + `SELECT domain, payload, domain_version, revision, updated_at, content_hash + FROM profile_state_documents WHERE domain = ?` + ) + .get(mutation.domain) + const existingRow = + existing === undefined + ? undefined + : validateProfileStateDocumentRow(existing, { + // An identical incoming payload has already passed JSON validation. + validateJson: !(isRecord(existing) && existing.payload === mutation.payload) + }) + if (existingRow) { + assertProfileStateDocumentRevision(existingRow.revision, actualRevision, mutation.domain) + } + if (mutation.domain === 'automationRuns') { + // Canonical history already has this hash; unchanged rows need no new projection. + if ( + currentAutomationRuns.presence === 'array' && + mutation.payload?.startsWith('[') && + currentAutomationRuns.contentHash === mutation.payloadHash + ) { + continue + } + const normalized = prepareProfileStateAutomationRunsReplacement( + db, + mutation, + actualRevision + ) + if (normalized !== undefined) { + if (normalized.changed) { + updates.push({ ...mutation, automationRuns: normalized }) + } + continue + } + } + const unchanged = + (mutation.payload === null && existingRow === undefined) || + (mutation.payload !== null && existingRow?.payload === mutation.payload) + if (!unchanged) { + updates.push(mutation) + } + } + if (transaction.automationRunsAfter !== undefined) { + const delta = prepareProfileStateAutomationRunsDelta( + db, + transaction.automationRunsAfter, + PROFILE_STATE_DOCUMENT_VERSION, + Date.now, + actualRevision + ) + if (delta === undefined) { + throw new Error('Normalized automationRuns delta is unsupported') + } + if (delta.changed) { + updates.push({ + domain: 'automationRuns', + payload: null, + domainVersion: PROFILE_STATE_DOCUMENT_VERSION, + payloadHash: null, + automationRuns: delta + }) + } + } + + if (updates.length === 0) { + return { changed: false, revision: actualRevision, changedDomains: [] } + } + + const nextRevision = actualRevision + 1 + for (const mutation of updates) { + if (mutation.automationRuns) { + applyProfileStateAutomationRuns(db, mutation.automationRuns, nextRevision) + continue + } + if (mutation.domain === 'automationRuns') { + clearProfileStateAutomationRuns(db) + } + if (mutation.payload === null) { + db.prepare('DELETE FROM profile_state_documents WHERE domain = ?').run(mutation.domain) + } else { + const updatedAt = (mutation.now ?? Date.now)() + if (!Number.isSafeInteger(updatedAt) || updatedAt < 0) { + throw new Error(`Profile state domain update timestamp is invalid: ${mutation.domain}`) + } + db.prepare( + `INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(domain) DO UPDATE SET + payload = excluded.payload, + domain_version = excluded.domain_version, + revision = excluded.revision, + updated_at = excluded.updated_at, + content_hash = excluded.content_hash` + ).run( + mutation.domain, + mutation.payload, + mutation.domainVersion, + nextRevision, + updatedAt, + mutation.payloadHash + ) + } + } + db.prepare( + `INSERT INTO profile_state_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(PROFILE_STATE_META_REVISION, String(nextRevision)) + return { + changed: true, + revision: nextRevision, + changedDomains: updates.map(({ domain }) => domain) + } + }) +} diff --git a/src/main/persistence/profile-state/profile-state-export-path.ts b/src/main/persistence/profile-state/profile-state-export-path.ts new file mode 100644 index 000000000000..34332e86fa0a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-export-path.ts @@ -0,0 +1,36 @@ +import { existsSync, readdirSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' + +/** Return the immutable JSON artifact created when a profile first enters SQLite authority. */ +export function profileStateJsonExportPath(dataFile: string, revision: number): string { + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new Error('Profile state export revision must be a positive safe integer') + } + return `${dataFile}.sqlite-export.${revision}.json` +} + +/** List retained rollback exports newest-first without opening SQLite. */ +export function profileStateJsonExportPaths(dataFile: string): readonly string[] { + const directory = dirname(dataFile) + const prefix = `${basename(dataFile)}.sqlite-export.` + if (!existsSync(directory)) { + return [] + } + return readdirSync(directory) + .flatMap((name) => { + const match = new RegExp(`^${escapeRegExp(prefix)}(\\d+)\\.json$`).exec(name) + if (match === null) { + return [] + } + const revision = Number(match[1]) + return Number.isSafeInteger(revision) && revision > 0 + ? [{ path: join(directory, name), revision }] + : [] + }) + .sort((left, right) => right.revision - left.revision) + .map(({ path }) => path) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts b/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts new file mode 100644 index 000000000000..5b4a1215da5e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts @@ -0,0 +1,128 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Store } from '../loading-store/store' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateSnapshot, + readProfileStateParsedSnapshot +} from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { createProfileStateStore } from './profile-state-store-factory' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(keepJson = false) { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-fragments-')) + directories.push(directory) + const paths = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: 'fragment-validation' + } + const source = JSON.stringify({ + settings: { theme: 'dark' }, + futureDomain: null, + automationRuns: [{ id: 'a' }, { id: 'b' }] + }) + if (keepJson) { + writeFileSync(paths.dataFile, source) + } + const { db } = openProfileStateDatabase(paths.databaseFile, paths.profileId) + importProfileStateJson(db, source, { acceptedLegacyJsonHash: hashProfileStateJson(source) }) + return { paths, db } +} + +describe('independent profile state JSON fragments', () => { + it.each([false, true])( + 'rejects a domain that injects a valid sibling into startup JSON (retained JSON: %s)', + (keepJson) => { + const { paths, db } = fixture(keepJson) + const payload = 'null,"settings":{"theme":"light"}' + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run(payload, hashProfileStateJson(payload), 'futureDomain') + expect(() => readProfileStateParsedSnapshot(db)).toThrow(/invalid JSON: futureDomain/) + db.close() + + expect(() => { + const result = createProfileStateStore({ ...paths, authorityMode: 'sqlite-established' }) + result.store.freezeWrites() + }).toThrow() + } + ) + + it('rejects a spliced domain through direct authority and Store reads', () => { + const { paths, db } = fixture() + const payload = 'null,"settings":{"theme":"light"}' + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run(payload, hashProfileStateJson(payload), 'futureDomain') + db.close() + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + try { + expect(() => { + const store = new Store({ dataFile: paths.dataFile, profileStateAuthority: authority }) + store.freezeWrites() + }).toThrow(/invalid JSON: futureDomain/) + expect(() => authority.readSerializedState()).toThrow(/invalid JSON: futureDomain/) + } finally { + authority.close() + } + }) + + it.each(['snapshot', 'parsed', 'authority', 'store'] as const)( + 'rejects history rows that form a valid array only when spliced together (%s)', + (boundary) => { + const { paths, db } = fixture() + const payloads = ['{"id":"a","text":"', 'b"},{"id":"b"}'] + const aggregate = `[${payloads.join(',')}]` + expect(JSON.parse(aggregate)).toEqual([{ id: 'a', text: ',b' }, { id: 'b' }]) + for (const [ordinal, payload] of payloads.entries()) { + expect(() => JSON.parse(payload)).toThrow() + db.prepare( + 'UPDATE profile_state_automation_runs SET payload = ?, content_hash = ? WHERE ordinal = ?' + ).run(payload, hashProfileStateJson(payload), ordinal) + } + db.prepare('UPDATE profile_state_automation_runs_meta SET content_hash = ?').run( + hashProfileStateJson(aggregate) + ) + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + try { + expect(() => { + if (boundary === 'snapshot') { + readProfileStateSnapshot(db) + } else if (boundary === 'parsed') { + readProfileStateParsedSnapshot(db) + } else if (boundary === 'authority') { + authority.readSerializedState() + } else { + const store = new Store({ dataFile: paths.dataFile, profileStateAuthority: authority }) + store.freezeWrites() + } + }).toThrow('Normalized automationRuns row is invalid JSON') + } finally { + authority.close() + db.close() + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-json-acceptance.ts b/src/main/persistence/profile-state/profile-state-json-acceptance.ts new file mode 100644 index 000000000000..015a35b2899b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-json-acceptance.ts @@ -0,0 +1,153 @@ +import { withProfileStateWriteTransaction } from './profile-state-write-transaction' +import type Database from '../../sqlite/sync-database' +import { PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE } from './profile-state-database-schema' +import { + hashProfileStatePayload, + isRecord, + parseProfileStateRoot, + ProfileStateDocumentCorruptionError, + ProfileStateRevisionConflictError +} from './profile-state-document-validation' +import { readProfileStateRevision } from './profile-state-revision' + +type ProfileStateJsonAcceptanceVersion = { + jsonHash: string + acceptedRevision: number +} + +export type ProfileStateJsonAcceptance = ProfileStateJsonAcceptanceVersion & { + pending?: ProfileStateJsonAcceptanceVersion +} + +/** Read the source acceptance marker, if this database has one. */ +export function readProfileStateJsonAcceptance( + db: Database.Database +): ProfileStateJsonAcceptance | undefined { + const row = db + .prepare('SELECT value FROM profile_state_meta WHERE key = ?') + .get(PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE) + if (row === undefined) { + return undefined + } + if (!isRecord(row) || typeof row.value !== 'string') { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + let parsed: unknown + try { + parsed = JSON.parse(row.value) + } catch { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + if (!isJsonAcceptanceVersion(parsed)) { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + let pending: ProfileStateJsonAcceptanceVersion | undefined + if ('pending' in parsed) { + if ( + !isJsonAcceptanceVersion(parsed.pending) || + parsed.pending.acceptedRevision < parsed.acceptedRevision + ) { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + pending = parsed.pending + } + return { + jsonHash: parsed.jsonHash, + acceptedRevision: parsed.acceptedRevision, + ...(pending === undefined ? {} : { pending }) + } +} + +function isJsonAcceptanceVersion(value: unknown): value is ProfileStateJsonAcceptanceVersion { + return ( + isRecord(value) && + typeof value.jsonHash === 'string' && + /^[a-f0-9]{64}$/.test(value.jsonHash) && + typeof value.acceptedRevision === 'number' && + Number.isSafeInteger(value.acceptedRevision) && + value.acceptedRevision >= 1 + ) +} + +/** Accept either side of the upcoming JSON replacement before publishing it. */ +export function stageProfileStateJsonCompatibility( + db: Database.Database, + rawJson: string, + expectedRevision: number, + retainedJson?: string +): void { + const retainedHash = + retainedJson === undefined ? undefined : hashProfileStatePayload(retainedJson) + updateProfileStateJsonAcceptance(db, rawJson, expectedRevision, (previous, next) => { + if (previous === undefined) { + return next + } + const retained = + retainedHash === undefined || retainedHash === previous.jsonHash + ? previous + : previous.pending?.jsonHash === retainedHash + ? previous.pending + : undefined + if (retained === undefined) { + throw new ProfileStateDocumentCorruptionError('Compatibility JSON changed before export') + } + return { + jsonHash: retained.jsonHash, + acceptedRevision: retained.acceptedRevision, + pending: next + } + }) +} + +/** Promote the staged JSON after publication; failures leave both versions accepted. */ +export function acceptProfileStateJsonCompatibility( + db: Database.Database, + rawJson: string, + expectedRevision: number +): void { + updateProfileStateJsonAcceptance(db, rawJson, expectedRevision, (previous, next) => { + const staged = previous?.pending ?? previous + if (staged?.jsonHash !== next.jsonHash || staged.acceptedRevision !== next.acceptedRevision) { + throw new ProfileStateDocumentCorruptionError('Compatibility JSON export was not staged') + } + return next + }) +} + +function updateProfileStateJsonAcceptance( + db: Database.Database, + rawJson: string, + expectedRevision: number, + update: ( + previous: ProfileStateJsonAcceptance | undefined, + next: ProfileStateJsonAcceptanceVersion + ) => ProfileStateJsonAcceptance +): void { + parseProfileStateRoot(rawJson) + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) { + throw new ProfileStateDocumentCorruptionError( + 'Compatibility JSON acceptance revision is invalid' + ) + } + return withProfileStateWriteTransaction(db, () => { + const actualRevision = readProfileStateRevision(db) + if (actualRevision !== expectedRevision) { + throw new ProfileStateRevisionConflictError(expectedRevision, actualRevision) + } + const previous = readProfileStateJsonAcceptance(db) + if ( + previous && + (previous.pending?.acceptedRevision ?? previous.acceptedRevision) > actualRevision + ) { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + const marker = update(previous, { + jsonHash: hashProfileStatePayload(rawJson), + acceptedRevision: expectedRevision + }) + db.prepare( + `INSERT INTO profile_state_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE, JSON.stringify(marker)) + }) +} diff --git a/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts b/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts new file mode 100644 index 000000000000..e72f08a145e7 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts @@ -0,0 +1,218 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as durableFiles from '../../durable-file-write' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateJsonAcceptance +} from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { createProfileStateStore } from './profile-state-store-factory' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { isEncryptionAvailable: () => false }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const fixtures: { directory: string; authority: ProfileStateSqliteAuthority }[] = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const { authority, directory } of fixtures.splice(0)) { + authority.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-json-compatibility-recovery-')) + const paths = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: 'compatibility-recovery' + } + const retainedJson = '{"settings":{"theme":"light"}}' + writeFileSync(paths.dataFile, retainedJson) + const withDatabase = ( + run: (db: ReturnType['db']) => T + ): T => { + const opened = openProfileStateDatabase(paths.databaseFile, paths.profileId) + try { + return run(opened.db) + } finally { + opened.db.close() + } + } + withDatabase((db) => + importProfileStateJson(db, retainedJson, { + acceptedLegacyJsonHash: hashProfileStateJson(retainedJson) + }) + ) + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authority.readSerializedState() + authority.writeSerializedState(Buffer.from('{"settings":{"theme":"dark"}}')) + fixtures.push({ directory, authority }) + return { + paths, + authority, + retainedJson, + withDatabase, + acceptance: () => withDatabase(readProfileStateJsonAcceptance), + publish: async (mode: 'sync' | 'async') => + mode === 'sync' + ? authority.writeJsonCompatibilityExport(paths.dataFile) + : authority.writeJsonCompatibilityExportAsync(paths.dataFile), + reopen: () => { + const result = createProfileStateStore({ ...paths, authorityMode: 'sqlite-established' }) + try { + expect(result.backend).toBe('sqlite') + return result.store.getSettings().theme + } finally { + result.store.freezeWrites() + } + } + } +} + +describe.each(['sync', 'async'] as const)('%s compatibility export recovery', (mode) => { + it.each(['staging', 'publication', 'promotion'] as const)( + 'reopens SQLite and permits a later export after failed %s', + async (phase) => { + const state = fixture() + if (phase === 'publication') { + vi.spyOn( + durableFiles, + mode === 'sync' ? 'writeFileDurableSync' : 'writeFileDurable' + ).mockImplementationOnce(() => { + throw new Error('injected publication failure') + }) + } else { + state.withDatabase((db) => + db.exec( + `CREATE TRIGGER reject_acceptance BEFORE INSERT ON profile_state_meta + WHEN NEW.key = 'legacy_json_acceptance' + ${phase === 'promotion' ? "AND json_type(NEW.value, '$.pending') IS NULL" : ''} + BEGIN SELECT RAISE(ABORT, 'injected acceptance failure'); END` + ) + ) + } + + await expect(state.publish(mode)).rejects.toThrow('injected') + + const published = readFileSync(state.paths.dataFile, 'utf8') + expect(JSON.parse(published).settings.theme).toBe(phase === 'promotion' ? 'dark' : 'light') + expect(state.reopen()).toBe('dark') + if (phase !== 'staging') { + expect(state.acceptance()?.pending).toEqual({ + jsonHash: hashProfileStateJson('{"settings":{"theme":"dark"}}'), + acceptedRevision: 2 + }) + } + state.withDatabase((db) => db.exec('DROP TRIGGER IF EXISTS reject_acceptance')) + state.authority.writeSerializedState(Buffer.from('{"settings":{"theme":"system"}}')) + + await state.publish(mode) + + expect(state.reopen()).toBe('system') + expect(state.acceptance()).toEqual({ + jsonHash: hashProfileStateJson(readFileSync(state.paths.dataFile, 'utf8')), + acceptedRevision: 3 + }) + } + ) + + it('keeps the published JSON accepted when a concurrent commit prevents promotion', async () => { + const state = fixture() + const compete = () => { + const other = new ProfileStateSqliteAuthority(state.paths.databaseFile, state.paths.profileId) + try { + other.readSerializedState() + other.writeSerializedState(Buffer.from('{"settings":{"theme":"system"}}')) + } finally { + other.close() + } + } + if (mode === 'sync') { + const write = durableFiles.writeFileDurableSync + vi.spyOn(durableFiles, 'writeFileDurableSync').mockImplementationOnce((...args) => { + write(...args) + compete() + }) + } else { + const write = durableFiles.writeFileDurable + vi.spyOn(durableFiles, 'writeFileDurable').mockImplementationOnce(async (...args) => { + await write(...args) + compete() + }) + } + + await expect(state.publish(mode)).rejects.toMatchObject({ + code: 'profile-state-revision-conflict' + }) + + expect(JSON.parse(readFileSync(state.paths.dataFile, 'utf8')).settings.theme).toBe('dark') + expect(state.reopen()).toBe('system') + expect(state.acceptance()?.pending?.acceptedRevision).toBe(2) + }) + + it('refuses an unrelated edit even while a previous export remains staged', async () => { + const state = fixture() + vi.spyOn( + durableFiles, + mode === 'sync' ? 'writeFileDurableSync' : 'writeFileDurable' + ).mockImplementationOnce(() => { + throw new Error('injected publication failure') + }) + await expect(state.publish(mode)).rejects.toThrow('injected') + const unrelatedJson = '{"settings":{"theme":"system"},"unrelatedEdit":true}' + writeFileSync(state.paths.dataFile, unrelatedJson) + + expect(state.reopen).toThrow('without a matching acceptance marker') + await expect(state.publish(mode)).rejects.toThrow('Compatibility JSON changed before export') + expect(readFileSync(state.paths.dataFile, 'utf8')).toBe(unrelatedJson) + }) +}) + +it.each([ + null, + [], + { jsonHash: 'invalid', acceptedRevision: 2 }, + { jsonHash: 'a'.repeat(64), acceptedRevision: 0 }, + { jsonHash: 'a'.repeat(64), acceptedRevision: 1.5 }, + { jsonHash: 'a'.repeat(64), acceptedRevision: 3 } +])('refuses malformed or impossible pending acceptance %#', (pending) => { + const state = fixture() + state.withDatabase((db) => + db.prepare('UPDATE profile_state_meta SET value = ? WHERE key = ?').run( + JSON.stringify({ + jsonHash: hashProfileStateJson(state.retainedJson), + acceptedRevision: 1, + pending + }), + 'legacy_json_acceptance' + ) + ) + expect(state.reopen).toThrow() + expect(() => state.authority.writeJsonCompatibilityExport(state.paths.dataFile)).toThrow() + expect(readFileSync(state.paths.dataFile, 'utf8')).toBe(state.retainedJson) +}) diff --git a/src/main/persistence/profile-state/profile-state-migration.ts b/src/main/persistence/profile-state/profile-state-migration.ts new file mode 100644 index 000000000000..172927c260a0 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-migration.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import { publishFileDurableSync } from '../../durable-file-write' +import { openProfileStateDatabase } from './profile-state-database' +import { hashProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { profileStateJsonExportPath } from './profile-state-export-path' +import { assertNoRetainedProfileStateExports } from './profile-state-recovery-required' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { + classifyProfileStateStorage, + profileStateDatabaseFiles +} from './profile-state-storage-classification' +import type { ProfileStateAuthorityInitialState } from '../loading-store/profile-state-authority' + +type ProfileStateMigrationOptions = { + dataFile: string + databaseFile: string + profileId: string + expectedLegacyJson: string | undefined + /** Storage-form state; inactive profiles retain sealed secrets without decrypting them. */ + serializedState: string +} + +/** Publish an imported database only after its complete source has committed durably. */ +export function migrateProfileStateToSqlite(options: ProfileStateMigrationOptions): { + authority: ProfileStateSqliteAuthority + initialState: ProfileStateAuthorityInitialState +} { + assertMigrationSourceUnchanged(options) + assertNoRetainedProfileStateExports(options) + mkdirSync(dirname(options.databaseFile), { recursive: true }) + const temporaryDatabaseFile = `${options.databaseFile}.migration.${process.pid}.${randomUUID()}.tmp` + let published = false + try { + const opened = openProfileStateDatabase(temporaryDatabaseFile, options.profileId) + let revision: number + try { + revision = importProfileStateJson( + opened.db, + options.serializedState, + options.expectedLegacyJson === undefined + ? {} + : { acceptedLegacyJsonHash: hashProfileStateJson(options.expectedLegacyJson) } + ) + } finally { + opened.db.close() + } + + // Closing checkpoints the temporary database before its canonical path becomes visible. + assertMigrationSourceUnchanged(options) + if (!publishFileDurableSync(temporaryDatabaseFile, options.databaseFile)) { + throw new Error('Profile state storage changed while importing legacy JSON') + } + published = true + const authority = new ProfileStateSqliteAuthority(options.databaseFile, options.profileId) + try { + authority.writeJsonExport(profileStateJsonExportPath(options.dataFile, revision)) + const initialState = authority.readInitialState() + return { authority, initialState } + } catch (error) { + authority.close() + throw error + } + } finally { + if (!published) { + for (const path of profileStateDatabaseFiles(temporaryDatabaseFile)) { + rmSync(path, { force: true }) + } + } + } +} + +function assertMigrationSourceUnchanged(options: ProfileStateMigrationOptions): void { + const expectedClassification = options.expectedLegacyJson === undefined ? 'neither' : 'json-only' + if ( + classifyProfileStateStorage(options.dataFile, options.databaseFile) !== expectedClassification + ) { + throw new Error('Profile state storage changed while importing legacy JSON') + } + const currentJson = existsSync(options.dataFile) + ? readFileSync(options.dataFile, 'utf8') + : undefined + if (currentJson !== options.expectedLegacyJson) { + throw new Error('Profile state JSON changed while importing legacy JSON') + } +} diff --git a/src/main/persistence/profile-state/profile-state-offline-settings.test.ts b/src/main/persistence/profile-state/profile-state-offline-settings.test.ts new file mode 100644 index 000000000000..412d90b13483 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-offline-settings.test.ts @@ -0,0 +1,123 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + readAgentHookSettingsFromProfileState, + updateAgentHookSettingsFromProfileState +} from './profile-state-offline-settings' +import * as exportPaths from './profile-state-export-path' +import { ProfileStateRecoveryRequiredError } from './profile-state-recovery-required' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' + +const directories: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createLocation() { + const directory = mkdtempSync(join(tmpdir(), 'orca-offline-settings-')) + directories.push(directory) + return { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'orca-state.db'), + profileId: 'profile-offline' + } +} + +describe.each(['json', 'sqlite'] as const)('offline settings %s updates', (backend) => { + it('filters malformed hook settings for callers while preserving their stored values', () => { + const location = createLocation() + const original = { + settings: { + agentStatusHooksEnabled: true, + agentCmdOverrides: { codex: 42, claude: 'claude --model opus', gemini: null }, + disabledTuiAgents: ['codex', false, 'future-agent', 'codex'], + futureSetting: { preserved: true } + }, + futureDomain: { preserved: ['雪', null] } + } + const authority = new ProfileStateSqliteAuthority(location.databaseFile, location.profileId) + try { + if (backend === 'sqlite') { + authority.writeSerializedState(Buffer.from(JSON.stringify(original))) + authority.close() + } else { + writeFileSync(location.dataFile, JSON.stringify(original)) + } + + expect(updateAgentHookSettingsFromProfileState(location, false)).toEqual({ + settingsPath: backend === 'sqlite' ? location.databaseFile : location.dataFile, + settings: { + agentCmdOverrides: { claude: 'claude --model opus' }, + disabledTuiAgents: ['codex', 'future-agent', 'codex'] + } + }) + const persisted = + backend === 'sqlite' + ? authority.readSerializedState() + : readFileSync(location.dataFile, 'utf8') + expect(JSON.parse(persisted ?? 'null')).toMatchObject({ + ...original, + settings: { ...original.settings, agentStatusHooksEnabled: false } + }) + } finally { + authority.close() + } + }) +}) + +describe.each(['read', 'update'] as const)('offline settings %s recovery', (operation) => { + function run(location: ReturnType) { + return operation === 'read' + ? readAgentHookSettingsFromProfileState(location) + : updateAgentHookSettingsFromProfileState(location, false) + } + + it.each([true, false])('rejects retained exports with JSON present=%s', (hasJson) => { + const location = createLocation() + const source = JSON.stringify({ settings: { agentStatusHooksEnabled: true } }) + if (hasJson) { + writeFileSync(location.dataFile, source) + } + const exportFile = exportPaths.profileStateJsonExportPath(location.dataFile, 1) + writeFileSync(exportFile, source) + // Recovery detection must also work in the Node 18 fallback without SQLite. + vi.spyOn(process, 'getBuiltinModule').mockReturnValue(undefined) + + expect(() => run(location)).toThrowError(ProfileStateRecoveryRequiredError) + expect(existsSync(location.databaseFile)).toBe(false) + expect(existsSync(location.dataFile)).toBe(hasJson) + expect(readFileSync(exportFile, 'utf8')).toBe(source) + if (hasJson) { + expect(readFileSync(location.dataFile, 'utf8')).toBe(source) + } + }) + + it('fails closed when retained exports cannot be enumerated', () => { + const location = createLocation() + vi.spyOn(exportPaths, 'profileStateJsonExportPaths').mockImplementation(() => { + throw new Error('permission denied') + }) + + expect(() => run(location)).toThrowError(ProfileStateRecoveryRequiredError) + expect(existsSync(location.dataFile)).toBe(false) + expect(existsSync(location.databaseFile)).toBe(false) + }) + + it.each(['-wal', '-shm', '-journal'])('rejects stale JSON when only %s remains', (suffix) => { + const location = createLocation() + const source = JSON.stringify({ settings: { agentStatusHooksEnabled: true } }) + writeFileSync(location.dataFile, source) + const sidecar = `${location.databaseFile}${suffix}` + writeFileSync(sidecar, 'orphaned recovery evidence') + + expect(() => run(location)).toThrow() + expect(readFileSync(location.dataFile, 'utf8')).toBe(source) + expect(existsSync(location.databaseFile)).toBe(false) + expect(readFileSync(sidecar, 'utf8')).toBe('orphaned recovery evidence') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-offline-settings.ts b/src/main/persistence/profile-state/profile-state-offline-settings.ts new file mode 100644 index 000000000000..d3538e64392f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-offline-settings.ts @@ -0,0 +1,225 @@ +import { existsSync, mkdirSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname } from 'node:path' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { getDefaultPersistedState } from '../../../shared/constants' +import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { profileStateJsonMatchesAcceptance } from './profile-state-documents' +import { + isProfileStateSqliteAvailable, + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { + readProfileStateDomains, + readProfileStateDomainsWithRevisionFromDatabase +} from './profile-state-domain-reader' +import { writeProfileStateDomain } from './profile-state-domain-writes' +import { assertNoRetainedProfileStateExports } from './profile-state-recovery-required' +import { classifyProfileStateStorage } from './profile-state-storage-classification' + +export type ProfileStateOfflineLocation = { + dataFile: string + databaseFile: string + profileId: string +} + +export type AgentHookSettings = Pick< + GlobalSettings, + 'agentStatusHooksEnabled' | 'disabledTuiAgents' +> + +export type AgentHookSettingsUpdate = { + settings: Pick + settingsPath: string +} + +/** Read the settings domain without creating SQLite for a JSON-only profile. */ +export function readAgentHookSettingsFromProfileState( + location: ProfileStateOfflineLocation +): AgentHookSettings { + const classification = classifyProfileStateStorage(location.dataFile, location.databaseFile) + if (classification === 'json-only' || classification === 'neither') { + assertNoRetainedProfileStateExports(location) + return readAgentHookSettingsFromJson(location.dataFile) + } + + assertSqliteCapability() + assertAcceptedLegacyJson(location, classification) + const result = readProfileStateDomains(location.databaseFile, location.profileId, ['settings']) + if (result.kind === 'unreadable') { + throw result.error + } + return readAgentHookSettingsFromSettingsValue(result.values.get('settings')) +} + +/** + * Update only the settings row in an existing SQLite authority. + * + * The snapshot revision is checked again by BEGIN IMMEDIATE, so a runtime + * writer between read and update produces a conflict instead of clobbering it. + */ +export function updateAgentHookSettingsInProfileState( + location: ProfileStateOfflineLocation, + enabled: boolean +): AgentHookSettingsUpdate { + const classification = classifyProfileStateStorage(location.dataFile, location.databaseFile) + if (classification === 'json-only' || classification === 'neither') { + throw new Error('SQLite profile state is not established for this profile') + } + + assertSqliteCapability() + assertAcceptedLegacyJson(location, classification) + const opened = openProfileStateDatabase(location.databaseFile, location.profileId) + try { + const domains = readProfileStateDomainsWithRevisionFromDatabase(opened.db, ['settings']) + if (domains.kind === 'unreadable') { + throw domains.error + } + const persistedSettings = domains.values.get('settings') + const settings = { + ...getDefaultPersistedState(homedir()).settings, + ...(isRecord(persistedSettings) ? persistedSettings : {}), + agentStatusHooksEnabled: enabled + } + writeProfileStateDomain(opened.db, { + domain: 'settings', + payload: JSON.stringify(settings), + expectedRevision: domains.revision + }) + return { + settingsPath: location.databaseFile, + settings: projectAgentHookSettings(settings) + } + } finally { + opened.db.close() + } +} + +/** Update the active profile through its classified backend without exposing that choice to CLI callers. */ +export function updateAgentHookSettingsFromProfileState( + location: ProfileStateOfflineLocation, + enabled: boolean +): AgentHookSettingsUpdate { + const classification = classifyProfileStateStorage(location.dataFile, location.databaseFile) + if (classification === 'sqlite-only' || classification === 'both') { + return updateAgentHookSettingsInProfileState(location, enabled) + } + + assertNoRetainedProfileStateExports(location) + const state = existsSync(location.dataFile) + ? parseRootState(readFileSync(location.dataFile, 'utf8')) + : structuredClone(getDefaultPersistedState(homedir())) + const persistedSettings = isRecord(state.settings) ? state.settings : {} + const settings = { + ...getDefaultPersistedState(homedir()).settings, + ...persistedSettings, + agentStatusHooksEnabled: enabled + } + state.settings = settings + writeJsonProfileState(location.dataFile, state) + return { + settingsPath: location.dataFile, + settings: projectAgentHookSettings(settings) + } +} + +function projectAgentHookSettings( + settings: AgentHookSettingsUpdate['settings'] +): AgentHookSettingsUpdate['settings'] { + return { + agentCmdOverrides: isRecord(settings.agentCmdOverrides) + ? Object.fromEntries( + Object.entries(settings.agentCmdOverrides).filter( + ([, value]) => typeof value === 'string' + ) + ) + : {}, + disabledTuiAgents: Array.isArray(settings.disabledTuiAgents) + ? settings.disabledTuiAgents.filter((value) => typeof value === 'string') + : [] + } +} + +function assertSqliteCapability(): void { + if (!isProfileStateSqliteAvailable()) { + throw new Error('SQLite profile state is present but this runtime cannot validate it') + } +} + +function assertAcceptedLegacyJson( + location: ProfileStateOfflineLocation, + classification: 'sqlite-only' | 'both' +): void { + if (classification === 'sqlite-only') { + if (!existsSync(location.databaseFile)) { + throw new Error('SQLite profile state has an orphaned database sidecar') + } + return + } + + const rawJson = readFileSync(location.dataFile, 'utf8') + const opened = openProfileStateDatabaseReadOnly(location.databaseFile, location.profileId) + try { + if (!profileStateJsonMatchesAcceptance(opened.db, rawJson)) { + throw new Error( + 'Profile state has both JSON and SQLite storage without a matching acceptance marker' + ) + } + } finally { + opened.db.close() + } +} + +function readAgentHookSettingsFromJson(dataFile: string): AgentHookSettings { + if (!existsSync(dataFile)) { + const defaults = getDefaultPersistedState(homedir()).settings + return { + agentStatusHooksEnabled: defaults.agentStatusHooksEnabled !== false, + disabledTuiAgents: normalizeDisabledTuiAgents(defaults.disabledTuiAgents) + } + } + return readAgentHookSettingsFromSnapshot(readFileSync(dataFile, 'utf8')) +} + +function readAgentHookSettingsFromSnapshot(raw: string): AgentHookSettings { + const state = parseRootState(raw) + return readAgentHookSettingsFromSettingsValue(state.settings) +} + +function readAgentHookSettingsFromSettingsValue(value: unknown): AgentHookSettings { + const settings = isRecord(value) ? value : {} + return { + agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false, + disabledTuiAgents: normalizeDisabledTuiAgents(settings.disabledTuiAgents) + } +} + +function parseRootState(raw: string): Record { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (error) { + throw new Error( + `Profile state JSON is invalid: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (!isRecord(parsed)) { + throw new Error('Profile state JSON root must be an object') + } + return parsed +} + +function writeJsonProfileState(dataFile: string, state: Record): void { + mkdirSync(dirname(dataFile), { recursive: true }) + writeFileDurableSync( + durableWriteTempPath(dataFile), + dataFile, + `${JSON.stringify(state, null, 2)}\n` + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts new file mode 100644 index 000000000000..8afac7160706 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts @@ -0,0 +1,155 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readAcceptedProfileStateParsedSnapshot, + readProfileStateDocuments, + readProfileStateParsedSnapshot, + readProfileStateSnapshot +} from './profile-state-documents' + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-parsed-profile-')) + directories.push(directory) + return openProfileStateDatabase(join(directory, 'profile-state.db'), 'parsed-profile').db +} + +describe('checked profile state values', () => { + it.each([ + [ + 'row hash', + "UPDATE profile_state_automation_runs SET payload = '{}' WHERE ordinal = 0", + 'row is corrupt' + ], + [ + 'ordering', + 'UPDATE profile_state_automation_runs SET ordinal = 3 WHERE ordinal = 0', + 'ordering is corrupt' + ], + [ + 'revision', + 'UPDATE profile_state_automation_runs SET revision = 99 WHERE ordinal = 0', + 'metadata is inconsistent' + ], + [ + 'timestamp', + 'UPDATE profile_state_automation_runs SET updated_at = updated_at + 1 WHERE ordinal = 0', + 'metadata is inconsistent' + ] + ])('rejects corrupt normalized %s in both representations', (_, sql, message) => { + const db = fixture() + try { + importProfileStateJson(db, '{"automationRuns":[{"id":"a"},{"id":"b"}]}') + db.exec(sql) + expect(() => readProfileStateParsedSnapshot(db)).toThrow(message) + expect(() => readProfileStateSnapshot(db)).toThrow(message) + } finally { + db.close() + } + }) + + it('rejects a normalized identity mismatch even when the payload hash is valid', () => { + const db = fixture() + try { + importProfileStateJson(db, '{"automationRuns":[{"id":"a"}]}') + const payload = '{"id":"other"}' + db.prepare('UPDATE profile_state_automation_runs SET payload = ?, content_hash = ?').run( + payload, + hashProfileStateJson(payload) + ) + expect(() => readProfileStateParsedSnapshot(db)).toThrow('identity is corrupt') + expect(() => readProfileStateSnapshot(db)).toThrow('identity is corrupt') + } finally { + db.close() + } + }) + + it.each([ + undefined, + null, + [], + [ + { id: 'second', unknown: '雪 🐋\ud800' }, + { id: 'first', unknown: null } + ], + { futureHistoryFormat: true }, + [{ id: 'duplicate' }, { id: 'duplicate' }] + ])('matches serialized semantics for history %j', (automationRuns) => { + const db = fixture() + try { + const source = JSON.stringify( + Object.fromEntries([ + ['z', { sealed: 'safeStorage:unchanged' }], + ['__proto__', { own: true }], + ['10', 'ten'], + ['2', 'two'], + ['constructor', 'own constructor'], + ['automationRuns', automationRuns], + ['a', null] + ]) + ) + importProfileStateJson(db, source, { acceptedLegacyJsonHash: hashProfileStateJson(source) }) + const serialized = readProfileStateSnapshot(db) + const expected: unknown = JSON.parse(serialized.json) + const parsed = readProfileStateParsedSnapshot(db) + expect(parsed).toStrictEqual({ revision: serialized.revision, state: expected }) + expect(Object.keys(parsed.state)).toEqual(Object.keys(JSON.parse(source))) + expect(Object.hasOwn(parsed.state, '__proto__')).toBe(true) + expect(Object.getPrototypeOf(parsed.state)).toBe(Object.prototype) + expect(readAcceptedProfileStateParsedSnapshot(db, source)).toStrictEqual(parsed) + expect(readAcceptedProfileStateParsedSnapshot(db, '{}')).toBeUndefined() + expect( + readProfileStateDocuments(db).every((document) => !Object.hasOwn(document, 'value')) + ).toBe(true) + expect(readProfileStateSnapshot(db).json).toBe(serialized.json) + } finally { + db.close() + } + }) + + it('validates original bytes while reusing noncanonical JSON values', () => { + const db = fixture() + try { + importProfileStateJson(db, '{"future":null,"automationRuns":[{"id":"a"},{"id":"b"}]}') + const future = + ' {"negativeZero":-0,"large":1e400,"duplicate":1,"duplicate":2,"text":"雪\\ud800"} ' + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run(future, hashProfileStateJson(future), 'future') + const payloads = [' {"id":"a","number":-0,"large":1e400} ', '{"id":"b","text":"雪\\ud800"}'] + for (const [ordinal, payload] of payloads.entries()) { + db.prepare( + 'UPDATE profile_state_automation_runs SET payload = ?, content_hash = ? WHERE ordinal = ?' + ).run(payload, hashProfileStateJson(payload), ordinal) + } + const aggregate = `[${payloads.join(',')}]` + db.prepare('UPDATE profile_state_automation_runs_meta SET content_hash = ?').run( + hashProfileStateJson(aggregate) + ) + const serialized = readProfileStateSnapshot(db) + expect(serialized.json).toBe(`{"future":${future},"automationRuns":${aggregate}}`) + expect(readProfileStateParsedSnapshot(db)).toStrictEqual({ + revision: serialized.revision, + state: JSON.parse(serialized.json) + }) + db.prepare('UPDATE profile_state_automation_runs_meta SET content_hash = ?').run( + hashProfileStateJson(JSON.stringify(JSON.parse(aggregate))) + ) + expect(() => readProfileStateParsedSnapshot(db)).toThrow('aggregate hash mismatch') + expect(() => readProfileStateSnapshot(db)).toThrow('aggregate hash mismatch') + } finally { + db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts b/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts new file mode 100644 index 000000000000..aba9c44396be --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts @@ -0,0 +1,168 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { verifyProfileStateSchema } from './profile-state-database-validation' +import { + importProfileStateJson, + readProfileStateSnapshot, + readProfileStateParsedSnapshot +} from './profile-state-documents' +import { readProfileStateDomainsWithRevisionFromDatabase } from './profile-state-domain-reader' +import { writeProfileStateDomains } from './profile-state-domain-writes' +import * as revisions from './profile-state-revision' + +const directories: string[] = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-read-concurrency-')) + directories.push(directory) + const path = join(directory, 'profile-state.db') + const { db } = openProfileStateDatabase(path, 'profile-a') + importProfileStateJson(db, '{"automationRuns":[{"id":"run-1","status":"pending"}]}') + return { path, db } +} + +describe('profile state reads during concurrent commits', () => { + it.each([readProfileStateSnapshot, readProfileStateParsedSnapshot])( + 'keeps revision and documents together when a writer commits during %s', + (read) => { + const { path, db: writer } = fixture() + const { db: reader } = openProfileStateDatabaseReadOnly(path, 'profile-a') + const readRevision = revisions.readProfileStateRevision + let committed = false + vi.spyOn(revisions, 'readProfileStateRevision').mockImplementation((db) => { + const revision = readRevision(db) + if (db === reader && !committed) { + committed = true + writeProfileStateDomains(writer, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload: '[]' }] + }) + } + return revision + }) + try { + const snapshot = read(reader) + expect(committed).toBe(true) + expect(snapshot).toMatchObject({ revision: 1 }) + expect(snapshot).toMatchObject( + read === readProfileStateSnapshot + ? { json: '{"automationRuns":[{"id":"run-1","status":"pending"}]}' } + : { state: { automationRuns: [{ id: 'run-1', status: 'pending' }] } } + ) + expect(reader.isTransaction).toBe(false) + expect(read(reader).revision).toBe(2) + } finally { + reader.close() + writer.close() + } + } + ) + + it.each([ + ['read-only', openProfileStateDatabaseReadOnly], + ['writable', openProfileStateDatabase] + ] as const)( + 'opens a healthy %s database when automation state changes between validation reads', + (_, open) => { + const { path, db: writer } = fixture() + const readRevision = revisions.readProfileStateRevision + let committed = false + vi.spyOn(revisions, 'readProfileStateRevision').mockImplementation((reader) => { + const revision = readRevision(reader) + if (reader !== writer && !committed) { + committed = true + writeProfileStateDomains(writer, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload: '[]' }] + }) + } + return revision + }) + try { + const opened = open(path, 'profile-a') + try { + expect(committed).toBe(true) + expect(opened.db.isTransaction).toBe(false) + expect(readProfileStateParsedSnapshot(opened.db)).toEqual({ + revision: 2, + state: { automationRuns: [] } + }) + expect(readProfileStateSnapshot(opened.db)).toMatchObject({ + revision: 2, + json: '{"automationRuns":[]}' + }) + } finally { + opened.db.close() + } + } finally { + writer.close() + } + } + ) + + it('keeps caller-owned writes uncommitted through schema, full and selected reads', () => { + const { db } = fixture() + try { + db.exec('BEGIN IMMEDIATE') + db.prepare('INSERT INTO profile_state_meta (key, value) VALUES (?, ?)').run('probe', 'value') + verifyProfileStateSchema(db, 'profile-a') + expect(readProfileStateSnapshot(db).revision).toBe(1) + expect(readProfileStateParsedSnapshot(db).revision).toBe(1) + expect(readProfileStateDomainsWithRevisionFromDatabase(db, ['automationRuns'])).toEqual({ + kind: 'values', + revision: 1, + values: new Map([['automationRuns', [{ id: 'run-1', status: 'pending' }]]]) + }) + expect(db.isTransaction).toBe(true) + db.exec('ROLLBACK') + expect( + db.prepare('SELECT value FROM profile_state_meta WHERE key = ?').get('probe') + ).toBeUndefined() + } finally { + db.close() + } + }) + + it.each(['owned', 'caller'] as const)( + 'preserves corrupt state and releases only %s read transactions', + (ownership) => { + const { db } = fixture() + try { + if (ownership === 'caller') { + db.exec('BEGIN IMMEDIATE') + } + db.exec('DELETE FROM profile_state_automation_runs_meta') + expect(() => verifyProfileStateSchema(db, 'profile-a')).toThrow('metadata is malformed') + expect(() => readProfileStateSnapshot(db)).toThrow('metadata is malformed') + expect(() => readProfileStateParsedSnapshot(db)).toThrow('metadata is malformed') + expect(readProfileStateDomainsWithRevisionFromDatabase(db, ['automationRuns']).kind).toBe( + 'unreadable' + ) + expect(db.isTransaction).toBe(ownership === 'caller') + expect(db.prepare('SELECT domain FROM profile_state_automation_runs_meta').all()).toEqual( + [] + ) + if (ownership === 'caller') { + db.exec('ROLLBACK') + expect(readProfileStateSnapshot(db).revision).toBe(1) + expect(readProfileStateParsedSnapshot(db).revision).toBe(1) + } + } finally { + db.close() + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-read-snapshot.ts b/src/main/persistence/profile-state/profile-state-read-snapshot.ts new file mode 100644 index 000000000000..e6be7ad4e2d6 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-read-snapshot.ts @@ -0,0 +1,25 @@ +import type Database from '../../sqlite/sync-database' + +/** Reuse a caller's transaction without committing or rolling it back. */ +export function withProfileStateReadSnapshot(db: Database.Database, read: () => T): T { + const ownsTransaction = !db.isTransaction + if (ownsTransaction) { + db.exec('BEGIN') + } + try { + const result = read() + if (ownsTransaction) { + db.exec('COMMIT') + } + return result + } catch (error) { + if (ownsTransaction) { + try { + db.exec('ROLLBACK') + } catch { + // Preserve the original read error if rollback itself is unavailable. + } + } + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-recovery-command.ts b/src/main/persistence/profile-state/profile-state-recovery-command.ts new file mode 100644 index 000000000000..6ca92db8e9aa --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-command.ts @@ -0,0 +1,136 @@ +import { readFileSync } from 'node:fs' +import { + ProfileStateRecoveryCommandError, + type ProfileStateRecoverySelector, + type ProfileStateExportsResult, + type ProfileStateRollbackResult +} from '../../../shared/profile-state-recovery-command' +import { getActiveProfileStateLocation } from './profile-state-active-location' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './profile-state-export-path' +import { profileStateDatabaseBackups } from './profile-state-backup-path' +import { restoreProfileStateJsonExport } from './profile-state-recovery' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import type { ProfileStateMaintenance } from './profile-state-access' +import { readProfileStateDomain } from './profile-state-domain-reader' +import { + invalidateHttp1CompatibilityMarker, + writeHttp1CompatibilityMarker +} from '../../startup/http1-compatibility-marker' + +export function getProfileStateExports(userDataPath: string): ProfileStateExportsResult { + const location = getActiveProfileStateLocation(userDataPath) + if (location === undefined) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + 'No active profile is available for recovery.' + ) + } + return { + profileId: location.profileId, + dataFile: location.dataFile, + databaseFile: location.databaseFile, + exportPaths: profileStateJsonExportPaths(location.dataFile), + backups: profileStateDatabaseBackups(location.databaseFile) + } +} + +export function rollbackProfileState( + userDataPath: string, + selector: ProfileStateRecoverySelector, + maintenance: ProfileStateMaintenance +): ProfileStateRollbackResult { + const result = getProfileStateExports(userDataPath) + if (selector.kind === 'sqlite') { + return restoreDatabaseBackup(userDataPath, result, selector.backupId, maintenance) + } + const revision = selector.revision + const exportPath = profileStateJsonExportPath(result.dataFile, revision) + if (!result.exportPaths.includes(exportPath)) { + throw new ProfileStateRecoveryCommandError( + 'invalid_argument', + `Profile-state export revision ${revision} is unavailable. Use profile state exports to inspect retained revisions.` + ) + } + const recovered = restoreProfileStateJsonExport({ + maintenance, + databasePath: result.databaseFile, + dataFile: result.dataFile, + exportPath, + profileId: result.profileId, + beforeRestore: () => invalidateHttp1CompatibilityMarker(userDataPath) + }) + syncHttp1CompatibilityMarkerAfterRollback(userDataPath, result.dataFile, result.profileId) + return { + ...result, + storage: 'json', + restoredPath: result.dataFile, + revision, + quarantineDirectory: recovered.quarantine.directory, + removedDatabaseFiles: recovered.removedDatabaseFiles + } +} + +function restoreDatabaseBackup( + userDataPath: string, + result: ProfileStateExportsResult, + backupId: string, + maintenance: ProfileStateMaintenance +): ProfileStateRollbackResult { + const backup = result.backups.find((entry) => entry.id === backupId) + if (!backup) { + throw new ProfileStateRecoveryCommandError( + 'invalid_argument', + 'Profile-state backup is unavailable. Use profile state exports to inspect retained backups.' + ) + } + const recovered = restoreProfileStateDatabaseBackup({ + maintenance, + databasePath: result.databaseFile, + dataFile: result.dataFile, + backupPath: backup.path, + profileId: result.profileId, + beforeRestore: () => invalidateHttp1CompatibilityMarker(userDataPath) + }) + const settings = readProfileStateDomain(result.databaseFile, result.profileId, 'settings') + if (settings.kind !== 'unreadable') { + const enabled = + settings.kind === 'value' && + isRecord(settings.value) && + settings.value.electronHttp1CompatibilityMode === true + writeHttp1CompatibilityMarker(userDataPath, enabled, result.profileId) + } + return { + ...result, + storage: 'sqlite', + restoredPath: result.databaseFile, + backupId: backup.id, + revision: recovered.revision, + quarantineDirectory: recovered.quarantine.directory, + removedDatabaseFiles: recovered.removedDatabaseFiles + } +} + +function syncHttp1CompatibilityMarkerAfterRollback( + userDataPath: string, + dataFile: string, + profileId: string +): void { + let enabled = false + try { + const parsed: unknown = JSON.parse(readFileSync(dataFile, 'utf8')) + if (isRecord(parsed) && isRecord(parsed.settings)) { + enabled = parsed.settings.electronHttp1CompatibilityMode === true + } + } catch { + // Leave the invalidated marker absent so startup reads the restored JSON itself. + return + } + writeHttp1CompatibilityMarker(userDataPath, enabled, profileId) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts b/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts new file mode 100644 index 000000000000..2554324968d4 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts @@ -0,0 +1,378 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { setSecretStore } from '../../../shared/secret-store' +import { profileStateStorage } from '../../orca-profiles/profile-project-state-file' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateSnapshot +} from './profile-state-documents' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './profile-state-export-path' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath, + profileStateDatabaseBackups +} from './profile-state-backup-path' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { createProfileStateStore } from './profile-state-store-factory' +import { restoreProfileStateJsonExport } from './profile-state-recovery' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import { + buildRecoveryCrashProcess, + killRecoveryAt, + type RecoveryCrashOptions +} from './profile-state-recovery-crash-process' + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const suiteRoot = mkdtempSync(join(tmpdir(), 'orca-recovery-crash-boundaries-')) +const fixtureRoots: string[] = [] +let bundle: string +const profileId = 'crash-recovery' +const selectedState = { + settings: { + theme: 'dark', + httpProxyUrl: Buffer.from('sealed-selected').toString('base64'), + electronHttp1CompatibilityMode: true + }, + ui: { extension: { origin: 'selected', value: null } }, + extension: { nested: [null, '\ud800', 'selected'], ['__proto__']: { inert: true } }, + opaque: null, + repos: [], + automationRuns: [], + automations: [] +} +const oldState = { + ...selectedState, + settings: { + ...selectedState.settings, + theme: 'light', + httpProxyUrl: Buffer.from('sealed-old').toString('base64') + }, + ui: { extension: { origin: 'old', value: null } }, + extension: { nested: [null, '\ud800', 'old'], ['__proto__']: { inert: true } } +} +const selectedJson = JSON.stringify(selectedState) +const oldJson = JSON.stringify(oldState) + +beforeAll(() => { + bundle = buildRecoveryCrashProcess(suiteRoot) +}) +beforeEach(() => { + setSecretStore({ + isEncryptionAvailable: () => false, + encryptString: () => { + throw new Error('Keychain unavailable') + }, + decryptString: () => { + throw new Error('Keychain unavailable') + }, + describeProtectionGap: () => null + }) +}) +afterEach(() => { + for (const root of fixtureRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +afterAll(() => rmSync(suiteRoot, { recursive: true, force: true })) + +type Fixture = RecoveryCrashOptions & { backupBytes: Buffer; originalFamily: Map } + +async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise { + const root = mkdtempSync(join(suiteRoot, 'profile-')) + fixtureRoots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const exportPath = profileStateJsonExportPath(dataFile, 3) + const backupPath = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + const options = { + root, + profileId, + dataFile, + databasePath, + exportPath, + backupPath, + markerPath: join(root, 'http1-compatibility.json'), + kind + } + const source = openProfileStateDatabase(databasePath, profileId) + try { + importProfileStateJson(source.db, oldJson) + importProfileStateJson(source.db, oldJson) + importProfileStateJson( + source.db, + selectedJson, + accepted ? { acceptedLegacyJsonHash: hashProfileStateJson(selectedJson) } : {} + ) + await writeProfileStateDatabaseSnapshotAsync(source.db, backupPath) + } finally { + source.db.close() + } + await killRecoveryAt(bundle, options, 'seed', oldJson) + expect(existsSync(`${databasePath}-wal`)).toBe(true) + expect(existsSync(`${databasePath}-shm`)).toBe(true) + // An empty abandoned rollback journal is harmless, but must be removed before publication. + writeFileSync(`${databasePath}-journal`, '') + if (accepted) { + writeFileSync(dataFile, selectedJson) + } + writeFileSync(exportPath, selectedJson) + writeFileSync( + profileStateJsonExportPath(dataFile, 1), + JSON.stringify({ ...oldState, exportRevision: 1 }) + ) + writeFileSync( + profileStateJsonExportPath(dataFile, 2), + JSON.stringify({ ...oldState, exportRevision: 2 }) + ) + writeFileSync(options.markerPath, JSON.stringify({ schemeVersion: 2, enabled: false, profileId })) + const originalFamily = new Map( + ['', '-wal', '-shm', '-journal'].map((suffix) => [ + suffix, + readFileSync(`${databasePath}${suffix}`) + ]) + ) + return { ...options, backupBytes: readFileSync(backupPath), originalFamily } +} + +function readSqlite(path: string): unknown { + const opened = openProfileStateDatabaseReadOnly(path, profileId) + try { + return JSON.parse(readProfileStateSnapshot(opened.db).json) + } finally { + opened.db.close() + } +} + +function assertQuarantine(profile: Fixture): void { + const quarantine = readdirSync(dirname(profile.databasePath)).find((name) => + name.startsWith('profile-state-corrupt-') + ) + if (quarantine === undefined) { + throw new Error('Recovery did not preserve a quarantine') + } + const directory = join(dirname(profile.databasePath), quarantine) + // Check exact family bytes before opening the copied WAL snapshot. + for (const [suffix, bytes] of profile.originalFamily) { + expect(readFileSync(join(directory, `profile-state.db${suffix}`))).toEqual(bytes) + } + expect(readFileSync(join(directory, basename(profile.exportPath)), 'utf8')).toBe(selectedJson) + expect(readFileSync(join(directory, basename(profile.backupPath)))).toEqual(profile.backupBytes) + expect(readSqlite(join(directory, 'profile-state.db'))).toEqual(oldState) +} + +function assertRestart(profile: Fixture, expected: 'old' | 'selected' | 'refused'): void { + const admission = acquireProfileStateRuntimeAdmission(profile.root) + try { + const open = () => + createProfileStateStore({ + dataFile: profile.dataFile, + databaseFile: profile.databasePath, + profileId, + authorityMode: 'sqlite-established' + }) + if (expected === 'refused') { + expect(open).toThrow() + expect(() => profileStateStorage(profileId, profile.root)).toThrow() + return + } + const expectedState = expected === 'old' ? oldState : selectedState + const storage = profileStateStorage(profileId, profile.root) + const raw = + storage === 'sqlite' + ? readSqlite(profile.databasePath) + : JSON.parse(readFileSync(profile.dataFile, 'utf8')) + // Full raw-state equality is checked before Store normalization can hide a lost domain. + expect(raw).toEqual(expectedState) + const reopened = open() + try { + expect(reopened.backend).toBe(storage) + const projected: unknown = JSON.parse(reopened.store.prepareProfileStateExport().json) + expect(projected).toMatchObject(expectedState) + } finally { + reopened.store.freezeWrites() + } + } finally { + admission.release() + } +} + +function retry(profile: Fixture): void { + const maintenance = acquireProfileStateMaintenance(profile.root) + try { + if (profile.kind === 'json') { + expect(profileStateJsonExportPaths(profile.dataFile)).toContain(profile.exportPath) + restoreProfileStateJsonExport({ ...profile, maintenance }) + } else { + expect( + profileStateDatabaseBackups(profile.databasePath).some( + (backup) => backup.path === profile.backupPath + ) + ).toBe(true) + restoreProfileStateDatabaseBackup({ ...profile, maintenance }) + } + } finally { + maintenance.release() + } + assertRestart(profile, 'selected') +} + +const JSON_BOUNDARIES = [ + 'marker-invalidated', + 'json-publish:before', + 'json-publish:after', + 'primary', + 'wal', + 'shm', + 'journal', + 'other-export', + 'first-export', + 'backup', + 'selected-export', + 'restore-returned', + 'marker-publish:before', + 'marker-publish:after', + 'marker-refreshed' +] as const + +function stage(profile: Fixture, name: string): string { + const paths: Record = { + primary: profile.databasePath, + wal: `${profile.databasePath}-wal`, + shm: `${profile.databasePath}-shm`, + journal: `${profile.databasePath}-journal`, + 'other-export': profileStateJsonExportPath(profile.dataFile, 2), + 'first-export': profileStateJsonExportPath(profile.dataFile, 1), + 'marker-invalidated': profile.markerPath, + backup: profile.backupPath, + 'selected-export': profile.exportPath, + json: profile.dataFile + } + const target = paths[name] + return target === undefined ? name : `removed:${target}` +} + +describe.each([false, true])('JSON recovery process death, accepted prior JSON=%s', (accepted) => { + it.each(JSON_BOUNDARIES)( + 'preserves a complete authority or exact retry at %s', + async (boundary) => { + const profile = await fixture('json', accepted) + await killRecoveryAt(bundle, profile, stage(profile, boundary)) + assertQuarantine(profile) + const finished = [ + 'selected-export', + 'restore-returned', + 'marker-publish:before', + 'marker-publish:after', + 'marker-refreshed' + ].includes(boundary) + const expected = finished + ? 'selected' + : ['marker-invalidated', 'json-publish:before'].includes(boundary) || + (boundary === 'json-publish:after' && accepted) + ? 'old' + : 'refused' + assertRestart(profile, expected) + if (finished) { + expect(readFileSync(profile.dataFile, 'utf8')).toBe(selectedJson) + expect(profileStateJsonExportPaths(profile.dataFile)).toEqual([]) + expect(profileStateDatabaseBackups(profile.databasePath)).toEqual([]) + } else { + expect(readFileSync(profile.exportPath, 'utf8')).toBe(selectedJson) + retry(profile) + } + } + ) +}) + +describe('SQLite recovery process death', () => { + it.each([ + 'marker-invalidated', + 'selected-export', + 'other-export', + 'first-export', + 'primary', + 'wal', + 'shm', + 'journal', + 'json', + 'sqlite-publish:before', + 'sqlite-publish:after', + 'restore-returned', + 'marker-publish:before', + 'marker-publish:after', + 'marker-refreshed' + ])('preserves full state and its immutable retry backup at %s', async (boundary) => { + const profile = await fixture('sqlite', true) + await killRecoveryAt(bundle, profile, stage(profile, boundary)) + assertQuarantine(profile) + expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes) + const expected = [ + 'marker-invalidated', + 'selected-export', + 'other-export', + 'first-export' + ].includes(boundary) + ? 'old' + : [ + 'sqlite-publish:after', + 'restore-returned', + 'marker-publish:before', + 'marker-publish:after', + 'marker-refreshed' + ].includes(boundary) + ? 'selected' + : 'refused' + assertRestart(profile, expected) + retry(profile) + expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes) + }) + + it.skipIf(process.platform === 'win32')( + 'allows clean JSON startup after final artifact cleanup is directory-synced', + async () => { + const profile = await fixture('json', true) + await killRecoveryAt(bundle, profile, 'cleanup-directory-synced') + assertQuarantine(profile) + assertRestart(profile, 'selected') + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts b/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts new file mode 100644 index 000000000000..dd3c7ac2bc80 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts @@ -0,0 +1,170 @@ +import { once } from 'node:events' +import { writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { buildSync } from 'esbuild' +import { spawnProcess } from '../../../shared/child-process/run-process' + +export type RecoveryCrashOptions = { + root: string + profileId: string + dataFile: string + databasePath: string + exportPath: string + backupPath: string + markerPath: string + kind: 'json' | 'sqlite' +} + +/** Build only the recovery graph into an isolated test directory, never shared out/. */ +export function buildRecoveryCrashProcess(directory: string): string { + const bundle = join(directory, 'recovery-crash-api.cjs') + buildSync({ + stdin: { + contents: ` + export { acquireProfileStateMaintenance } from './src/main/persistence/profile-state/profile-state-access' + export { restoreProfileStateJsonExport } from './src/main/persistence/profile-state/profile-state-recovery' + export { restoreProfileStateDatabaseBackup } from './src/main/persistence/profile-state/profile-state-database-recovery' + export { openProfileStateDatabase } from './src/main/persistence/profile-state/profile-state-database' + export { importProfileStateJson } from './src/main/persistence/profile-state/profile-state-documents' + export { invalidateHttp1CompatibilityMarker, writeHttp1CompatibilityMarker } from './src/main/startup/http1-compatibility-marker' + `, + loader: 'ts', + resolveDir: process.cwd() + }, + outfile: bundle, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external' + }) + return bundle +} + +const CHILD_SOURCE = ` +const fs = require('node:fs') +const [bundle, raw, stage, payloadPath] = process.argv.slice(2) +const options = JSON.parse(raw) +const payload = fs.readFileSync(payloadPath, 'utf8') +const api = require(bundle) +const barrier = label => { + if (label !== stage) return + fs.writeSync(1, label + '\\n') + fs.readSync(0, Buffer.alloc(1), 0, 1) + throw new Error('Crash barrier unexpectedly resumed') +} +if (stage === 'seed') { + const source = api.openProfileStateDatabase(options.databasePath, options.profileId) + api.importProfileStateJson(source.db, payload, { expectedRevision: 3 }) + barrier('seed') +} +const rename = fs.renameSync +fs.renameSync = (from, to) => { + if (to === options.dataFile) barrier('json-publish:before') + if (to === options.databasePath) barrier('sqlite-publish:before') + if (to === options.markerPath) barrier('marker-publish:before') + rename(from, to) + if (to === options.dataFile) barrier('json-publish:after') + if (to === options.databasePath) barrier('sqlite-publish:after') + if (to === options.markerPath) barrier('marker-publish:after') +} +const rm = fs.rmSync +fs.rmSync = (target, ...rest) => { + rm(target, ...rest) + barrier('removed:' + target) +} +const fsync = fs.fsyncSync +fs.fsyncSync = descriptor => { + fsync(descriptor) + if (fs.fstatSync(descriptor).isDirectory() && !fs.existsSync(options.exportPath)) { + barrier('cleanup-directory-synced') + } +} +const maintenance = api.acquireProfileStateMaintenance(options.root) +const recovered = (options.kind === 'json' + ? api.restoreProfileStateJsonExport + : api.restoreProfileStateDatabaseBackup)({ + ...options, maintenance, + beforeRestore: () => api.invalidateHttp1CompatibilityMarker(options.root) + }) +barrier('restore-returned') +api.writeHttp1CompatibilityMarker(options.root, true, options.profileId) +barrier('marker-refreshed') +maintenance.release() +throw new Error('Requested crash boundary was not reached: ' + stage) +` + +/** A pipe barrier proves the syscall completed before the parent sends SIGKILL. */ +export async function killRecoveryAt( + bundle: string, + options: RecoveryCrashOptions, + stage: string, + payload = '' +): Promise { + const childScript = join(dirname(bundle), 'recovery-crash-child.cjs') + const payloadPath = join(dirname(bundle), 'recovery-crash-payload.json') + writeFileSync(childScript, CHILD_SOURCE, 'utf8') + writeFileSync(payloadPath, payload, 'utf8') + const childEnv = { + ORCA_BACKGROUND_LAUNCH: '1', + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + ...(process.env.TEMP ? { TEMP: process.env.TEMP } : {}), + ...(process.env.TMP ? { TMP: process.env.TMP } : {}) + } + const recoveryOptions: RecoveryCrashOptions = { + root: options.root, + profileId: options.profileId, + dataFile: options.dataFile, + databasePath: options.databasePath, + exportPath: options.exportPath, + backupPath: options.backupPath, + markerPath: options.markerPath, + kind: options.kind + } + const child = spawnProcess({ + program: process.execPath, + args: [childScript, bundle, JSON.stringify(recoveryOptions), stage, payloadPath], + env: childEnv + }) + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + try { + await new Promise((resolve, reject) => { + let stdout = '' + const timer = setTimeout( + () => finish(new Error(`Crash boundary timed out: ${stage}; ${stderr}`)), + 10_000 + ) + const onData = (chunk: Buffer) => { + stdout += chunk.toString() + if (stdout.includes(`${stage}\n`)) { + finish() + } + } + const onExit = () => finish(new Error(`Recovery exited before ${stage}: ${stderr}`)) + const onError = (error: Error) => finish(error) + const finish = (error?: Error) => { + clearTimeout(timer) + child.stdout.off('data', onData) + child.off('exit', onExit) + child.off('error', onError) + if (error) { + reject(error) + } else { + resolve() + } + } + child.stdout.on('data', onData) + child.once('exit', onExit) + child.once('error', onError) + }) + } finally { + if (child.exitCode === null && child.signalCode === null) { + const closed = once(child, 'close') + child.kill('SIGKILL') + await closed + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-recovery-required.ts b/src/main/persistence/profile-state/profile-state-recovery-required.ts new file mode 100644 index 000000000000..cff324fedce5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-required.ts @@ -0,0 +1,60 @@ +import { profileStateJsonExportPaths } from './profile-state-export-path' +import { profileStateDatabaseBackups } from './profile-state-backup-path' + +type ProfileStateRecoveryLocation = { + dataFile: string + databaseFile: string + profileId: string +} + +/** Startup can surface this error with the exact artifacts an explicit rollback may use. */ +export class ProfileStateRecoveryRequiredError extends Error { + readonly code = 'profile-state-recovery-required' as const + readonly dataFile: string + readonly databaseFile: string + readonly exportPaths: readonly string[] + readonly backupPaths: readonly string[] + + constructor(options: ProfileStateRecoveryLocation, cause: unknown) { + super( + `SQLite profile state could not be read; choose a retained backup or JSON export to recover the profile`, + { cause } + ) + this.name = 'ProfileStateRecoveryRequiredError' + this.dataFile = options.dataFile + this.databaseFile = options.databaseFile + // Recovery guidance must survive a permissions failure while enumerating the directory. + // The startup error still names the canonical paths and remains typed for fail-closed handling. + try { + this.exportPaths = profileStateJsonExportPaths(options.dataFile) + } catch { + this.exportPaths = [] + } + try { + this.backupPaths = profileStateDatabaseBackups(options.databaseFile).map(({ path }) => path) + } catch { + this.backupPaths = [] + } + } +} + +/** A retained migration export proves that absent SQLite is not a fresh profile. */ +export function assertNoRetainedProfileStateExports(options: ProfileStateRecoveryLocation): void { + let hasRetainedExport: boolean + try { + hasRetainedExport = + profileStateJsonExportPaths(options.dataFile).length > 0 || + profileStateDatabaseBackups(options.databaseFile).length > 0 + } catch { + throw new ProfileStateRecoveryRequiredError( + options, + new Error('Could not enumerate retained profile state exports') + ) + } + if (hasRetainedExport) { + throw new ProfileStateRecoveryRequiredError( + options, + new Error('SQLite profile state is missing while retained migration exports exist') + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-recovery.ts b/src/main/persistence/profile-state/profile-state-recovery.ts new file mode 100644 index 000000000000..2b3a061ed5ad --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery.ts @@ -0,0 +1,85 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import { parseProfileStateRoot } from './profile-state-document-validation' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { bestEffortFsyncDirectorySync } from '../../../shared/secure-file' +import { + quarantineProfileStateDatabase, + type ProfileStateDatabaseQuarantine +} from './profile-state-database-quarantine' +import { profileStateJsonExportPaths } from './profile-state-export-path' +import { profileStateDatabaseBackupFiles } from './profile-state-backup-path' +import { assertProfileStateMaintenance, type ProfileStateMaintenance } from './profile-state-access' + +export type ProfileStateJsonRecoveryOptions = { + maintenance: ProfileStateMaintenance + databasePath: string + dataFile: string + exportPath: string + profileId: string + quarantineRoot?: string + reason?: string + beforeRestore?: () => void +} + +export type ProfileStateJsonRecovery = { + quarantine: ProfileStateDatabaseQuarantine + removedDatabaseFiles: readonly string[] +} + +/** Restore an explicitly selected JSON export after preserving a failed SQLite authority. */ +export function restoreProfileStateJsonExport( + options: ProfileStateJsonRecoveryOptions +): ProfileStateJsonRecovery { + assertProfileStateMaintenance(options.maintenance, options) + const rawJson = readRecoveryExport(options.exportPath) + const retainedExports = profileStateJsonExportPaths(options.dataFile) + const retainedBackups = profileStateDatabaseBackupFiles(options.databasePath) + const recoveryFiles = [options.exportPath, ...retainedExports, ...retainedBackups] + if (existsSync(options.dataFile)) { + recoveryFiles.push(options.dataFile) + } + const quarantine = quarantineProfileStateDatabase( + options.databasePath, + options.profileId, + options.quarantineRoot, + options.reason ?? 'profile-state-json-rollback', + recoveryFiles + ) + + // Invalidate authority-dependent caches while the database and recovery exports still exist. + options.beforeRestore?.() + mkdirSync(dirname(options.dataFile), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(options.dataFile), options.dataFile, rawJson) + + const removedDatabaseFiles = profileStateDatabaseFiles(options.databasePath).filter((path) => + existsSync(path) + ) + for (const path of removedDatabaseFiles) { + rmSync(path) + } + // Removing the reserved exports completes the authority transition back to JSON. Keeping one + // would make established startup correctly reject the restored legacy state as a stale mirror. + const retainedArtifacts = [...retainedBackups, ...retainedExports] + for (const path of retainedArtifacts) { + if (path !== options.exportPath) { + rmSync(path) + } + } + // Keep the selected revision retryable until no reserved artifact can block JSON startup. + if (retainedArtifacts.includes(options.exportPath)) { + rmSync(options.exportPath) + } + bestEffortFsyncDirectorySync(dirname(options.databasePath)) + return { quarantine, removedDatabaseFiles } +} + +function readRecoveryExport(exportPath: string): string { + if (exportPath.length === 0 || exportPath.includes('\0')) { + throw new Error('Profile state recovery export path is invalid') + } + const rawJson = readFileSync(exportPath, 'utf8') + parseProfileStateRoot(rawJson) + return rawJson +} diff --git a/src/main/persistence/profile-state/profile-state-revision.ts b/src/main/persistence/profile-state/profile-state-revision.ts new file mode 100644 index 000000000000..6c64714606f7 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-revision.ts @@ -0,0 +1,31 @@ +import type Database from '../../sqlite/sync-database' +import { PROFILE_STATE_META_REVISION } from './profile-state-database-schema' +import { isRecord, ProfileStateDocumentCorruptionError } from './profile-state-document-validation' + +export function readProfileStateRevision(db: Database.Database): number { + const row = db + .prepare('SELECT value FROM profile_state_meta WHERE key = ?') + .get(PROFILE_STATE_META_REVISION) + if (!isRecord(row) || typeof row.value !== 'string') { + return 0 + } + const revision = Number(row.value) + if (!Number.isSafeInteger(revision) || revision < 0) { + throw new ProfileStateDocumentCorruptionError('Profile state revision is invalid') + } + return revision +} + +/** Unchanged domains may lag the profile revision, but cannot lead it. */ +export function assertProfileStateDocumentRevision( + revision: number, + profileRevision: number, + domain: string +): void { + if (revision > profileRevision) { + throw new ProfileStateDocumentCorruptionError( + `Profile state document revision ${revision} exceeds profile revision ${profileRevision}`, + domain + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-sqlite-authority.ts b/src/main/persistence/profile-state/profile-state-sqlite-authority.ts new file mode 100644 index 000000000000..d6f2473c28fc --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-sqlite-authority.ts @@ -0,0 +1,337 @@ +import { existsSync, mkdirSync, readFileSync } from 'node:fs' +import { mkdir, readFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { + durableWriteTempPath, + writeFileDurable, + writeFileDurableSync +} from '../../durable-file-write' +import type { + ProfileStateAuthority, + ProfileStateAuthorityInitialState, + ProfileStateDomainReplacement +} from '../loading-store/profile-state-authority' +import { + acceptProfileStateJsonCompatibility, + importProfileStateJson, + readAcceptedProfileStateParsedSnapshot, + readProfileStateParsedSnapshot, + readProfileStateRevision, + readProfileStateSnapshot, + stageProfileStateJsonCompatibility +} from './profile-state-documents' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { writeProfileStateDomains } from './profile-state-domain-writes' +import { + prepareProfileStateDomainMutation, + validateProfileStateDomainTransaction +} from './profile-state-domain-write-validation' +import { + parseProfileStateRoot, + ProfileStateRevisionConflictError +} from './profile-state-document-validation' +import type { AutomationRun } from '../../../shared/automations-types' +import { + quarantineProfileStateDatabase, + type ProfileStateDatabaseQuarantine +} from './profile-state-database-quarantine' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' + +/** + * Complete-document authority for the Store cutover. + * + * A Store authority keeps one writable handle for its lifetime so repeated + * domain commits do not pay connection and pragma setup costs. Store teardown + * calls {@link close} before profile switches or process removal. Complete + * payloads use one fenced domain transaction, so unchanged normalized rows are + * not rebuilt; Store callers can still opt into narrower dirty-domain writes. + */ +export class ProfileStateSqliteAuthority implements ProfileStateAuthority { + private observedRevision: number | undefined + private writableDatabase: ReturnType | undefined + private backupRotation: ProfileStateBackupRotation | undefined + + constructor( + private readonly databasePath: string, + private readonly profileId: string + ) {} + + /** Keep the startup payload and write fence on the same accepted revision. */ + readAcceptedState(rawJson: string): ProfileStateAuthorityInitialState | undefined { + const opened = openProfileStateDatabaseReadOnly(this.databasePath, this.profileId) + try { + const snapshot = readAcceptedProfileStateParsedSnapshot(opened.db, rawJson) + if (snapshot === undefined) { + return undefined + } + return this.createInitialState(snapshot.revision, snapshot.state) + } finally { + opened.db.close() + } + } + + readInitialState(): ProfileStateAuthorityInitialState { + if (!this.writableDatabase && !existsSync(this.databasePath)) { + return this.createInitialState(0, undefined) + } + const opened = + this.writableDatabase ?? openProfileStateDatabaseReadOnly(this.databasePath, this.profileId) + try { + const snapshot = readProfileStateParsedSnapshot(opened.db) + return this.createInitialState( + snapshot.revision, + snapshot.revision === 0 ? undefined : snapshot.state + ) + } finally { + if (opened !== this.writableDatabase) { + opened.db.close() + } + } + } + + readSerializedState(): string | undefined { + if (!this.writableDatabase && !existsSync(this.databasePath)) { + // Treat an absent database as the empty revision so a concurrent creator + // cannot race this authority's first commit. + this.observedRevision = 0 + return undefined + } + const opened = + this.writableDatabase ?? openProfileStateDatabaseReadOnly(this.databasePath, this.profileId) + try { + const snapshot = readProfileStateSnapshot(opened.db) + this.observedRevision = snapshot.revision + return snapshot.revision === 0 ? undefined : snapshot.json + } finally { + if (opened !== this.writableDatabase) { + opened.db.close() + } + } + } + + writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): void { + if (this.observedRevision === undefined) { + // Store normally reads before its first write. Establishing the revision + // here keeps direct authority callers fenced too. + this.readSerializedState() + } + const opened = this.openWritableDatabase() + const result = writeProfileStateDomains(opened.db, { + expectedRevision: this.observedRevision ?? 0, + replacements + }) + this.observedRevision = result.revision + } + + assertCurrentRevision(): void { + const actualRevision = readProfileStateRevision(this.openWritableDatabase().db) + if (this.observedRevision === undefined || actualRevision !== this.observedRevision) { + throw new ProfileStateRevisionConflictError(this.observedRevision ?? 0, actualRevision) + } + } + + writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ): void { + if (this.observedRevision === undefined) { + this.readSerializedState() + } + const opened = this.openWritableDatabase() + const result = writeProfileStateDomains(opened.db, { + expectedRevision: this.observedRevision ?? 0, + replacements, + automationRunsAfter: runs + }) + this.observedRevision = result.revision + } + + writeSerializedState(payload: Buffer): void { + const serialized = payload.toString('utf8') + if (!Buffer.from(serialized, 'utf8').equals(payload)) { + throw new Error('Profile state payload is not valid UTF-8') + } + const parsed = parseProfileStateRoot(serialized) + this.writeCompleteSerializedDomains( + Object.entries(parsed).map(([domain, value]) => { + const payload = JSON.stringify(value) + if (payload === undefined) { + throw new Error(`Profile state domain payload is not serializable: ${domain}`) + } + return { domain, payload } + }) + ) + } + + writeCompleteSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): void { + if (this.observedRevision === undefined) { + this.readSerializedState() + } + if (!Array.isArray(replacements) || replacements.length > 0) { + validateProfileStateDomainTransaction({ + expectedRevision: this.observedRevision ?? 0, + replacements + }) + } + const opened = this.openWritableDatabase() + const currentRevision = readProfileStateRevision(opened.db) + const complete = buildCompleteDocumentReplacements(opened.db, replacements) + if (currentRevision === 0 || complete.length === 0) { + // Each fragment must be valid independently before it can become part of a root object. + for (const replacement of replacements) { + prepareProfileStateDomainMutation(replacement) + } + const rawJson = `{${replacements + .filter(({ payload }) => payload !== null) + .map(({ domain, payload }) => `${JSON.stringify(domain)}:${payload}`) + .join(',')}}` + this.observedRevision = importProfileStateJson(opened.db, rawJson, { + expectedRevision: this.observedRevision + }) + return + } + const result = writeProfileStateDomains(opened.db, { + expectedRevision: this.observedRevision ?? currentRevision, + replacements: complete + }) + this.observedRevision = result.revision + } + + scheduleBackup(): void { + this.backupRotation ??= new ProfileStateBackupRotation(this.databasePath, this.profileId) + this.backupRotation.schedule() + } + + async drainBackups(): Promise { + await this.backupRotation?.drain() + } + + /** Publish a durable JSON rollback/compatibility export without changing authority. */ + writeJsonExport(targetPath: string): number { + const opened = this.openWritableDatabase() + const snapshot = readProfileStateSnapshot(opened.db) + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, snapshot.json) + return snapshot.revision + } + + /** Stage both accepted versions before replacing canonical JSON for an older build. */ + writeJsonCompatibilityExport(targetPath: string): number | undefined { + const opened = this.openWritableDatabase() + const snapshot = readProfileStateSnapshot(opened.db) + if (snapshot.revision === 0) { + return undefined + } + const retained = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : undefined + stageProfileStateJsonCompatibility(opened.db, snapshot.json, snapshot.revision, retained) + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, snapshot.json) + acceptProfileStateJsonCompatibility(opened.db, snapshot.json, snapshot.revision) + return snapshot.revision + } + + async writeJsonCompatibilityExportAsync(targetPath: string): Promise { + const opened = this.openWritableDatabase() + const snapshot = readProfileStateSnapshot(opened.db) + if (snapshot.revision === 0) { + return undefined + } + const retained = await readFile(targetPath, 'utf8').catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + }) + stageProfileStateJsonCompatibility(opened.db, snapshot.json, snapshot.revision, retained) + await mkdir(dirname(targetPath), { recursive: true }) + await writeFileDurable(durableWriteTempPath(targetPath), targetPath, snapshot.json) + acceptProfileStateJsonCompatibility(opened.db, snapshot.json, snapshot.revision) + return snapshot.revision + } + + quarantineDatabase(quarantineRoot?: string, reason?: string): ProfileStateDatabaseQuarantine { + this.backupRotation?.assertIdle() + this.close() + return quarantineProfileStateDatabase(this.databasePath, this.profileId, quarantineRoot, reason) + } + + close(): void { + this.backupRotation?.stop() + this.writableDatabase?.db.close() + this.writableDatabase = undefined + } + + private createInitialState( + revision: number, + value: Record | undefined + ): ProfileStateAuthorityInitialState { + let pending: { revision: number; value: Record | undefined } | undefined = { + revision, + value + } + this.observedRevision = revision + return { + authority: this, + takeParsedState: () => { + const snapshot = pending + if (snapshot === undefined) { + throw new Error('Profile state startup snapshot has already been consumed') + } + pending = undefined + this.observedRevision = snapshot.revision + return snapshot.value + } + } + } + + private openWritableDatabase(): NonNullable { + if (this.writableDatabase) { + return this.writableDatabase + } + mkdirSync(dirname(this.databasePath), { recursive: true }) + const opened = openProfileStateDatabase(this.databasePath, this.profileId) + if (opened.readOnly) { + opened.db.close() + throw new Error('Cannot write a future profile state schema') + } + this.writableDatabase = opened + return opened + } +} + +function buildCompleteDocumentReplacements( + db: ReturnType['db'], + replacements: readonly ProfileStateDomainReplacement[] +): ProfileStateDomainReplacement[] { + const domains = new Set(replacements.map(({ domain }) => domain)) + const incoming = new Set(domains) + for (const row of db + .prepare(`SELECT domain FROM profile_state_documents + UNION SELECT domain FROM profile_state_automation_runs_meta WHERE presence <> 'document'`) + .all()) { + if (isDomainRow(row)) { + domains.add(row.domain) + } + } + + return [ + ...replacements, + ...[...domains] + .filter((domain) => !incoming.has(domain)) + .map((domain) => ({ domain, payload: null })) + ] +} + +function isDomainRow(value: unknown): value is { domain: string } { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + 'domain' in value && + typeof value.domain === 'string' && + value.domain.length > 0 + ) +} diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.test.ts b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts new file mode 100644 index 000000000000..db5c33c6e46d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts @@ -0,0 +1,289 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { + createProfileStateStoreForStartup, + desktopProfileStateAuthorityMode, + orcadProfileStateAuthorityMode, + ProfileStateStartupAuthorityError, + type ProfileStateStartupAuthorityOptions +} from './profile-state-startup-authority' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +describe('profile-state startup authority boundary', () => { + it('establishes desktop SQLite by default while preserving orcad capability selection', () => { + expect(desktopProfileStateAuthorityMode()).toBe('sqlite-candidate') + expect(orcadProfileStateAuthorityMode(true)).toBe('sqlite-candidate') + expect(orcadProfileStateAuthorityMode(false)).toBe('legacy') + }) + + it('imports legacy desktop state by default and reopens acknowledged SQLite state', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-startup-authority-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(directory) + writeFileSync(dataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + + const base: Omit = { + dataFile, + databaseFile, + profileId: 'startup-authority-test', + storageAuthority: 'desktop' + } + const legacy = createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: 'legacy' + }) + expect(legacy.backend).toBe('json') + expect(existsSync(databaseFile)).toBe(false) + legacy.store.freezeWrites() + + const candidate = createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode() + }) + expect(candidate.backend).toBe('sqlite') + expect(candidate.migrated).toBe(true) + candidate.store.updateSettings({ theme: 'dark' }) + candidate.store.flushOrThrow() + candidate.store.freezeWrites() + rmSync(dataFile) + + const restarted = createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode() + }) + expect(restarted.backend).toBe('sqlite') + expect(restarted.classification).toBe('sqlite-only') + expect(restarted.store.getSettings().theme).toBe('dark') + restarted.store.freezeWrites() + + const packaged = createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode() + }) + expect(packaged.backend).toBe('sqlite') + expect(packaged.classification).toBe('sqlite-only') + expect(packaged.store.getSettings().theme).toBe('dark') + packaged.store.freezeWrites() + + expect(() => + createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: 'legacy' + }) + ).toThrowError(expect.objectContaining({ code: 'profile-state-authority-required' })) + + const orcad = createProfileStateStoreForStartup({ + ...base, + runtime: 'orcad', + authorityMode: 'sqlite-candidate', + storageAuthority: 'runtime' + }) + expect(orcad.backend).toBe('sqlite') + orcad.store.freezeWrites() + + expect(() => + createProfileStateStoreForStartup({ + ...base, + runtime: 'orcad', + authorityMode: 'legacy', + storageAuthority: 'runtime' + }) + ).toThrowError(expect.objectContaining({ code: 'profile-state-authority-required' })) + }) + + it('rejects an orcad candidate request on a Node 18-style host', () => { + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { + if (id === 'node:sqlite') { + return undefined + } + return original(id) + }) + + expect(() => + createProfileStateStoreForStartup({ + dataFile: join(tmpdir(), 'missing-orca-data.json'), + databaseFile: join(tmpdir(), 'missing-profile-state.db'), + profileId: 'startup-authority-node18-test', + runtime: 'orcad', + authorityMode: 'sqlite-candidate', + storageAuthority: 'runtime' + }) + ).toThrowError(ProfileStateStartupAuthorityError) + }) + + it('creates an empty desktop profile directly in SQLite and preserves its first acknowledged write', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-default-empty-profile-')) + temporaryDirectories.push(directory) + const options: ProfileStateStartupAuthorityOptions = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'default-empty', + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode(), + storageAuthority: 'desktop' + } + const first = createProfileStateStoreForStartup(options) + try { + expect(first.backend).toBe('sqlite') + expect(first.classification).toBe('neither') + first.store.updateSettings({ terminalFontSize: 19 }) + first.store.flushOrThrow() + expect(existsSync(options.databaseFile)).toBe(true) + expect(existsSync(options.dataFile)).toBe(false) + } finally { + first.store.freezeWrites() + } + const reopened = createProfileStateStoreForStartup(options) + try { + expect(reopened.backend).toBe('sqlite') + expect(reopened.migrated).toBe(false) + expect(reopened.store.getSettings().terminalFontSize).toBe(19) + } finally { + reopened.store.freezeWrites() + } + }) + + it.each(['corrupt', 'future-schema', 'ambiguous'] as const)( + 'refuses %s storage under the desktop default without replacing the authority', + (kind) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-default-invalid-profile-')) + temporaryDirectories.push(directory) + const options: ProfileStateStartupAuthorityOptions = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'default-invalid', + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode(), + storageAuthority: 'desktop' + } + if (kind === 'corrupt') { + writeFileSync(options.databaseFile, 'not a SQLite database') + } else { + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + try { + if (kind === 'future-schema') { + opened.db.exec('PRAGMA user_version = 999') + } else { + writeFileSync(options.dataFile, '{"settings":{"theme":"dark"}}') + } + } finally { + opened.db.close() + } + } + const before = readFileSync(options.databaseFile) + expect(() => createProfileStateStoreForStartup(options)).toThrow() + expect(readFileSync(options.databaseFile)).toEqual(before) + if (kind === 'ambiguous') { + expect(readFileSync(options.dataFile, 'utf8')).toBe('{"settings":{"theme":"dark"}}') + } + } + ) + + it('migrates a JSON-only orcad profile when the runtime exposes SQLite', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-orcad-capable-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(directory) + writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + + const result = createProfileStateStoreForStartup({ + dataFile, + databaseFile, + profileId: 'orcad-capable-test', + runtime: 'orcad', + authorityMode: orcadProfileStateAuthorityMode(true), + storageAuthority: 'runtime' + }) + + expect(result.backend).toBe('sqlite') + expect(result.migrated).toBe(true) + expect(result.store.getSettings().theme).toBe('dark') + result.store.freezeWrites() + }) + + it('keeps a runtime with SQLite but no native backup on JSON authority', () => { + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { + return id === 'node:sqlite' ? { DatabaseSync: class {} } : original(id) + }) + expect(orcadProfileStateAuthorityMode()).toBe('legacy') + expect(() => + createProfileStateStoreForStartup({ + dataFile: join(tmpdir(), 'missing-backup-orca-data.json'), + databaseFile: join(tmpdir(), 'missing-backup-profile-state.db'), + profileId: 'missing-native-backup', + runtime: 'orcad', + authorityMode: 'sqlite-candidate', + storageAuthority: 'runtime' + }) + ).toThrowError(ProfileStateStartupAuthorityError) + }) + + it('keeps a JSON-only orcad profile on JSON when the runtime lacks SQLite', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-orcad-node18-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(directory) + writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + + const result = createProfileStateStoreForStartup({ + dataFile, + databaseFile, + profileId: 'orcad-node18-test', + runtime: 'orcad', + authorityMode: orcadProfileStateAuthorityMode(false), + storageAuthority: 'runtime' + }) + + expect(result.backend).toBe('json') + expect(result.migrated).toBe(false) + expect(result.store.getSettings().theme).toBe('dark') + result.store.freezeWrites() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.ts b/src/main/persistence/profile-state/profile-state-startup-authority.ts new file mode 100644 index 000000000000..b8abfcc5fae5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-authority.ts @@ -0,0 +1,62 @@ +import type { AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' +import { isProfileStateSqliteAvailable } from './profile-state-database' +import { + createProfileStateStore, + type ProfileStateStoreAuthorityMode, + type ProfileStateStoreFactoryOptions, + type ProfileStateStoreFactoryResult +} from './profile-state-store-factory' + +/** Runtime roots sharing the profile-state selection boundary. */ +export type ProfileStateStartupRuntime = 'desktop' | 'orcad' + +export type ProfileStateStartupAuthorityOptions = Omit< + ProfileStateStoreFactoryOptions, + 'authorityMode' | 'storageAuthority' +> & { + runtime: ProfileStateStartupRuntime + authorityMode: ProfileStateStoreAuthorityMode + storageAuthority: AutomationStorageAuthority +} + +export class ProfileStateStartupAuthorityError extends Error { + readonly code = 'orcad-sqlite-authority-unsupported' as const + + constructor() { + super('orcad requires node:sqlite database and backup support to select SQLite profile state') + this.name = 'ProfileStateStartupAuthorityError' + } +} + +/** Desktop startup establishes SQLite for legacy and empty profiles. */ +export function desktopProfileStateAuthorityMode(): ProfileStateStoreAuthorityMode { + return 'sqlite-candidate' +} + +/** + * Select orcad's authority from the runtime capability, without raising the + * Node floor shared by the relay and older remote hosts. + * + * A capable host may establish SQLite for a JSON-only profile. An older host + * stays on the legacy path, while the factory still refuses to open an + * already-established database it cannot validate. + */ +export function orcadProfileStateAuthorityMode( + sqliteAvailable = isProfileStateSqliteAvailable() +): ProfileStateStoreAuthorityMode { + return sqliteAvailable ? 'sqlite-candidate' : 'legacy' +} + +/** Construct both runtimes through the same validated authority boundary. */ +export function createProfileStateStoreForStartup( + options: ProfileStateStartupAuthorityOptions +): ProfileStateStoreFactoryResult { + if ( + options.runtime === 'orcad' && + options.authorityMode === 'sqlite-candidate' && + !isProfileStateSqliteAvailable() + ) { + throw new ProfileStateStartupAuthorityError() + } + return createProfileStateStore(options) +} diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.test.ts b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts new file mode 100644 index 000000000000..cc0f73d6b573 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-authority-bootstrap' +import { + formatProfileStateStartupFailure, + profileStateStartupFailureClass +} from './profile-state-startup-failure' + +describe('profile-state startup failure formatting', () => { + it('prints recovery paths and the offline rollback command', () => { + const error = new ProfileStateRecoveryRequiredError( + { + dataFile: '/profile/orca-data.json', + databaseFile: '/profile/profile-state.db', + profileId: 'profile-a' + }, + new Error('database is corrupt') + ) + + expect(formatProfileStateStartupFailure(error)).toContain( + 'orca profile state rollback --revision ' + ) + expect(formatProfileStateStartupFailure(error)).toContain('/profile/profile-state.db') + expect(profileStateStartupFailureClass(error)).toBe('recovery-required') + }) + + it('formats authority ambiguity without suggesting a destructive recovery', () => { + const message = formatProfileStateStartupFailure( + new ProfileStateAuthorityBootstrapError('both profile stores are present') + ) + + expect(message).toBe( + 'Orca cannot safely choose a profile-state authority: both profile stores are present' + ) + expect( + profileStateStartupFailureClass(new ProfileStateAuthorityBootstrapError('ambiguous')) + ).toBe('ambiguous-authority') + }) + + it('shows retained SQLite backups and their explicit recovery command without JSON exports', () => { + const message = formatProfileStateStartupFailure({ + code: 'profile-state-recovery-required', + dataFile: '/profile/orca-data.json', + databaseFile: '/profile/profile-state.db', + exportPaths: [], + backupPaths: ['/profile/profile-state.db.backup.latest.db'] + }) + expect(message).toContain( + 'Retained SQLite backups:\n /profile/profile-state.db.backup.latest.db' + ) + expect(message).toContain('orca profile state rollback --backup ') + }) + + it('leaves unrelated startup errors on the existing fatal path', () => { + expect(formatProfileStateStartupFailure(new Error('unrelated startup failure'))).toBeUndefined() + expect(profileStateStartupFailureClass(new Error('unrelated startup failure'))).toBeUndefined() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.ts b/src/main/persistence/profile-state/profile-state-startup-failure.ts new file mode 100644 index 000000000000..d0d5dcaabff2 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-failure.ts @@ -0,0 +1,82 @@ +type ProfileStateRecoveryFailure = { + code: 'profile-state-recovery-required' + dataFile: string + databaseFile: string + exportPaths: readonly string[] + backupPaths?: readonly string[] +} + +type ProfileStateAuthorityFailure = { + code: 'ambiguous-profile-state' + message: string +} + +export type ProfileStateStartupFailureClass = 'recovery-required' | 'ambiguous-authority' + +/** Return the bounded failure class used by startup breadcrumbs and support diagnostics. */ +export function profileStateStartupFailureClass( + error: unknown +): ProfileStateStartupFailureClass | undefined { + if (isProfileStateRecoveryFailure(error)) { + return 'recovery-required' + } + if (isProfileStateAuthorityFailure(error)) { + return 'ambiguous-authority' + } + return undefined +} + +/** Format profile-state startup failures without exposing a generic fatal-error path. */ +export function formatProfileStateStartupFailure(error: unknown): string | undefined { + if (isProfileStateRecoveryFailure(error)) { + const retainedBackups = !error.backupPaths?.length + ? ' (none found)' + : error.backupPaths.map((path) => ` ${path}`).join('\n') + const retainedExports = + error.exportPaths.length === 0 + ? ' (none found)' + : error.exportPaths.map((path) => ` ${path}`).join('\n') + return [ + 'Orca cannot safely open the active profile because its SQLite state is unreadable.', + `Legacy JSON path: ${error.dataFile}`, + `SQLite path: ${error.databaseFile}`, + 'Retained SQLite backups:', + retainedBackups, + 'Retained JSON exports:', + retainedExports, + 'Stop Orca, then run `orca profile state exports` and choose a known-good recovery artifact.', + 'Restore SQLite with `orca profile state rollback --backup `, or restore a JSON export with', + '`orca profile state rollback --revision `.' + ].join('\n') + } + + if (isProfileStateAuthorityFailure(error)) { + return `Orca cannot safely choose a profile-state authority: ${error.message}` + } + + return undefined +} + +function isProfileStateRecoveryFailure(error: unknown): error is ProfileStateRecoveryFailure { + return ( + isRecord(error) && + error.code === 'profile-state-recovery-required' && + typeof error.dataFile === 'string' && + typeof error.databaseFile === 'string' && + Array.isArray(error.exportPaths) && + error.exportPaths.every((path) => typeof path === 'string') && + (error.backupPaths === undefined || + (Array.isArray(error.backupPaths) && + error.backupPaths.every((path) => typeof path === 'string'))) + ) +} + +function isProfileStateAuthorityFailure(error: unknown): error is ProfileStateAuthorityFailure { + return ( + isRecord(error) && error.code === 'ambiguous-profile-state' && typeof error.message === 'string' + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts new file mode 100644 index 000000000000..44fb9ccf4bf5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { presentProfileStateStartupRecoveryDialog } from './profile-state-startup-recovery-dialog' + +describe('profile state startup recovery dialog', () => { + it('offers a copyable offline export command and does not mutate state', async () => { + const showMessageBox = vi.fn().mockResolvedValue({ response: 0 }) + const copyToClipboard = vi.fn() + + await presentProfileStateStartupRecoveryDialog({ + message: 'SQLite state is unreadable.\nSQLite path: /tmp/profile-state.db', + recoveryCommand: 'orca profile state exports', + showMessageBox, + copyToClipboard + }) + + expect(showMessageBox).toHaveBeenCalledWith({ + type: 'error', + buttons: ['Copy recovery command', 'Quit'], + defaultId: 1, + cancelId: 1, + title: 'Orca profile state cannot be opened', + message: 'Orca cannot safely open this profile.', + detail: + 'SQLite state is unreadable.\nSQLite path: /tmp/profile-state.db\n\nCopy the recovery command, then run it after Orca closes.' + }) + expect(copyToClipboard).toHaveBeenCalledWith('orca profile state exports') + }) + + it('leaves the clipboard untouched when the user quits', async () => { + const showMessageBox = vi.fn().mockResolvedValue({ response: 1 }) + const copyToClipboard = vi.fn() + + await presentProfileStateStartupRecoveryDialog({ + message: 'ambiguous profile state', + recoveryCommand: 'orca profile state exports', + showMessageBox, + copyToClipboard + }) + + expect(copyToClipboard).not.toHaveBeenCalled() + }) + + it('does not offer rollback for an authority ambiguity', async () => { + const showMessageBox = vi.fn().mockResolvedValue({ response: 0 }) + const copyToClipboard = vi.fn() + + await presentProfileStateStartupRecoveryDialog({ + message: 'both profile authorities are present', + showMessageBox, + copyToClipboard + }) + + expect(showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: ['Quit'], + defaultId: 0, + cancelId: 0, + detail: + 'both profile authorities are present\n\nQuit Orca and resolve the profile-state authority before retrying.' + }) + ) + expect(copyToClipboard).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts new file mode 100644 index 000000000000..d89fbbb7729a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts @@ -0,0 +1,30 @@ +import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' + +export type ProfileStateStartupRecoveryDialogDeps = { + message: string + recoveryCommand?: string + showMessageBox: (options: MessageBoxOptions) => Promise + copyToClipboard: (text: string) => void +} + +/** Present the only safe desktop recovery action without changing the failed authority. */ +export async function presentProfileStateStartupRecoveryDialog( + deps: ProfileStateStartupRecoveryDialogDeps +): Promise { + const buttons = deps.recoveryCommand ? ['Copy recovery command', 'Quit'] : ['Quit'] + const detail = deps.recoveryCommand + ? `${deps.message}\n\nCopy the recovery command, then run it after Orca closes.` + : `${deps.message}\n\nQuit Orca and resolve the profile-state authority before retrying.` + const { response } = await deps.showMessageBox({ + type: 'error', + buttons, + defaultId: buttons.length - 1, + cancelId: buttons.length - 1, + title: 'Orca profile state cannot be opened', + message: 'Orca cannot safely open this profile.', + detail + }) + if (response === 0 && deps.recoveryCommand) { + deps.copyToClipboard(deps.recoveryCommand) + } +} diff --git a/src/main/persistence/profile-state/profile-state-startup-secrets.test.ts b/src/main/persistence/profile-state/profile-state-startup-secrets.test.ts new file mode 100644 index 000000000000..e9fbd395bd0e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-secrets.test.ts @@ -0,0 +1,184 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getSecretStore, + hasSecretStore, + setSecretStore, + _resetSecretStoreForTests +} from '../../../shared/secret-store' +import { Store } from '../loading-store/store' +import * as storeDomains from '../loading-store/store-domain-composition' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' + +let keyState: 'available' | 'unavailable' | 'decrypt-fails' = 'available' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +let originalSecretStore: ReturnType | undefined +beforeEach(() => { + originalSecretStore = hasSecretStore() ? getSecretStore() : undefined + setSecretStore({ + isEncryptionAvailable: () => keyState !== 'unavailable', + encryptString: (value) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value) => { + if (keyState === 'decrypt-fails') { + throw new Error('keychain denied decryption') + } + return value.toString('utf8').slice('encrypted:'.length) + }, + describeProtectionGap: () => null + }) +}) + +const directories: string[] = [] +const stores: Store[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const store of stores) { + store.freezeWrites() + } + for (const store of stores.splice(0)) { + await store.flushAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + keyState = 'available' + if (originalSecretStore) { + setSecretStore(originalSecretStore) + } else { + _resetSecretStoreForTests() + } +}) + +const secrets = { + proxy: 'http://user:password@proxy.test:8080', + cookie: 'startup-secret-cookie', + kagi: 'https://kagi.com/session?t=startup-secret', + lease: `startup-owner-lease-${'x'.repeat(480)}` +} +const sealed = (value: string) => Buffer.from(`encrypted:${value}`, 'utf8').toString('base64') + +it.each([false, true])( + 'rejects reused input before Store context installation (secret=%s)', + (hasSecret) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-consumed-startup-')) + directories.push(directory) + const authority = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'once') + if (hasSecret) { + authority.writeSerializedState( + Buffer.from( + JSON.stringify({ + settings: { opencodeSessionCookie: sealed(secrets.cookie) } + }) + ) + ) + } + const options = { + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority, + initialAuthorityState: authority.readInitialState() + } + const store = new Store(options) + stores.push(store) + if (hasSecret) { + expect(store.getSettings().opencodeSessionCookie).toBe(secrets.cookie) + } + const install = vi.spyOn(storeDomains, 'installStoreDomainContexts') + + expect(() => new Store(options)).toThrow('already been consumed') + expect(install).not.toHaveBeenCalled() + } +) + +describe.each(['serialized', 'parsed'] as const)( + '%s startup secret retention', + (representation) => { + it.each(['available', 'unavailable', 'decrypt-fails'] as const)( + 'preserves every protected slot through an unrelated save when keys are %s', + async (failure) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-startup-secrets-')) + directories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + function open() { + const authority = new ProfileStateSqliteAuthority(databaseFile, 'startup-secrets') + const store = new Store({ + dataFile, + profileStateAuthority: authority, + ...(representation === 'parsed' + ? { initialAuthorityState: authority.readInitialState() } + : {}) + }) + stores.push(store) + return { store, authority } + } + + const seeded = open() + seeded.store.updateSettings({ + httpProxyUrl: secrets.proxy, + opencodeSessionCookie: secrets.cookie + }) + seeded.store.updateUI({ browserKagiSessionLink: secrets.kagi }) + await seeded.store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'server-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: secrets.lease + }) + await seeded.store.flushAsync() + seeded.store.freezeWrites() + + keyState = failure + const reopened = open() + reopened.store.updateSettings({ terminalFontSize: 19 }) + await reopened.store.flushAsync() + const persisted = reopened.authority.readSerializedState() + expect(persisted).toBeDefined() + for (const value of Object.values(secrets)) { + expect(persisted).not.toContain(value) + } + expect(JSON.parse(persisted ?? 'null')).toMatchObject({ + settings: { + terminalFontSize: 19, + httpProxyUrl: sealed(secrets.proxy), + opencodeSessionCookie: sealed(secrets.cookie) + }, + ui: { browserKagiSessionLink: sealed(secrets.kagi) }, + sshPtyConsumerRecoveries: [{ ownerLease: sealed(secrets.lease) }] + }) + reopened.store.freezeWrites() + + keyState = 'available' + const restored = open().store + expect(restored.getSettings().httpProxyUrl).toBe(secrets.proxy) + expect(restored.getSettings().opencodeSessionCookie).toBe(secrets.cookie) + expect(restored.getUI().browserKagiSessionLink).toBe(secrets.kagi) + expect(restored.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(secrets.lease) + } + ) + } +) diff --git a/src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts new file mode 100644 index 000000000000..af5043a7d231 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts @@ -0,0 +1,310 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProfileStateAuthority } from '../loading-store/profile-state-authority' +import { Store } from '../loading-store/store' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import * as profileStateDatabase from './profile-state-database' +import * as profileStateDocumentReader from './profile-state-document-reader' +import { hashProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { createProfileStateStore } from './profile-state-store-factory' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] +const stores: Store[] = [] +const authorities: ProfileStateAuthority[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + const openedStores = stores.splice(0) + const openedAuthorities = authorities.splice(0) + for (const store of openedStores) { + store.freezeWrites() + } + for (const authority of openedAuthorities) { + authority.close?.() + } + for (const store of openedStores) { + await store.flushAsync() + } + await Promise.all(openedAuthorities.map((authority) => authority.drainBackups?.())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createPaths(): { dataFile: string; databaseFile: string; profileId: string } { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-startup-snapshot-')) + directories.push(directory) + return { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: 'startup-snapshot-test' + } +} + +function createEstablishedProfile(keepJson: boolean): ReturnType { + const paths = createPaths() + const source = JSON.stringify({ settings: { theme: 'dark' }, futureDomain: { keep: true } }) + if (keepJson) { + writeFileSync(paths.dataFile, source) + } + const opened = profileStateDatabase.openProfileStateDatabase(paths.databaseFile, paths.profileId) + try { + importProfileStateJson(opened.db, source, { + acceptedLegacyJsonHash: hashProfileStateJson(source) + }) + } finally { + opened.db.close() + } + return paths +} + +describe('profile state startup snapshot handoff', () => { + it('keeps serialized-only authorities usable without SQLite capability', () => { + const paths = createPaths() + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => + id === 'node:sqlite' ? undefined : original(id) + ) + let serialized = '{"settings":{"theme":"dark"},"futureDomain":{"keep":true}}' + const authority: ProfileStateAuthority = { + readSerializedState: () => serialized, + writeSerializedState: (payload) => { + serialized = payload.toString('utf8') + } + } + const store = new Store({ dataFile: paths.dataFile, profileStateAuthority: authority }) + stores.push(store) + expect(store.getSettings().theme).toBe('dark') + expect(store.getSettings().terminalFontSize).toBeGreaterThan(0) + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + expect(JSON.parse(serialized)).toMatchObject({ + settings: { theme: 'light' }, + futureDomain: { keep: true } + }) + }) + + it.each([false, true])('reads established storage once with retained JSON=%s', (keepJson) => { + const paths = createEstablishedProfile(keepJson) + const open = vi.spyOn(profileStateDatabase, 'openProfileStateDatabaseReadOnly') + const documentRead = vi.spyOn(profileStateDocumentReader, 'readProfileStateDocuments') + const initialRead = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'readInitialState') + const serializedRead = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'readSerializedState') + const acceptedRead = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'readAcceptedState') + + const result = createProfileStateStore({ ...paths, authorityMode: 'sqlite-established' }) + stores.push(result.store) + + expect(open).toHaveBeenCalledTimes(1) + expect(documentRead).toHaveBeenCalledTimes(1) + expect(initialRead).toHaveBeenCalledTimes(keepJson ? 0 : 1) + expect(serializedRead).not.toHaveBeenCalled() + expect(acceptedRead).toHaveBeenCalledTimes(keepJson ? 1 : 0) + expect(result.store.getSettings().theme).toBe('dark') + expect(JSON.parse(result.store.prepareProfileStateExport().json)).toMatchObject({ + futureDomain: { keep: true } + }) + }) + + it('uses the validated empty snapshot without rereading SQLite or consulting JSON', () => { + const paths = createPaths() + const opened = profileStateDatabase.openProfileStateDatabase( + paths.databaseFile, + paths.profileId + ) + opened.db.close() + const bootstrapped = bootstrapProfileStateAuthority(paths) + if (bootstrapped.authority === undefined) { + throw new Error('Expected SQLite authority') + } + authorities.push(bootstrapped.authority) + expect(bootstrapped.initialState.serializedState).toBeUndefined() + const read = vi.spyOn(bootstrapped.authority, 'readSerializedState') + writeFileSync(paths.dataFile, '{"settings":{"opencodeSessionCookie":"stale-json"}}') + + const store = new Store({ + dataFile: paths.dataFile, + profileStateAuthority: bootstrapped.authority, + initialAuthorityState: bootstrapped.initialState + }) + stores.push(store) + + expect(read).not.toHaveBeenCalled() + expect(store.getSettings().opencodeSessionCookie).not.toBe('stale-json') + store.updateSettings({ theme: 'dark' }) + store.flushOrThrow() + expect(readFileSync(paths.dataFile, 'utf8')).toContain('stale-json') + expect(bootstrapped.authority.readSerializedState()).toContain('"dark"') + }) + + it('consumes a parsed startup snapshot once, including an empty revision', () => { + const paths = createPaths() + const opened = profileStateDatabase.openProfileStateDatabase( + paths.databaseFile, + paths.profileId + ) + opened.db.close() + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authorities.push(authority) + const initial = authority.readInitialState() + expect(initial.takeParsedState?.()).toBeUndefined() + expect(() => initial.takeParsedState?.()).toThrow('already been consumed') + }) + + it.each([false, true])( + 'fences a writer between bootstrap and Store construction with retained JSON=%s', + (keepJson) => { + const paths = createEstablishedProfile(keepJson) + const bootstrapped = bootstrapProfileStateAuthority(paths) + if (bootstrapped.authority === undefined) { + throw new Error('Expected SQLite authority') + } + authorities.push(bootstrapped.authority) + const opened = profileStateDatabase.openProfileStateDatabase( + paths.databaseFile, + paths.profileId + ) + try { + importProfileStateJson(opened.db, '{"settings":{"theme":"light"}}', { + expectedRevision: 1 + }) + } finally { + opened.db.close() + } + + const store = new Store({ + dataFile: paths.dataFile, + profileStateAuthority: bootstrapped.authority, + initialAuthorityState: bootstrapped.initialState + }) + stores.push(store) + + expect(store.getSettings().theme).toBe('dark') + store.updateSettings({ theme: 'system' }) + expect(() => store.flushOrThrow()).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 1, + actualRevision: 2 + }) + ) + expect(bootstrapped.authority.readSerializedState()).toBe('{"settings":{"theme":"light"}}') + } + ) + + it('restores the captured fence after a same-authority refresh', () => { + const paths = createEstablishedProfile(true) + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authorities.push(authority) + const initial = authority.readInitialState() + const writer = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authorities.push(writer) + writer.writeSerializedState(Buffer.from('{"settings":{"theme":"light"}}')) + expect(authority.readSerializedState()).toContain('"light"') + + const store = new Store({ + dataFile: paths.dataFile, + profileStateAuthority: authority, + initialAuthorityState: initial + }) + stores.push(store) + store.updateSettings({ theme: 'system' }) + expect(() => store.flushOrThrow()).toThrowError( + expect.objectContaining({ code: 'profile-state-revision-conflict', actualRevision: 2 }) + ) + expect(writer.readSerializedState()).toContain('"light"') + }) + + it('keeps direct authority reads fresh after bootstrap', () => { + const paths = createEstablishedProfile(true) + const bootstrapped = bootstrapProfileStateAuthority(paths) + if (bootstrapped.authority === undefined) { + throw new Error('Expected SQLite authority') + } + authorities.push(bootstrapped.authority) + const writer = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authorities.push(writer) + writer.writeSerializedState(Buffer.from('{"settings":{"theme":"light"}}')) + + expect(bootstrapped.authority.readSerializedState()).toBe('{"settings":{"theme":"light"}}') + writer.writeSerializedState(Buffer.from('{"settings":{"theme":"system"}}')) + expect(bootstrapped.authority.readSerializedState()).toBe('{"settings":{"theme":"system"}}') + }) + + it('decrypts and normalizes the startup snapshot through the existing Store loader', () => { + const paths = createPaths() + writeFileSync(paths.dataFile, '{"settings":{"theme":"dark"}}') + const first = createProfileStateStore({ ...paths, authorityMode: 'sqlite-candidate' }).store + stores.push(first) + first.updateSettings({ opencodeSessionCookie: 'startup-secret' }) + first.flushOrThrow() + first.freezeWrites() + + const reopened = createProfileStateStore({ + ...paths, + authorityMode: 'sqlite-established' + }).store + stores.push(reopened) + expect(reopened.getSettings().opencodeSessionCookie).toBe('startup-secret') + expect(reopened.getSettings().terminalFontSize).toBeGreaterThan(0) + reopened.updateSettings({ theme: 'light' }) + reopened.flushOrThrow() + expect(reopened.prepareProfileStateExport().json).not.toContain('startup-secret') + const again = createProfileStateStore({ ...paths, authorityMode: 'sqlite-established' }).store + stores.push(again) + expect(again.getSettings().opencodeSessionCookie).toBe('startup-secret') + expect(again.getSettings().theme).toBe('light') + }) + + it('rejects initial state without its authority or alongside migration input', () => { + const paths = createPaths() + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + const otherAuthority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + const initialAuthorityState = { authority, serializedState: '{}' } + + expect(() => new Store({ dataFile: paths.dataFile, initialAuthorityState })).toThrow( + 'must belong to its profile-state authority' + ) + expect( + () => new Store({ profileStateAuthority: otherAuthority, initialAuthorityState }) + ).toThrow('must belong to its profile-state authority') + expect( + () => + new Store({ + profileStateAuthority: authority, + initialAuthorityState, + serializedState: '{}' + }) + ).toThrow('cannot use both a profile-state authority and serialized state') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-storage-classification.ts b/src/main/persistence/profile-state/profile-state-storage-classification.ts new file mode 100644 index 000000000000..987f2a7bbe7f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-storage-classification.ts @@ -0,0 +1,32 @@ +import { existsSync } from 'node:fs' + +export type ProfileStateStorageClassification = 'json-only' | 'sqlite-only' | 'both' | 'neither' + +/** Primary first: recovery must remove it before any journal can be replayed. */ +export function profileStateDatabaseFiles(databaseFile: string): string[] { + return ['', '-wal', '-shm', '-journal'].map((suffix) => `${databaseFile}${suffix}`) +} + +/** Any surviving database-family file rules out a fresh profile or JSON fallback. */ +export function hasProfileStateDatabaseFiles(databaseFile: string): boolean { + return profileStateDatabaseFiles(databaseFile).some(existsSync) +} + +/** Classify storage without opening SQLite or changing either representation. */ +export function classifyProfileStateStorage( + dataFile: string, + databaseFile: string +): ProfileStateStorageClassification { + const hasJson = existsSync(dataFile) + const hasDatabase = hasProfileStateDatabaseFiles(databaseFile) + if (hasJson && hasDatabase) { + return 'both' + } + if (hasJson) { + return 'json-only' + } + if (hasDatabase) { + return 'sqlite-only' + } + return 'neither' +} diff --git a/src/main/persistence/profile-state/profile-state-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-store-factory.test.ts new file mode 100644 index 000000000000..6255cc9be84f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-store-factory.test.ts @@ -0,0 +1,413 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + isProfileStateSqliteAvailable, + openProfileStateDatabase, + profileStateDatabaseFile +} from './profile-state-database' +import { + createProfileStateStore as createProfileStateStoreImpl, + type ProfileStateStoreFactoryOptions +} from './profile-state-store-factory' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './profile-state-export-path' +import { ProfileStateRecoveryRequiredError } from './profile-state-authority-bootstrap' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { restoreProfileStateJsonExport } from './profile-state-recovery' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { LoadedStateParsingOperations } from '../loading-store/loaded-state-parsing' +import { hashProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { PROFILE_STATE_DATABASE_SCHEMA_VERSION } from './profile-state-database-schema' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const temporaryDirectories: string[] = [] +const storesToClose: ReturnType['store'][] = [] + +function createProfileStateStore( + options: ProfileStateStoreFactoryOptions +): ReturnType { + const result = createProfileStateStoreImpl(options) + storesToClose.push(result.store) + return result +} + +afterEach(async () => { + vi.restoreAllMocks() + const openedStores = storesToClose.splice(0) + for (const store of openedStores) { + store.freezeWrites() + } + for (const store of openedStores) { + await store.flushAsync() + } + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createOptions(): ProfileStateStoreFactoryOptions & { directory: string } { + const root = mkdtempSync(join(tmpdir(), 'orca-profile-state-store-factory-')) + temporaryDirectories.push(root) + const directory = join(root, 'profiles', 'profile-factory-test') + mkdirSync(directory, { recursive: true }) + return { + directory, + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'profile-factory-test' + } +} + +describe('profile state Store authority factory', () => { + it.each([false, true])( + 'refuses future schemas without changing storage (retained JSON=%s)', + (keepJson) => { + const options = createOptions() + const source = '{"settings":{"theme":"dark"},"futureDomain":{"keep":true}}' + const { db } = openProfileStateDatabase(options.databaseFile, options.profileId) + try { + importProfileStateJson(db, source, { acceptedLegacyJsonHash: hashProfileStateJson(source) }) + db.pragma(`user_version = ${PROFILE_STATE_DATABASE_SCHEMA_VERSION + 1}`) + } finally { + db.close() + } + if (keepJson) { + writeFileSync(options.dataFile, source) + } + const databaseBefore = readFileSync(options.databaseFile) + + expect(() => + createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) + ).toThrow(ProfileStateRecoveryRequiredError) + expect(readFileSync(options.databaseFile)).toEqual(databaseBefore) + expect(existsSync(options.dataFile)).toBe(keepJson) + if (keepJson) { + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + } + } + ) + + it('closes a migrated authority when Store normalization fails', () => { + const options = createOptions() + writeFileSync(options.dataFile, '{"settings":{"theme":"dark"}}') + const close = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'close') + const readInitialState = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'readInitialState') + vi.spyOn(LoadedStateParsingOperations.prototype, 'loadParsedFromAuthority').mockImplementation( + () => { + throw new Error('injected normalization failure') + } + ) + + expect(() => + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + ).toThrow('injected normalization failure') + expect(close).toHaveBeenCalledOnce() + expect(readInitialState).toHaveBeenCalledOnce() + const initial = readInitialState.mock.results[0] + if (initial?.type !== 'return') { + throw new Error('Expected a startup token before normalization') + } + expect(() => initial.value.takeParsedState?.()).toThrow('already been consumed') + }) + + it('uses a capability probe that remains false on a Node 18-style host', () => { + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { + if (id === 'node:sqlite') { + return undefined + } + return original(id) + }) + + expect(isProfileStateSqliteAvailable()).toBe(false) + }) + + it('keeps legacy construction read/write-compatible and does not create SQLite', () => { + const options = createOptions() + const source = JSON.stringify({ settings: { theme: 'dark' }, unknownDomain: { keep: true } }) + writeFileSync(options.dataFile, source) + + const result = createProfileStateStore(options) + + expect(result.backend).toBe('json') + expect(result.classification).toBe('json-only') + expect(result.migrated).toBe(false) + expect(result.store.getSettings().theme).toBe('dark') + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + }) + + it('uses one explicit candidate policy to migrate JSON and construct a SQLite Store', () => { + const options = createOptions() + const source = JSON.stringify({ settings: { theme: 'dark' }, unknownDomain: { keep: true } }) + writeFileSync(options.dataFile, source) + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('json-only') + expect(result.migrated).toBe(true) + expect(result.store.getSettings().theme).toBe('dark') + expect(existsSync(options.databaseFile)).toBe(true) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + }) + + it('keeps an unmigrated JSON profile on the legacy path in established mode', () => { + const options = createOptions() + const source = JSON.stringify({ settings: { theme: 'dark' } }) + writeFileSync(options.dataFile, source) + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) + + expect(result.backend).toBe('json') + expect(result.classification).toBe('json-only') + expect(result.migrated).toBe(false) + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + }) + + it('reuses the candidate authority after the legacy export is removed', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + rmSync(options.dataFile) + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('sqlite-only') + expect(result.migrated).toBe(false) + expect(result.store.getSettings().theme).toBe('dark') + }) + + it('reopens an established SQLite profile without the migration switch', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + rmSync(options.dataFile) + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('sqlite-only') + expect(result.migrated).toBe(false) + expect(result.store.getSettings().theme).toBe('dark') + }) + + it('fails closed instead of falling back to a stale JSON mirror when SQLite is missing', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const migrated = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + expect(existsSync(exportPath)).toBe(true) + migrated.store.freezeWrites() + rmSync(options.databaseFile) + + expect(() => + createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) + ).toThrow(ProfileStateRecoveryRequiredError) + }) + + it('does not let candidate mode re-import JSON after SQLite was established', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const migrated = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + migrated.store.freezeWrites() + rmSync(options.databaseFile) + + expect(() => + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + ).toThrow(ProfileStateRecoveryRequiredError) + }) + + it('reopens JSON after an explicit rollback removes the SQLite export marker', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const migrated = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + migrated.store.freezeWrites() + + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(options.directory))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + exportPath, + profileId: options.profileId + }) + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) + expect(result.backend).toBe('json') + expect(result.store.getSettings().theme).toBe('dark') + }) + + it('keeps a migrated profile on SQLite across mutation and restart', () => { + const options = createOptions() + const source = JSON.stringify({ + settings: { theme: 'light' }, + unknownDomain: { preserved: true } + }) + writeFileSync(options.dataFile, source) + const first = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + first.store.updateSettings({ theme: 'dark' }) + first.store.flushOrThrow() + + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + rmSync(options.dataFile) + const restarted = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + expect(restarted.store.getSettings().theme).toBe('dark') + expect(JSON.parse(restarted.store.prepareProfileStateExport().json)).toMatchObject({ + settings: { theme: 'dark' }, + unknownDomain: { preserved: true } + }) + }) + + it('initializes a valid empty SQLite profile instead of falling back to legacy JSON', () => { + const options = createOptions() + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + opened.db.close() + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('sqlite-only') + expect(result.store.getSettings()).toBeDefined() + result.store.updateSettings({ theme: 'dark' }) + result.store.flushOrThrow() + + const verifier = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + expect(verifier.store.getSettings().theme).toBe('dark') + }) + + it('establishes SQLite for a fresh candidate profile before its first write', () => { + const options = createOptions() + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('neither') + expect(result.migrated).toBe(false) + expect(existsSync(options.databaseFile)).toBe(true) + result.store.updateSettings({ theme: 'dark' }) + result.store.flushOrThrow() + result.store.freezeWrites() + + const restarted = createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) + expect(restarted.backend).toBe('sqlite') + expect(restarted.store.getSettings().theme).toBe('dark') + restarted.store.freezeWrites() + }) + + it('does not let legacy construction silently edit a SQLite profile', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + rmSync(options.dataFile) + + expect(() => createProfileStateStore(options)).toThrowError( + expect.objectContaining({ + code: 'profile-state-authority-required' + }) + ) + }) + + it('refuses a stale JSON mirror when candidate mode sees both files', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'light' } })) + + expect(() => + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + ).toThrow('matching acceptance marker') + }) + + it('fails closed for the Node 18 legacy path when migration leaves both authorities', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + expect(() => createProfileStateStore(options)).toThrowError( + expect.objectContaining({ + code: 'profile-state-authority-required' + }) + ) + }) + + describe.each(['legacy', 'sqlite-established', 'sqlite-candidate'] as const)( + '%s missing database recovery', + (authorityMode) => { + it.each([ + { hasJson: true, artifact: 'export' }, + { hasJson: false, artifact: 'export' }, + { hasJson: true, artifact: 'backup' }, + { hasJson: false, artifact: 'backup' } + ])( + 'fails closed with a retained $artifact and JSON present=$hasJson', + ({ hasJson, artifact }) => { + const options = createOptions() + const source = JSON.stringify({ settings: { theme: 'dark' } }) + writeFileSync(options.dataFile, source) + const migrated = createProfileStateStore({ + ...options, + authorityMode: 'sqlite-candidate' + }) + migrated.store.freezeWrites() + rmSync(options.databaseFile) + if (artifact === 'backup') { + for (const path of profileStateJsonExportPaths(options.dataFile)) { + rmSync(path) + } + writeFileSync( + `${options.databaseFile}.backup.1789999999999-00000000-0000-4000-8000-000000000000.db`, + 'reserved recovery artifact' + ) + } + if (!hasJson) { + rmSync(options.dataFile) + } + + expect(() => createProfileStateStore({ ...options, authorityMode })).toThrowError( + ProfileStateRecoveryRequiredError + ) + expect(existsSync(options.databaseFile)).toBe(false) + expect(existsSync(options.dataFile)).toBe(hasJson) + if (hasJson) { + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + } + } + ) + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-store-factory.ts b/src/main/persistence/profile-state/profile-state-store-factory.ts new file mode 100644 index 000000000000..adfcbf7055cc --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-store-factory.ts @@ -0,0 +1,126 @@ +import type { AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' +import type { ProfileStateAuthorityInitialState } from '../loading-store/profile-state-authority' +import { Store } from '../loading-store/store' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import { + classifyProfileStateStorage, + type ProfileStateStorageClassification +} from './profile-state-storage-classification' +import { assertNoRetainedProfileStateExports } from './profile-state-recovery-required' + +/** Legacy refuses SQLite; candidate migrates; established only reopens existing SQLite. */ +export type ProfileStateStoreAuthorityMode = + | 'legacy' + | 'sqlite-candidate' + /** Use SQLite only when a prior migration already established it. */ + | 'sqlite-established' + +export type ProfileStateStoreFactoryOptions = { + dataFile: string + databaseFile: string + profileId: string + storageAuthority?: AutomationStorageAuthority + authorityMode?: ProfileStateStoreAuthorityMode +} + +export class ProfileStateStoreFactoryError extends Error { + readonly code = 'profile-state-authority-required' as const + + constructor(message: string) { + super(message) + this.name = 'ProfileStateStoreFactoryError' + } +} + +export type ProfileStateStoreFactoryResult = { + store: Store + backend: 'json' | 'sqlite' + classification: ProfileStateStorageClassification + migrated: boolean +} + +/** Centralize authority selection for desktop, orcad and offline callers. */ +export function createProfileStateStore( + options: ProfileStateStoreFactoryOptions +): ProfileStateStoreFactoryResult { + const authorityMode = options.authorityMode ?? 'legacy' + const classification = classifyProfileStateStorage(options.dataFile, options.databaseFile) + if (classification === 'json-only' || classification === 'neither') { + assertNoRetainedProfileStateExports(options) + } + if (authorityMode === 'legacy') { + if (classification === 'sqlite-only' || classification === 'both') { + throw new ProfileStateStoreFactoryError( + 'SQLite profile state is present; construct the Store with sqlite-candidate authority mode' + ) + } + return { + store: createLegacyStore(options), + backend: 'json', + classification, + migrated: false + } + } + + if ( + authorityMode === 'sqlite-established' && + (classification === 'neither' || classification === 'json-only') + ) { + return { + store: createLegacyStore(options), + backend: 'json', + classification, + migrated: false + } + } + + const bootstrap = bootstrapProfileStateAuthority({ + ...options, + allowEmptyProfileState: authorityMode === 'sqlite-candidate' + }) + const authority = bootstrap.authority + if (authority === undefined) { + return { + store: createLegacyStore(options), + backend: 'json', + classification: bootstrap.classification, + migrated: bootstrap.migrated + } + } + + return { + store: createSqliteStore(options, bootstrap.initialState), + backend: 'sqlite', + classification: bootstrap.classification, + migrated: bootstrap.migrated + } +} + +function createLegacyStore(options: ProfileStateStoreFactoryOptions): Store { + return new Store({ + dataFile: options.dataFile, + ...(options.storageAuthority === undefined + ? {} + : { storageAuthority: options.storageAuthority }) + }) +} + +function createSqliteStore( + options: ProfileStateStoreFactoryOptions, + initialState: ProfileStateAuthorityInitialState +): Store { + try { + return new Store({ + dataFile: options.dataFile, + profileStateAuthority: initialState.authority, + initialAuthorityState: initialState, + ...(options.storageAuthority === undefined + ? {} + : { storageAuthority: options.storageAuthority }) + }) + } catch (error) { + // Store construction owns the authority only after its load boundary succeeds. + initialState.authority.close?.() + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-write-transaction.test.ts b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts new file mode 100644 index 000000000000..7e23ecf75b61 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { withProfileStateWriteTransaction } from './profile-state-write-transaction' + +describe('profile state write transaction ownership', () => { + it('rolls back a failed deferred commit and leaves the connection usable', () => { + const db = new Database(':memory:') + try { + db.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE parent (id INTEGER PRIMARY KEY); + CREATE TABLE child (parent_id INTEGER REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED); + `) + expect(() => + withProfileStateWriteTransaction(db, () => { + db.exec('INSERT INTO child VALUES (1)') + }) + ).toThrow(/FOREIGN KEY/) + expect(db.isTransaction).toBe(false) + expect(db.prepare('SELECT COUNT(*) AS count FROM child').get()).toMatchObject({ count: 0 }) + withProfileStateWriteTransaction(db, () => { + db.exec('INSERT INTO parent VALUES (1); INSERT INTO child VALUES (1)') + }) + expect(db.prepare('SELECT COUNT(*) AS count FROM child').get()).toMatchObject({ count: 1 }) + } finally { + db.close() + } + }) + + it('leaves a caller-owned transaction intact when a nested write is refused', () => { + const db = new Database(':memory:') + try { + db.exec('CREATE TABLE pending (id INTEGER); BEGIN; INSERT INTO pending VALUES (1)') + expect(() => + withProfileStateWriteTransaction(db, () => db.exec('DELETE FROM pending')) + ).toThrow(/idle database/) + expect(db.isTransaction).toBe(true) + db.exec('COMMIT') + expect(db.prepare('SELECT id FROM pending').get()).toMatchObject({ id: 1 }) + } finally { + db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-write-transaction.ts b/src/main/persistence/profile-state/profile-state-write-transaction.ts new file mode 100644 index 000000000000..8ed21ab36aaa --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-write-transaction.ts @@ -0,0 +1,21 @@ +import type Database from '../../sqlite/sync-database' + +/** Own the write transaction; joining a caller's transaction would weaken its revision fence. */ +export function withProfileStateWriteTransaction(db: Database.Database, write: () => T): T { + if (db.isTransaction) { + throw new Error('Profile state write requires an idle database connection') + } + db.exec('BEGIN IMMEDIATE') + try { + const result = write() + db.exec('COMMIT') + return result + } catch (error) { + try { + db.exec('ROLLBACK') + } catch { + // Preserve the write failure if rollback is unavailable. + } + throw error + } +} diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index c25315e4986b..1192b7aaf391 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -3,6 +3,7 @@ import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree import type { Automation, AutomationCreateInput, + AutomationRun, AutomationUpdateInput } from '../../../shared/automations-types' import type { PersistedState } from '../../../shared/persisted-state-types' @@ -38,6 +39,7 @@ export type AutomationDefinitionOperations = { storageAuthority: AutomationStorageAuthority flush: () => void recordCreated: () => void + recordAutomationRunsMutation?: (runs: readonly AutomationRun[]) => void } export function listAutomations(state: PersistedState): Automation[] { @@ -263,6 +265,7 @@ export function deleteAutomation( operations.state.automationRuns = (operations.state.automationRuns ?? []).filter( (entry) => entry.automationId !== id ) + operations.recordAutomationRunsMutation?.(operations.state.automationRuns) // Why: the automation and its unfinished runs were pinning their workspace; both are gone (#17775). invalidateLocalWorktreeMetadataPruneInputs() operations.flush() diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts index 0dc9e61731ad..7415fd12cf1f 100644 --- a/src/main/persistence/scheduling-automations/automation-run-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -28,6 +28,7 @@ import { export type AutomationRunOperations = { state: PersistedState flush: () => void + recordAutomationRunsMutation?: (runs: readonly AutomationRun[]) => void recordManualRun: () => void getWorkspaceDisplayName: (workspaceId: string | null | undefined) => string | null } @@ -110,6 +111,7 @@ export function createAutomationRun( ...(operations.state.automationRuns ?? []), run ]) + operations.recordAutomationRunsMutation?.(operations.state.automationRuns ?? []) if (trigger === 'manual') { operations.recordManualRun() } @@ -149,6 +151,7 @@ export function recordRepeatedAutomationSkip( } // Replaced, not patched in place: the list projection caches on array identity. operations.state.automationRuns = runs.map((run) => (run.id === latest.id ? updated : run)) + operations.recordAutomationRunsMutation?.(operations.state.automationRuns) touchAutomation(operations.state, automationId, now) operations.flush() return updated @@ -202,6 +205,7 @@ export function updateAutomationRun( operations.state.automationRuns = operations.state.automationRuns.map((run) => run.id === result.runId ? updated : run ) + operations.recordAutomationRunsMutation?.(operations.state.automationRuns) if (!isFinalAutomationRunStatus(current.status) && isFinalAutomationRunStatus(updated.status)) { // Why: only a non-final run pins its workspace, so finishing releases the claim (#17775). invalidateLocalWorktreeMetadataPruneInputs() @@ -229,6 +233,7 @@ export function snapshotAutomationRunWorkspaceDisplayName( return { ...run, workspaceDisplayName: normalizedDisplayName } }) if (updatedCount > 0) { + operations.recordAutomationRunsMutation?.(operations.state.automationRuns ?? []) operations.flush() } return updatedCount diff --git a/src/main/protected-secret-persistence.test.ts b/src/main/protected-secret-persistence.test.ts index 28a9ca6cf430..cbd1561f3449 100644 --- a/src/main/protected-secret-persistence.test.ts +++ b/src/main/protected-secret-persistence.test.ts @@ -62,6 +62,7 @@ describe('ProtectedSecretPersistence', () => { degraded: true, hashValue: ciphertext }) + expect(secrets.hasPendingEncryption()).toBe(false) cipherState.available = true expect(secrets.encrypt(slot, '')).toEqual({ @@ -74,4 +75,34 @@ describe('ProtectedSecretPersistence', () => { secrets.removeRetainedBlob(slot) expect(secrets.encrypt(slot, '')).toEqual({ blob: '', degraded: false }) }) + + it('keeps deferred encryption pending until its retention update commits', async () => { + const { ProtectedSecretPersistence } = await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + cipherState.available = false + secrets.encrypt('slot', 'pending') + expect(secrets.hasPendingEncryption()).toBe(true) + cipherState.available = true + const encrypted = secrets.encrypt('slot', 'pending') + expect(secrets.hasPendingEncryption()).toBe(true) + if (!encrypted.retentionUpdate) { + throw new Error('Expected a retention update') + } + secrets.commitRetentionUpdates([encrypted.retentionUpdate]) + expect(secrets.hasPendingEncryption()).toBe(false) + }) + + it('retires a deferred empty secret without retaining a phantom retry', async () => { + const { ProtectedSecretPersistence } = await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + cipherState.available = false + secrets.encrypt('slot', 'pending') + const cleared = secrets.encrypt('slot', '') + expect(secrets.hasPendingEncryption()).toBe(true) + if (!cleared.retentionUpdate) { + throw new Error('Expected a retention update') + } + secrets.commitRetentionUpdates([cleared.retentionUpdate]) + expect(secrets.hasPendingEncryption()).toBe(false) + }) }) diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts index 90bb2ee334d9..a07818bc160a 100644 --- a/src/main/protected-secret-persistence.ts +++ b/src/main/protected-secret-persistence.ts @@ -34,10 +34,16 @@ type ProtectedSecretEncryption = { export class ProtectedSecretPersistence { private readonly retainedBlobs = new Map() private readonly sealedSlots = new Set() + private readonly pendingEncryption = new Set() + + hasPendingEncryption(): boolean { + return this.pendingEncryption.size > 0 + } removeRetainedBlob(slot: string): void { this.retainedBlobs.delete(slot) this.sealedSlots.delete(slot) + this.pendingEncryption.delete(slot) } isSealed(slot: string, value: string): boolean { @@ -46,6 +52,7 @@ export class ProtectedSecretPersistence { commitRetentionUpdates(updates: readonly ProtectedSecretRetentionUpdate[]): void { for (const update of updates) { + this.pendingEncryption.delete(update.slot) if (update.blob === null) { this.removeRetainedBlob(update.slot) } else { @@ -58,9 +65,16 @@ export class ProtectedSecretPersistence { encrypt(slot: string, plaintext: string): ProtectedSecretEncryption { const retained = this.retainedBlobs.get(slot) ?? '' if (!plaintext && !retained) { - return { blob: '', degraded: false } + return { + blob: '', + degraded: false, + ...(this.pendingEncryption.has(slot) ? { retentionUpdate: { slot, blob: null } } : {}) + } } if (!this.encryptionAvailable()) { + if (!this.isSealed(slot, plaintext) && (plaintext || !this.sealedSlots.has(slot))) { + this.pendingEncryption.add(slot) + } return { blob: retained, degraded: true, @@ -85,6 +99,7 @@ export class ProtectedSecretPersistence { retentionUpdate: { slot, blob } } } catch (err) { + this.pendingEncryption.add(slot) console.error('[persistence] Encryption failed; retaining the prior protected value:', err) return { blob: retained, degraded: true } } diff --git a/src/main/runtime/mobile-session-terminal-retirement.ts b/src/main/runtime/mobile-session-terminal-retirement.ts index e8caba4ddf3e..0f027e8c68d5 100644 --- a/src/main/runtime/mobile-session-terminal-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-retirement.ts @@ -115,7 +115,15 @@ function chooseGroupActiveTab( if (group.activeTabId && retainedTabIds.has(group.activeTabId)) { return group.activeTabId } - const recent = (group.recentTabIds ?? []).toReversed().find((tabId) => retainedTabIds.has(tabId)) + // Node 18 is the orcad floor and does not provide Array.prototype.toReversed. + let recent: string | undefined + for (let index = (group.recentTabIds?.length ?? 0) - 1; index >= 0; index -= 1) { + const tabId = group.recentTabIds?.[index] + if (tabId && retainedTabIds.has(tabId)) { + recent = tabId + break + } + } return recent ?? group.tabOrder.find((tabId) => retainedTabIds.has(tabId)) ?? null } diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index 4d2219e3e565..39b8dbf0dcfb 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -15,6 +15,7 @@ import { RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, + TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { @@ -46,6 +47,17 @@ type RuntimeStatusHost = { ): string[] } +function supportsDurableTerminalPromptDelivery(): boolean { + if (typeof process.getBuiltinModule !== 'function') { + return false + } + try { + return process.getBuiltinModule('node:sqlite') !== undefined + } catch { + return false + } +} + export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { private asRuntimeStatusHost(): RuntimeStatusHost { return this as unknown as RuntimeStatusHost @@ -76,7 +88,9 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { (process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' || capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) && (process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' || - capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) + capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) && + (capability !== TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY || + supportsDurableTerminalPromptDelivery()) ) if (hasOffscreen || hasHeadlessCommands) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 0bfa79e43f5c..08f69b9cf998 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import type { DatabaseSync, StatementSync, SQLInputValue } from 'node:sqlite' +import type { backup, BackupOptions, DatabaseSync, StatementSync, SQLInputValue } from 'node:sqlite' type SqlitePath = ConstructorParameters[0] @@ -36,6 +36,15 @@ function loadDatabaseSync(): typeof DatabaseSync { .DatabaseSync } +function hasBackup(value: unknown): value is { backup: typeof backup } { + return ( + typeof value === 'object' && + value !== null && + 'backup' in value && + typeof value.backup === 'function' + ) +} + class SyncDatabase { private readonly db: DatabaseSync private readonly statementCache = new Map() @@ -100,6 +109,21 @@ class SyncDatabase { return this.db.isTransaction } + /** The source connection must remain open until the native backup settles. */ + async backup(path: string, options?: BackupOptions): Promise { + const sqlite: unknown = + typeof process.getBuiltinModule === 'function' + ? process.getBuiltinModule('node:sqlite') + : undefined + if (!hasBackup(sqlite)) { + throw new Error('Asynchronous SQLite backup is unavailable in this Node.js runtime') + } + if (this.db.isTransaction) { + throw new Error('Asynchronous SQLite backup requires an idle database connection') + } + return sqlite.backup(this.db, path, options ?? {}) + } + close(): void { this.statementCache.clear() this.db.close() diff --git a/src/main/ssh/orcad-remote-deploy-stop.ts b/src/main/ssh/orcad-remote-deploy-stop.ts new file mode 100644 index 000000000000..8a00a1dfa037 --- /dev/null +++ b/src/main/ssh/orcad-remote-deploy-stop.ts @@ -0,0 +1,68 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { + parseOrcadStopOutcome, + stopOrcadCommand, + type OrcadStopOutcome +} from './orcad-remote-process-control' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { compareOrcadStateSnapshotCommand, orcadSnapshotIsUnchanged } from './orcad-state-snapshot' + +export type OrcadOutgoingStopOptions = { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + nodePath: string + signal?: AbortSignal +} + +/** Stop the outgoing runtime and return its execution-host verdict. */ +export async function stopOutgoingOrcad( + options: OrcadOutgoingStopOptions, + outgoingVersion: string +): Promise { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + outgoingVersion + ) + const output = await execCommand( + options.conn, + stopOrcadCommand(options.host, outgoingDir, { waitSeconds: 20, nodePath: options.nodePath }), + { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + } + ) + return parseOrcadStopOutcome(output) +} + +/** The caller must confirm candidate exit before inspecting its shared state. */ +export async function rejectedOrcadStateRecoveryRefusal( + options: OrcadOutgoingStopOptions & { userDataDir: string }, + incumbentVersion: string, + snapshotDir: string | undefined +): Promise { + const unchanged = snapshotDir + ? orcadSnapshotIsUnchanged( + await execCommand( + options.conn, + compareOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir), + { wrapCommand: options.host.commandDialect !== 'powershell', signal: options.signal } + ).catch(() => '') + ) + : false + if (unchanged) { + return undefined + } + // RPC was already exposed; a prelaunch census cannot authorize discarding candidate writes. + const retainedSnapshot = snapshotDir ? ` at ${snapshotDir}.` : ', which is unavailable.' + return ( + 'The candidate is stopped, but profile state changed or could not be verified. ' + + `orcad ${incumbentVersion} was not restarted against potentially incompatible state. ` + + 'Current state and daemon terminals are preserved; recovery requires a fresh host ' + + `terminal census before restoring the prelaunch snapshot${retainedSnapshot}` + ) +} diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 1d9f87b009d0..8fc387184f59 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -82,6 +82,9 @@ type HostScript = { /** Readiness content per version dir, keyed by the version in the path. */ readiness: Record log: string[] + snapshotResult?: string + comparisonResult?: string + candidateStopResult?: string } function scriptHost(script: HostScript): void { @@ -100,11 +103,15 @@ function scriptHost(script: HostScript): void { } if (text.includes('kill -TERM')) { script.log.push(`stop:${text.includes(NEW_VERSION) ? NEW_VERSION : OLD_VERSION}`) - return 'STOPPED' + return text.includes(NEW_VERSION) ? (script.candidateStopResult ?? 'STOPPED') : 'STOPPED' } if (text.includes('tar -C') && text.includes('-cf')) { script.log.push('snapshot') - return 'CAPTURED' + return script.snapshotResult ?? 'CAPTURED' + } + if (text.includes('verdict=UNCHANGED')) { + script.log.push('compare-state') + return script.comparisonResult ?? 'UNCHANGED' } return '' }) @@ -218,7 +225,7 @@ describe('deployOrcad', () => { }) }) - it('snapshots the shared data root before the candidate ever runs', async () => { + it('stops the incumbent before snapshotting, so SQLite WAL files are quiescent', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, readiness: { [NEW_VERSION]: readyLine({}) }, @@ -228,6 +235,7 @@ describe('deployOrcad', () => { await deployOrcad(options()) expect(script.log.indexOf('snapshot')).toBeGreaterThan(-1) expect(script.log.indexOf('snapshot')).toBeLessThan(script.log.indexOf(`launch:${NEW_VERSION}`)) + expect(script.log.indexOf(`stop:${OLD_VERSION}`)).toBeLessThan(script.log.indexOf('snapshot')) }) it('installs but does not activate when terminals are running', async () => { @@ -278,10 +286,11 @@ describe('deployOrcad', () => { const result = await deployOrcad(options()) expect(result).toMatchObject({ outcome: 'installed-not-activated' }) expect(script.log).toEqual([ - 'snapshot', `stop:${OLD_VERSION}`, + 'snapshot', `launch:${NEW_VERSION}`, `stop:${NEW_VERSION}`, + 'compare-state', `launch:${OLD_VERSION}` ]) expect(result.outcome === 'installed-not-activated' && result.reason).toContain( @@ -289,6 +298,74 @@ describe('deployOrcad', () => { ) }) + it.each(['CHANGED', 'UNKNOWN', ''])( + 'preserves rejected candidate state when comparison is %s', + async (comparisonResult) => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({ selfTestOk: false }) }, + log: [], + comparisonResult + } + scriptHost(script) + + const result = await deployOrcad(options()) + + expect(result).toMatchObject({ outcome: 'installed-not-activated' }) + expect(script.log).toEqual([ + `stop:${OLD_VERSION}`, + 'snapshot', + `launch:${NEW_VERSION}`, + `stop:${NEW_VERSION}`, + 'compare-state' + ]) + expect(result.outcome === 'installed-not-activated' && result.reason).toContain( + 'recovery requires a fresh host terminal census' + ) + expect(result.outcome === 'installed-not-activated' && result.reason).toContain( + '/home/u/.orca-remote/orcad-state-snapshots/' + ) + expect(mockExec.mock.calls.some(([, command]) => command.includes('echo RESTORED'))).toBe( + false + ) + } + ) + + it('restarts the incumbent when a quiescent snapshot cannot be captured', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [OLD_VERSION]: readyLine({}) }, + log: [], + snapshotResult: 'tar: write failed' + } + scriptHost(script) + + await expect(deployOrcad(options())).rejects.toThrow('incumbent was stopped') + expect(script.log).toEqual([`stop:${OLD_VERSION}`, 'snapshot', `launch:${OLD_VERSION}`]) + }) + + it.each(['NO_PID', 'STILL_RUNNING', 'SIGNAL_FAILED', ''])( + 'does not inspect or replace state without confirmed candidate exit: %s', + async (candidateStopResult) => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({ selfTestOk: false }) }, + log: [], + candidateStopResult + } + scriptHost(script) + + await deployOrcad(options()) + + expect(script.log).toEqual([ + `stop:${OLD_VERSION}`, + 'snapshot', + `launch:${NEW_VERSION}`, + `stop:${NEW_VERSION}` + ]) + } + ) + it('refuses to activate when a different build answered the port', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index 88a4d923c847..43ca4e43aa8b 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -1,16 +1,7 @@ /** - * Installing orcad on a host and, only if it proves itself, making it the active one. - * - * The install half is the relay's transaction, parameterized: the same per-version lock, - * staged SFTP write, `.install-complete` sentinel and stale-lock recovery, under - * `orcad-/` instead of `relay-/`. That is what §02 marks reusable. - * - * The activation half has no relay equivalent, because the relay has no notion of a version - * being *selected*. Bytes landing in a versioned directory neither picks a version nor rolls - * one back; the activation record does, and it is written only after the candidate publishes - * a health payload that survives `evaluateOrcadActivation`. A rejected candidate leaves the - * previous version running and its own bytes on disk — nothing is lost, and a retry costs no - * upload. + * Activate installed bytes only after the candidate proves healthy. A rejected candidate + * allows restarting the incumbent only when profile state is provably unchanged; otherwise + * preserve current state and the prelaunch snapshot for explicit recovery. */ import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' @@ -40,9 +31,9 @@ import { ORCAD_LOG_FILENAME, orcadLaunchCommand, parseOrcadReadinessOutput, - readOrcadReadinessCommand, - type OrcadLaunchSpec + readOrcadReadinessCommand } from './orcad-remote-launch' +import { rejectedOrcadStateRecoveryRefusal, stopOutgoingOrcad } from './orcad-remote-deploy-stop' import { captureOrcadStateSnapshotCommand, orcadSnapshotDirName, @@ -156,6 +147,8 @@ async function captureSnapshot( outgoingVersion: string | null, takenAt: Date ): Promise { + // The caller has already stopped the outgoing runtime. This is required once profile state + // includes SQLite: a tar of a live WAL, main database, and SHM file is not a SQLite backup. const dirName = orcadSnapshotDirName(fullVersion, takenAt.getTime()) const snapshotDir = joinRemotePath( options.host, @@ -191,16 +184,20 @@ async function captureSnapshot( async function launchAndAwaitReadiness( options: OrcadDeployOptions, - spec: OrcadLaunchSpec + remoteInstallDir: string, + fullVersion: string ): Promise> { - await exec(options, orcadLaunchCommand(options.host, spec)) + await exec( + options, + orcadLaunchCommand(options.host, { ...options, remoteInstallDir, fullVersion }) + ) const deadline = Date.now() + (options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS) const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) let last = parseOrcadReadinessOutput('') while (Date.now() < deadline) { options.signal?.throwIfAborted() last = parseOrcadReadinessOutput( - await exec(options, readOrcadReadinessCommand(options.host, spec.remoteInstallDir)) + await exec(options, readOrcadReadinessCommand(options.host, remoteInstallDir)) ) if (last.state !== 'pending') { return last @@ -210,57 +207,51 @@ async function launchAndAwaitReadiness( return last } -/** - * Put the previous version back after a rejected candidate. - * - * Why this exists at all: activating means swapping which process owns the data root and the - * port, so the incumbent has to stop before the candidate can start. A gate that rejected - * and returned would leave the host with nothing running — a careful deploy causing the - * outage it was being careful about. The returned sentence goes into the caller's reason so - * the operator learns the host's actual state, not just why the candidate failed. - */ +/** Restart the incumbent only when the candidate left shared state unchanged. */ async function restoreIncumbent( options: OrcadDeployOptions, record: OrcadActivationRecord, - candidateDir: string + candidateDir?: string, + snapshot?: OrcadStateSnapshot | null ): Promise { - const stopped = parseOrcadStopOutcome( - await exec( - options, - stopOrcadCommand(options.host, candidateDir, { waitSeconds: STOP_WAIT_SECONDS }) + if (candidateDir) { + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, candidateDir, { + waitSeconds: STOP_WAIT_SECONDS, + justLaunched: true + }) + ) ) - ) - if (!orcadStopFreedTheHost(stopped)) { - return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` + if (!orcadStopFreedTheHost(stopped)) { + return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` + } } if (!record.active) { return 'No previous version was active, so this host is now serving nothing.' } + if (candidateDir) { + const snapshotDir = snapshot + ? joinRemotePath(options.host, baseDir(options), ORCAD_STATE_SNAPSHOT_DIR, snapshot.dirName) + : undefined + const refusal = await rejectedOrcadStateRecoveryRefusal(options, record.active, snapshotDir) + if (refusal) { + return refusal + } + } const incumbentDir = computeRemoteInstallDir( ORCAD_INSTALL_MODEL, options.remoteHome, record.active ) - const parsed = await launchAndAwaitReadiness(options, { - remoteInstallDir: incumbentDir, - nodePath: options.nodePath, - fullVersion: record.active, - userDataDir: options.userDataDir, - bindHost: options.bindHost, - port: options.port - }) + const parsed = await launchAndAwaitReadiness(options, incumbentDir, record.active) return parsed.state === 'ready' ? `orcad ${record.active} was restarted and is serving again.` : `orcad ${record.active} was relaunched but has not published readiness; this host may be down.` } -/** - * Install, then activate only on a green cross-process health verdict. - * - * Every early return past the install leaves the bytes on disk and the previous version - * serving, which is why they all report `installed-not-activated` rather than throwing: a - * refusal to switch is a successful outcome of a deploy that was asked to be careful. - */ +/** Activate on a healthy verdict; retain changed candidate state for explicit recovery. */ export async function deployOrcad(options: OrcadDeployOptions): Promise { const now = options.now ?? ((): Date => new Date()) const fullVersion = readLocalFullVersion(options.localOrcadDir) @@ -287,52 +278,49 @@ export async function deployOrcad(options: OrcadDeployOptions): Promise + `The incumbent could not be restarted: ${ + restartError instanceof Error ? restartError.message : String(restartError) + }` + ) + throw new Error( + `${error instanceof Error ? error.message : String(error)} The incumbent was stopped ` + + `before snapshotting; ${restored}` + ) + } + } + + const parsed = await launchAndAwaitReadiness(options, remoteDir, fullVersion) const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { buildHash: computeLocalOrcadBuildHash(options.localOrcadDir), fullVersion }) if (verdict.decision === 'reject') { - const restored = await restoreIncumbent(options, record, remoteDir) + const restored = await restoreIncumbent(options, record, remoteDir, snapshot) return { outcome: 'installed-not-activated', fullVersion, diff --git a/src/main/ssh/orcad-remote-host-support.ts b/src/main/ssh/orcad-remote-host-support.ts index dfcb0f4b6e4d..7ef8548ecdfd 100644 --- a/src/main/ssh/orcad-remote-host-support.ts +++ b/src/main/ssh/orcad-remote-host-support.ts @@ -42,9 +42,17 @@ export const ORCAD_PID_FILENAME = '.orcad-pid' * A host without `ps` yields an empty state, which falls through to "alive" — the safe * direction for both callers. */ -export function posixProcessAliveShellFunction(): string { +export function posixProcessAliveShellFunction( + options: { refuseUnverifiable?: boolean } = {} +): string { + // Destructive lifecycle steps need explicit absence; permission failures cannot prove exit. + const probe = options.refuseUnverifiable + ? 'probe_error=$(LC_ALL=C kill -0 "$1" 2>&1) || { ' + + 'case "$probe_error" in *"No such process"*) return 1;; ' + + '*) echo UNKNOWN; exit 0;; esac; }; ' + : 'kill -0 "$1" 2>/dev/null || return 1; ' return ( - 'orcad_alive() { kill -0 "$1" 2>/dev/null || return 1; ' + + `orcad_alive() { ${probe}` + 'case "$(ps -o stat= -p "$1" 2>/dev/null)" in Z*) return 1;; esac; return 0; };' ) } diff --git a/src/main/ssh/orcad-remote-launch.test.ts b/src/main/ssh/orcad-remote-launch.test.ts index 068ae588f657..0be9ddc76c1b 100644 --- a/src/main/ssh/orcad-remote-launch.test.ts +++ b/src/main/ssh/orcad-remote-launch.test.ts @@ -100,7 +100,10 @@ describe('liveness', () => { describe('stopping a running orcad', () => { it('sends SIGTERM and never SIGKILL', () => { - const command = stopOrcadCommand(posix, SPEC.remoteInstallDir, { waitSeconds: 20 }) + const command = stopOrcadCommand(posix, SPEC.remoteInstallDir, { + waitSeconds: 20, + nodePath: SPEC.nodePath + }) expect(command).toContain('kill -TERM') for (const kill of ['kill -9', 'kill -KILL', 'kill -SIGKILL', 'pkill']) { expect(command).not.toContain(kill) @@ -110,9 +113,10 @@ describe('stopping a running orcad', () => { it.each([ ['STOPPED', 'stopped', true], ['ALREADY_EXITED', 'already-exited', true], - ['NO_PID', 'no-pid', true], + ['NO_PID', 'no-pid', false], ['STILL_RUNNING', 'still-running', false], ['SIGNAL_FAILED', 'signal-failed', false], + ['UNKNOWN', 'unknown', false], ['', 'unknown', false] ])('parses %s and frees the host = %s', (output, expected, frees) => { expect(parseOrcadStopOutcome(output)).toBe(expected) diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts index a76010ccbce5..3abd43ed1d92 100644 --- a/src/main/ssh/orcad-remote-launch.ts +++ b/src/main/ssh/orcad-remote-launch.ts @@ -62,7 +62,8 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp 'umask 077 &&', `ORCA_VERSION=${shellEscape(spec.fullVersion)}`, `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, - `nohup ${shellEscape(spec.nodePath)} ${entry}`, + // Keep $! equal to the runtime PID rather than a waiting shell's PID. + `exec nohup ${shellEscape(spec.nodePath)} ${entry}`, `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, `> ${readiness} 2>> ${log} < /dev/null &`, `echo $! > ${pidFile} && cat ${pidFile}` diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts index 6a9038ea3d62..c7f78331bd5f 100644 --- a/src/main/ssh/orcad-remote-process-control.ts +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -10,6 +10,7 @@ */ import { shellEscape } from './ssh-connection-utils' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { ORCAD_READINESS_FILENAME } from './orcad-remote-launch' import { assertPosixOrcadHost as assertPosixHost, ORCAD_PID_FILENAME, @@ -19,20 +20,37 @@ import { /** * Signal the orcad recorded in a version dir and wait for it to go. * - * `escalate` sends the second SIGTERM orcad reads as "exit immediately". Callers use it only - * after the first deadline elapses, so the two signals are never in the same command. + * `justLaunched` is only for this client's fixed exec launcher, including pre-readiness exits. + * Incumbents need their own readiness PID to corroborate the launcher's PID before any signal. */ export function stopOrcadCommand( host: RemoteHostPlatform, remoteInstallDir: string, - options: { waitSeconds: number } + options: { waitSeconds: number } & ( + | { justLaunched: true } + | { justLaunched?: false; nodePath: string } + ) ): string { assertPosixHost(host) const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) + const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) + const readRuntimePid = [ + `const r = JSON.parse(require('node:fs').readFileSync(process.argv[1], 'utf8'));`, + `const pid = r?.type === 'orca_server_ready' ? r.health?.pid : null;`, + `if (!Number.isSafeInteger(pid) || pid <= 1) process.exit(1);`, + `process.stdout.write(String(pid));` + ].join(' ') return [ - posixProcessAliveShellFunction(), + posixProcessAliveShellFunction({ refuseUnverifiable: true }), `pid=$(cat ${pidFile} 2>/dev/null);`, 'case "$pid" in "" | *[!0-9]* ) echo NO_PID; exit 0;; esac;', + // Older launchers recorded a waiting shell, whose exit does not prove runtime exit. + ...(options.justLaunched + ? [] + : [ + `runtime_pid=$(${shellEscape(options.nodePath)} -e ${shellEscape(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`, + '[ "$pid" = "$runtime_pid" ] || { echo UNKNOWN; exit 0; };' + ]), 'orcad_alive "$pid" || { echo ALREADY_EXITED; exit 0; };', 'kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };', `i=0; while [ "$i" -lt ${options.waitSeconds} ]; do`, @@ -69,5 +87,5 @@ export function parseOrcadStopOutcome(output: string): OrcadStopOutcome { /** True when the port is free and a successor may bind. */ export function orcadStopFreedTheHost(outcome: OrcadStopOutcome): boolean { - return outcome === 'stopped' || outcome === 'already-exited' || outcome === 'no-pid' + return outcome === 'stopped' || outcome === 'already-exited' } diff --git a/src/main/ssh/orcad-remote-rollback.ts b/src/main/ssh/orcad-remote-rollback.ts index 03df9479e4a3..985bc05a7b3a 100644 --- a/src/main/ssh/orcad-remote-rollback.ts +++ b/src/main/ssh/orcad-remote-rollback.ts @@ -142,7 +142,10 @@ export async function rollbackOrcad(options: OrcadRollbackOptions): Promise() function sh(command: string): string { return execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' }) @@ -52,10 +75,141 @@ beforeEach(() => { }) afterEach(() => { + for (const pid of launchedPids) { + try { + process.kill(pid, 'SIGKILL') + } catch { + // Successful stops have already removed their test processes. + } + } + launchedPids.clear() rmSync(root, { recursive: true, force: true }) }) +async function launchTestRuntime(legacyWrapper = false): Promise<{ + runtimePid: number + recordedPid: number + terminatedFile: string +}> { + const terminatedFile = join(versionDir, 'terminated') + writeFileSync( + join(versionDir, 'orcad.js'), + [ + `process.on('SIGTERM', () => {`, + ` require('node:fs').writeFileSync(${JSON.stringify(terminatedFile)}, 'terminated');`, + ` process.exit(0);`, + `});`, + `console.log(JSON.stringify({type: 'orca_server_ready', health: {pid: process.pid}}));`, + `setTimeout(() => process.exit(1), 10_000);` + ].join('\n') + ) + let command = orcadLaunchCommand(host, { + remoteInstallDir: versionDir, + nodePath: process.execPath, + fullVersion: '0.2.0+bb01', + userDataDir: dataDir, + bindHost: '127.0.0.1', + port: 0 + }) + if (legacyWrapper) { + // The trailing command retains the old macOS waiting-shell behavior on every POSIX shell. + command = command + .replace('exec nohup ', 'nohup ') + .replace('< /dev/null &', '< /dev/null && : &') + } + execFileSync('/bin/sh', ['-c', command], { stdio: 'ignore', timeout: 5_000 }) + const recordedPid = Number(readFileSync(join(versionDir, ORCAD_PID_FILENAME), 'utf8').trim()) + expect(recordedPid).toBeGreaterThan(1) + launchedPids.add(recordedPid) + const readRuntimePid = (): number => { + const parsed = parseOrcadReadinessOutput( + readFileSync(join(versionDir, ORCAD_READINESS_FILENAME), 'utf8') + ) + return parsed.state === 'ready' ? (parsed.readiness.health?.pid ?? 0) : 0 + } + await expect.poll(readRuntimePid, { timeout: 2_000, interval: 20 }).toBeGreaterThan(1) + const runtimePid = readRuntimePid() + launchedPids.add(runtimePid) + return { runtimePid, recordedPid, terminatedFile } +} + +function stopTestRuntime(justLaunched = false): ReturnType { + return parseOrcadStopOutcome( + execFileSync( + '/bin/sh', + [ + '-c', + stopOrcadCommand(host, versionDir, { + waitSeconds: 3, + ...(justLaunched ? { justLaunched: true as const } : { nodePath: process.execPath }) + }) + ], + { encoding: 'utf8', timeout: 5_000 } + ) + ) +} + describe('state snapshot commands, run for real', () => { + it('detects candidate SQLite migration without modifying current state or the snapshot', () => { + sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) + const archive = readFileSync(join(snapshotDir, 'state.tar')) + const compare = (): boolean => + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + expect(compare()).toBe(true) + writeFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'live-daemon-after-launch') + expect(compare()).toBe(true) + + const databasePath = join(dataDir, 'profiles', 'p1', 'profile-state.db') + const candidate = openProfileStateDatabase(databasePath, 'p1') + try { + importProfileStateJson(candidate.db, '{"settings":{"theme":"dark"}}') + } finally { + candidate.db.close() + } + const databaseBytes = readFileSync(databasePath) + + expect(compare()).toBe(false) + expect(readFileSync(databasePath)).toEqual(databaseBytes) + expect(readFileSync(join(snapshotDir, 'state.tar'))).toEqual(archive) + expect(readFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'utf8')).toBe( + 'live-daemon-after-launch' + ) + }) + + it('requires an intact comparison snapshot before an older build can restart', () => { + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe(false) + sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) + writeFileSync(join(snapshotDir, 'state.tar'), 'not an archive') + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe(false) + expect(readFileSync(join(dataDir, 'profiles', 'p1', 'orca-data.json'), 'utf8')).toBe( + '{"repos":"before"}' + ) + }) + + it('rejects symlinked profile state that a tar snapshot does not preserve', () => { + const external = join(root, 'external-profile') + mkdirSync(external) + writeFileSync(join(external, 'orca-data.json'), '{"before":true}') + const profile = join(dataDir, 'profiles', 'linked') + symlinkSync(external, profile) + + expect( + parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('failed') + + mkdirSync(snapshotDir, { recursive: true }) + execFileSync('tar', ['-C', dataDir, '-cf', join(snapshotDir, 'state.tar'), 'profiles']) + writeFileSync(join(external, 'orca-data.json'), '{"candidate":true}') + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe(false) + expect(readFileSync(join(external, 'orca-data.json'), 'utf8')).toBe('{"candidate":true}') + }) + it('captures, then restores state the newer build overwrote', () => { expect( parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) @@ -74,6 +228,49 @@ describe('state snapshot commands, run for real', () => { expect(() => readFileSync(join(dataDir, 'profiles', 'p1', 'new-build-only.json'))).toThrow() }) + it('round-trips a quiescent SQLite profile database with its WAL sidecars', () => { + const profileDirectory = join(dataDir, 'profiles', 'p1') + const databasePath = join(profileDirectory, 'profile-state.db') + const opened = openProfileStateDatabase(databasePath, 'p1') + try { + importProfileStateJson( + opened.db, + JSON.stringify({ settings: { theme: 'dark' }, snapshotMarker: 'before' }) + ) + // The connection remains open, so WAL/SHM are still part of the archive boundary while + // the generated command reads the now-quiescent files. + expect(existsSync(`${databasePath}-wal`)).toBe(true) + expect( + parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('captured') + } finally { + opened.db.close() + } + + const changed = openProfileStateDatabase(databasePath, 'p1') + try { + importProfileStateJson( + changed.db, + JSON.stringify({ settings: { theme: 'light' }, snapshotMarker: 'after' }) + ) + } finally { + changed.db.close() + } + expect( + parseOrcadSnapshotRestore(sh(restoreOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('restored') + + const restored = openProfileStateDatabase(databasePath, 'p1') + try { + expect(JSON.parse(exportProfileStateJson(restored.db))).toEqual({ + settings: { theme: 'dark' }, + snapshotMarker: 'before' + }) + } finally { + restored.db.close() + } + }) + it('leaves the live daemon runtime dir untouched through capture and restore', () => { sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) // The daemon is running across the rollback and rewrites its token; a restore that @@ -115,7 +312,13 @@ describe('state snapshot commands, run for real', () => { expect( parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, nasty, snapshotDir))) ).toBe('captured') + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, nasty, snapshotDir))) + ).toBe(true) writeFileSync(join(nasty, 'orca-profile-index.json'), '{"v":"changed"}') + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, nasty, snapshotDir))) + ).toBe(false) expect( parseOrcadSnapshotRestore(sh(restoreOrcadStateSnapshotCommand(host, nasty, snapshotDir))) ).toBe('restored') @@ -124,6 +327,85 @@ describe('state snapshot commands, run for real', () => { }) describe('liveness and stop commands, run for real', () => { + it('records the runtime PID and waits for that runtime to exit when stopped', async () => { + const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime() + expect(recordedPid).toBe(runtimePid) + expect(stopTestRuntime()).toBe('stopped') + expect(readFileSync(terminatedFile, 'utf8')).toBe('terminated') + // An unreaped zombie has exited even though kill -0 still succeeds. + expect(sh(`ps -o stat= -p ${runtimePid} || true`).trim()).toMatch(/^(?:Z.*)?$/) + expect(existsSync(join(versionDir, ORCAD_PID_FILENAME))).toBe(true) + expect(stopTestRuntime()).toBe('already-exited') + }) + + it('refuses a legacy wrapper PID both before and after its shell exits', async () => { + const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime(true) + expect(recordedPid).not.toBe(runtimePid) + const beforeWrapperExit = stopTestRuntime() + expect(beforeWrapperExit).toBe('unknown') + expect(orcadStopFreedTheHost(beforeWrapperExit)).toBe(false) + expect(() => process.kill(recordedPid, 0)).not.toThrow() + expect(() => process.kill(runtimePid, 0)).not.toThrow() + + process.kill(recordedPid, 'SIGTERM') + await expect + .poll(() => sh(`ps -o stat= -p ${recordedPid} || true`).trim(), { timeout: 2_000 }) + .toMatch(/^(?:Z.*)?$/) + const afterWrapperExit = stopTestRuntime() + expect(afterWrapperExit).toBe('unknown') + expect(orcadStopFreedTheHost(afterWrapperExit)).toBe(false) + expect(() => process.kill(runtimePid, 0)).not.toThrow() + expect(existsSync(terminatedFile)).toBe(false) + }) + + it.each(['missing', 'malformed', 'without-health'])( + 'refuses an incumbent with %s readiness proof', + async (proof) => { + const { runtimePid, terminatedFile } = await launchTestRuntime() + const readinessFile = join(versionDir, ORCAD_READINESS_FILENAME) + if (proof === 'missing') { + rmSync(readinessFile) + } else { + writeFileSync(readinessFile, proof === 'malformed' ? '{' : '{"type":"orca_server_ready"}') + } + expect(stopTestRuntime()).toBe('unknown') + expect(() => process.kill(runtimePid, 0)).not.toThrow() + expect(existsSync(terminatedFile)).toBe(false) + } + ) + + it('can stop a candidate just launched with exec without readiness', async () => { + const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime() + expect(recordedPid).toBe(runtimePid) + rmSync(join(versionDir, ORCAD_READINESS_FILENAME)) + expect(stopTestRuntime(true)).toBe('stopped') + expect(readFileSync(terminatedFile, 'utf8')).toBe('terminated') + expect(sh(`ps -o stat= -p ${runtimePid} || true`).trim()).toMatch(/^(?:Z.*)?$/) + }) + + it.each(['before', 'after'])('refuses a permission-denied probe %s SIGTERM', async (phase) => { + const { runtimePid, terminatedFile } = await launchTestRuntime() + const deniedProbe = [ + 'term_sent=0; kill() {', + 'if [ "$1" = -TERM ]; then term_sent=1; return 0; fi;', + `if [ '${phase}' = before ] || [ "$term_sent" = 1 ]; then`, + 'echo "kill: Operation not permitted" >&2; return 1; fi;', + 'command kill "$@"; };' + ].join(' ') + const outcome = parseOrcadStopOutcome( + sh( + `${deniedProbe} ${stopOrcadCommand(host, versionDir, { + waitSeconds: 1, + nodePath: process.execPath + })}` + ) + ) + expect(outcome).toBe('unknown') + expect(orcadStopFreedTheHost(outcome)).toBe(false) + expect(() => process.kill(runtimePid, 0)).not.toThrow() + expect(existsSync(terminatedFile)).toBe(false) + }) + it('reports UNKNOWN with no pid file, and DEAD for a pid that has exited', () => { expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('UNKNOWN') writeFileSync(join(versionDir, ORCAD_PID_FILENAME), 'not-a-pid') @@ -144,7 +426,9 @@ describe('liveness and stop commands, run for real', () => { child.once('exit', (_code, signal) => resolve(signal)) ) expect( - parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10 }))) + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10, justLaunched: true })) + ) ).toBe('stopped') expect(await exited).toBe('SIGTERM') expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') @@ -166,7 +450,9 @@ describe('liveness and stop commands, run for real', () => { expect(sh(`kill -0 ${child.pid} 2>/dev/null && echo LIVE || echo DEAD`).trim()).toBe('LIVE') expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') expect( - parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 }))) + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true })) + ) ).toBe('already-exited') } finally { child.unref() @@ -176,14 +462,18 @@ describe('liveness and stop commands, run for real', () => { it('reports ALREADY_EXITED for a stale pid file rather than signalling a stranger', () => { const exited = Number(sh('sh -c "echo $$"').trim()) writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(exited)) - expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))).toBe( - 'already-exited' - ) + expect( + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true })) + ) + ).toBe('already-exited') }) it('reports NO_PID when the version dir was never launched', () => { - expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))).toBe( - 'no-pid' - ) + expect( + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, nodePath: process.execPath })) + ) + ).toBe('no-pid') }) }) diff --git a/src/main/ssh/orcad-state-snapshot.test.ts b/src/main/ssh/orcad-state-snapshot.test.ts index 0c50dc55fdbe..f660e95e5a42 100644 --- a/src/main/ssh/orcad-state-snapshot.test.ts +++ b/src/main/ssh/orcad-state-snapshot.test.ts @@ -4,6 +4,7 @@ import { ORCAD_SNAPSHOT_EXCLUDED, ORCAD_SNAPSHOT_MEMBERS, captureOrcadStateSnapshotCommand, + compareOrcadStateSnapshotCommand, newestStateMtimeCommand, orcadSnapshotDirName, parseNewestStateMtimeSeconds, @@ -93,6 +94,7 @@ describe('Windows hosts', () => { it.each([ ['capture', () => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP)], ['restore', () => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP)], + ['compare', () => compareOrcadStateSnapshotCommand(windows, ROOT, SNAP)], ['mtime', () => newestStateMtimeCommand(windows, ROOT)] ])('refuses %s rather than emitting a POSIX command', (_label, build) => { expect(build).toThrow('orcad to a Windows host is not implemented') diff --git a/src/main/ssh/orcad-state-snapshot.ts b/src/main/ssh/orcad-state-snapshot.ts index 78ad8b47d2ac..7d0622754424 100644 --- a/src/main/ssh/orcad-state-snapshot.ts +++ b/src/main/ssh/orcad-state-snapshot.ts @@ -27,7 +27,9 @@ export const ORCAD_SNAPSHOT_MEMBERS = [ 'orca-profile-index.json', // Pre-profiles layout; still read as a migration source. 'orca-data.json', - 'profiles' + 'profiles', + // Cross-profile SQLite moves must survive an orcad rollback too. + 'profile-move-intents' ] as const /** Never captured and never restored — see the module comment. */ @@ -45,6 +47,10 @@ function assertPlainMemberName(member: string): string { return member } +function noSymlinkedStateCommand(path: string): string { + return `links=$(find ${path} -type l -print) && [ -z "$links" ]` +} + export function orcadSnapshotDirName(fullVersion: string, takenAtMs: number): string { // Why the version and the timestamp: two activations of one version (a re-deploy after a // rejected activation) must not overwrite each other's snapshot. @@ -72,8 +78,14 @@ export function captureOrcadStateSnapshotCommand( // Why the accumulated name is NOT quoted: `$members` is re-split by the shell before it // reaches tar, so a quoted name arrives as a literal `'profiles'` that tar cannot stat. // `assertPlainMemberName` is what makes leaving them bare safe. - (member) => - `[ -e ${root}/${shellEscape(member)} ] && members="$members ${assertPlainMemberName(member)}";` + (member) => { + const path = `${root}/${shellEscape(member)}` + return ( + `if [ -e ${path} ] || [ -L ${path} ]; then ` + + `${noSymlinkedStateCommand(path)} || { echo FAILED; exit 0; }; ` + + `members="$members ${assertPlainMemberName(member)}"; fi;` + ) + } ).join(' ') return [ `members=;`, @@ -145,6 +157,42 @@ export function parseOrcadSnapshotRestore(output: string): OrcadSnapshotRestore return value === 'MISSING' ? 'missing' : 'failed' } +/** Compare after stopping the candidate; a changed root cannot be handed to an older build. */ +export function compareOrcadStateSnapshotCommand( + host: RemoteHostPlatform, + userDataDir: string, + snapshotDir: string +): string { + assertPosixHost(host) + const root = shellEscape(userDataDir) + const dir = shellEscape(snapshotDir) + const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) + const comparisons = ORCAD_SNAPSHOT_MEMBERS.map((member) => { + const name = shellEscape(member) + return [ + `if [ -e ${root}/${name} ] || [ -L ${root}/${name} ]; then`, + `${noSymlinkedStateCommand(`${root}/${name}`)} || { echo UNKNOWN; exit 0; };`, + `diff -r ${root}/${name} "$comparison"/${name} >/dev/null 2>&1 || verdict=CHANGED;`, + `elif [ -e "$comparison"/${name} ] || [ -L "$comparison"/${name} ]; then verdict=CHANGED; fi;` + ].join(' ') + }).join(' ') + return [ + `test -d ${root} && test -r ${root} && test -x ${root} || { echo UNKNOWN; exit 0; };`, + `test -f ${archive} || { echo UNKNOWN; exit 0; };`, + `comparison=$(mktemp -d ${dir}/compare.XXXXXX) || { echo UNKNOWN; exit 0; };`, + `trap 'rm -rf "$comparison"' EXIT HUP INT TERM;`, + `tar -C "$comparison" -xf ${archive} || { echo UNKNOWN; exit 0; };`, + `${noSymlinkedStateCommand('"$comparison"')} || { echo UNKNOWN; exit 0; };`, + 'verdict=UNCHANGED;', + comparisons, + 'echo "$verdict"' + ].join(' ') +} + +export function orcadSnapshotIsUnchanged(output: string): boolean { + return output.trim().split('\n').pop()?.trim() === 'UNCHANGED' +} + /** * Has the shared store been written since `activatedAt`? * diff --git a/src/main/startup/browser-process-user-agent-ordering.test.ts b/src/main/startup/browser-process-user-agent-ordering.test.ts index 5913890b5cf7..3fbc9ba32c82 100644 --- a/src/main/startup/browser-process-user-agent-ordering.test.ts +++ b/src/main/startup/browser-process-user-agent-ordering.test.ts @@ -30,7 +30,7 @@ const mocks = vi.hoisted(() => { events.push(`set-name:${name}`) }) } - return { app, events, userAgent: () => userAgent } + return { app, events, userAgent: () => userAgent, admission: vi.fn() } }) vi.mock('electron', () => ({ @@ -127,6 +127,12 @@ vi.mock('../persistence', () => ({ initDataPath: () => mocks.events.push('init-data-path'), getCanonicalUserDataPath: () => '/canonical-user-data' })) +vi.mock('../persistence/profile-state/profile-state-access', () => ({ + acquireProfileStateRuntimeAdmission: (root: string) => { + mocks.events.push(`admission:${root}`) + return mocks.admission() + } +})) vi.mock('../macos-press-and-hold-default') vi.mock('../ai-vault/session-parse-cache-persistence') vi.mock('../orca-profiles/profile-index-store') @@ -180,6 +186,10 @@ describe('browser process user-agent startup ordering', () => { const writeIndex = mocks.events.indexOf('write-user-agent') const continuationIndex = mocks.events.indexOf('continued-after-browser-identity') expect(mocks.events.indexOf('init-data-path')).toBeLessThan(nameIndex) + expect(mocks.events.indexOf('init-data-path')).toBeLessThan( + mocks.events.indexOf('admission:/canonical-user-data') + ) + expect(mocks.events.indexOf('admission:/canonical-user-data')).toBeLessThan(modeIndex) expect(nameIndex).toBeLessThan(modeIndex) expect(modeIndex).toBeLessThan(writeIndex) expect(writeIndex).toBeLessThan(continuationIndex) @@ -191,4 +201,24 @@ describe('browser process user-agent startup ordering', () => { expect(mocks.userAgent()).not.toMatch(/Electron/) expect(mocks.userAgent()).not.toMatch(/Orca|Development/) }) + + it('exits without reading profile state or revealing a window when recovery holds admission', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + mocks.events.length = 0 + mocks.admission.mockImplementationOnce(() => { + throw new Error('Profile recovery is in progress') + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const focusExistingWindow = vi.fn() + const requestDesktopActivation = vi.fn() + try { + expect(runMainProcessPreflight({ focusExistingWindow, requestDesktopActivation })).toBe(false) + expect(mocks.app.exit).toHaveBeenCalledWith(1) + expect(mocks.events).toEqual(['init-data-path', 'admission:/canonical-user-data']) + expect(focusExistingWindow).not.toHaveBeenCalled() + expect(requestDesktopActivation).not.toHaveBeenCalled() + } finally { + error.mockRestore() + } + }) }) diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts index 0c001b74f444..22d90947cedf 100644 --- a/src/main/startup/cli-command-names.ts +++ b/src/main/startup/cli-command-names.ts @@ -48,6 +48,7 @@ export const CLI_COMMAND_NAMES = [ 'open-url', 'orchestration', 'pdf', + 'profile', 'project', 'reload', 'repo', diff --git a/src/main/startup/configure-process-profile-state.test.ts b/src/main/startup/configure-process-profile-state.test.ts new file mode 100644 index 000000000000..ed9a6176af5e --- /dev/null +++ b/src/main/startup/configure-process-profile-state.test.ts @@ -0,0 +1,46 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => ''), + quit: vi.fn(), + exit: vi.fn(), + isPackaged: false, + disableHardwareAcceleration: vi.fn(), + commandLine: { appendSwitch: vi.fn(), getSwitchValue: vi.fn(() => '') } + } +})) + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('pre-ready profile-state recovery boundary', () => { + it('ignores a matching marker when SQLite is missing but an export remains', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const { writeHttp1CompatibilityMarker } = await import('./http1-compatibility-marker') + const { profileStateJsonExportPath } = + await import('../persistence/profile-state/profile-state-export-path') + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-profile-')) + temporaryDirectories.push(userDataPath) + const profileDirectory = join(userDataPath, 'profiles', 'profile-b') + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: 'profile-b', profiles: [{ id: 'profile-b' }] }) + ) + const dataFile = join(profileDirectory, 'orca-data.json') + writeFileSync(dataFile, JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } })) + writeHttp1CompatibilityMarker(userDataPath, true, 'profile-b') + writeFileSync(profileStateJsonExportPath(dataFile, 7), '{}') + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) + }) +}) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 7d7e2b0cc1a7..fd4379354c41 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -420,6 +420,22 @@ describe('configureElectronNetworkCompatibility', () => { return userDataPath } + function createProfileState( + userDataPath: string, + profileId: string, + settings: Record + ): string { + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }), + 'utf-8' + ) + writeFileSync(join(profileDirectory, 'orca-data.json'), JSON.stringify({ settings }), 'utf-8') + return profileDirectory + } + afterEach(() => { for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) @@ -504,6 +520,40 @@ describe('configureElectronNetworkCompatibility', () => { ).toBe(false) }) + it('scopes a profile marker to the active profile before trusting it', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const { writeHttp1CompatibilityMarker } = await import('./http1-compatibility-marker') + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-profile-')) + tempDirs.push(userDataPath) + createProfileState(userDataPath, 'profile-b', { electronHttp1CompatibilityMode: false }) + writeHttp1CompatibilityMarker(userDataPath, true, 'profile-a') + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) + }) + + it('uses a matching profile marker even when the legacy JSON is stale', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const { writeHttp1CompatibilityMarker } = await import('./http1-compatibility-marker') + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-profile-')) + tempDirs.push(userDataPath) + createProfileState(userDataPath, 'profile-b', { electronHttp1CompatibilityMode: false }) + writeHttp1CompatibilityMarker(userDataPath, true, 'profile-b') + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(true) + }) + + it('fails closed when a profile database exists without a trusted marker', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-profile-')) + tempDirs.push(userDataPath) + const profileDirectory = createProfileState(userDataPath, 'profile-b', { + electronHttp1CompatibilityMode: true + }) + writeFileSync(join(profileDirectory, 'profile-state.db'), 'sqlite-present', 'utf-8') + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) + }) + it('appends Electron disable-http2 before sessions are created', async () => { const { app } = await import('electron') const { configureElectronNetworkCompatibility } = await import('./configure-process') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 13dbd70c2c39..95e1df91337c 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -1,11 +1,16 @@ import { app } from 'electron' -import { existsSync, mkdirSync, readFileSync } from 'node:fs' +import { mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { getVersionManagerBinPaths } from '../codex-cli/command' import { getMainE2EConfig } from '../e2e-config' import { DISABLED_CHROMIUM_FEATURES } from './disabled-chromium-features' import { readHttp1CompatibilityMarker } from './http1-compatibility-marker' +import { + hasMissingProfileStateDatabaseWithRetainedExport, + readActiveProfileId, + readPersistedHttp1CompatibilityMode +} from './http1-compatibility-profile-state' const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000 const HTTP1_COMPATIBILITY_ENV_VAR = 'ORCA_DISABLE_HTTP2' @@ -32,22 +37,6 @@ function parseBooleanEnvFlag(value: string | undefined): boolean | null { return null } -function readPersistedHttp1CompatibilityMode(userDataPath: string): boolean { - const dataFile = join(userDataPath, 'orca-data.json') - if (!existsSync(dataFile)) { - return false - } - - try { - const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as { - settings?: { electronHttp1CompatibilityMode?: unknown } - } - return parsed.settings?.electronHttp1CompatibilityMode === true - } catch { - return false - } -} - export function shouldDisableHttp2ForElectronNetworking( options: NetworkCompatibilityOptions = {} ): boolean { @@ -56,11 +45,22 @@ export function shouldDisableHttp2ForElectronNetworking( return envValue } const userDataPath = options.userDataPath ?? app.getPath('userData') + const activeProfileId = readActiveProfileId(userDataPath) // Why the marker first: this runs before app.whenReady(), and the settings file is the multi-MB - // orca-data.json the Store parses again moments later. The marker is refreshed whenever settings - // change, so the full read only happens on a profile that has never written one. + // profile document the Store parses again moments later. The marker is refreshed whenever + // settings change; an untrusted SQLite profile fails closed rather than falling back to JSON. + if ( + activeProfileId !== undefined && + activeProfileId !== null && + hasMissingProfileStateDatabaseWithRetainedExport(userDataPath, activeProfileId) + ) { + return false + } return ( - readHttp1CompatibilityMarker(userDataPath) ?? readPersistedHttp1CompatibilityMode(userDataPath) + (activeProfileId === null + ? null + : readHttp1CompatibilityMarker(userDataPath, activeProfileId)) ?? + readPersistedHttp1CompatibilityMode(userDataPath) ) } diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 5e4d4cfe4289..376a41fd3076 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -104,6 +104,32 @@ describe('startup ordering', () => { expect(foundationSource.split('initializeBrowserClientHostId(')).toHaveLength(2) }) + it('fails closed with offline recovery guidance when profile state is unreadable', () => { + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + expect(entrySource).toContain('formatProfileStateStartupFailure') + expect(entrySource).toContain('const message = formatProfileStateStartupFailure(error)') + expect(entrySource).toContain('presentProfileStateStartupRecoveryDialog') + expect(entrySource).toContain( + "!state.isServeMode && process.env.ORCA_BACKGROUND_LAUNCH !== '1'" + ) + expect(entrySource).toContain( + "console.warn('[profile-state] Recovery dialog failed; exiting safely:'" + ) + expect(entrySource).toContain('app.exit(1)') + }) + + it('initializes telemetry before publishing profile-state authority selection', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-observers.ts'), + 'utf8' + ) + const telemetryInit = source.indexOf('initTelemetry(store)') + const authoritySelection = source.indexOf("track('profile_state_authority_selected'") + expect(telemetryInit).toBeGreaterThanOrEqual(0) + expect(authoritySelection).toBeGreaterThan(telemetryInit) + }) + it('requires daemon authority before restored-subagent liveness runs', () => { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index 3b661dede99e..c761ccf0409f 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -40,7 +40,7 @@ describe('headless PTY registry hydration ordering', () => { it('hydrates orcad after Store and daemon readiness but before RPC and publication', () => { const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') - const store = source.indexOf('const store = new Store(') + const store = source.indexOf('createOrcadProfileStateStartup(runtimeUserDataPath)') const daemon = source.indexOf('await startOrcadDaemon()', store) const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', daemon) const rpc = source.indexOf('await rpc.start()', handlersAndHydration) @@ -57,7 +57,7 @@ describe('headless PTY registry hydration ordering', () => { const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') const cleanup = source.indexOf('registerCleanup(async () => {') const hookStop = source.indexOf('agentHookServer.stop()', cleanup) - const store = source.indexOf('const store = new Store(') + const store = source.indexOf('createOrcadProfileStateStartup(runtimeUserDataPath)') const hookStart = source.indexOf('await agentHookServer.start(', store) const daemon = source.indexOf('await startOrcadDaemon()', hookStart) const hookEnv = source.indexOf('buildAgentHookPtyEnv:', daemon) diff --git a/src/main/startup/http1-compatibility-marker.ts b/src/main/startup/http1-compatibility-marker.ts index 9e85a83be66f..88de78ab8085 100644 --- a/src/main/startup/http1-compatibility-marker.ts +++ b/src/main/startup/http1-compatibility-marker.ts @@ -1,8 +1,12 @@ -import { readFileSync, writeFileSync } from 'node:fs' +import { readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' +import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write' +import { bestEffortFsyncDirectorySync } from '../../shared/secure-file' /** * Cached copy of `settings.electronHttp1CompatibilityMode` for pre-`ready` startup. + * Version 2 carries the active profile ID so a profile switch cannot reuse the + * previous profile's network compatibility choice. * * Why a standalone file (not the Store): app.commandLine.appendSwitch('disable-http2') must run * before the first Electron session exists, which is before the settings Store is constructed. @@ -12,11 +16,13 @@ import { join } from 'node:path' */ export const HTTP1_COMPATIBILITY_MARKER_FILE = 'http1-compatibility.json' -const MARKER_SCHEME_VERSION = 1 +const LEGACY_MARKER_SCHEME_VERSION = 1 +const MARKER_SCHEME_VERSION = 2 type Http1CompatibilityMarker = { schemeVersion: number enabled: boolean + profileId?: string } function markerPath(userDataPath: string): string { @@ -24,12 +30,30 @@ function markerPath(userDataPath: string): string { } /** Returns null when the marker is missing or unreadable, so callers fall back to the settings file. */ -export function readHttp1CompatibilityMarker(userDataPath: string): boolean | null { +export function readHttp1CompatibilityMarker( + userDataPath: string, + expectedProfileId?: string +): boolean | null { try { const parsed = JSON.parse( readFileSync(markerPath(userDataPath), 'utf-8') ) as Partial - if (parsed.schemeVersion !== MARKER_SCHEME_VERSION || typeof parsed.enabled !== 'boolean') { + if (typeof parsed.enabled !== 'boolean') { + return null + } + if (parsed.schemeVersion === LEGACY_MARKER_SCHEME_VERSION) { + // A v1 marker predates profile-scoped state. It remains useful for a + // legacy install with no profile index, but cannot be trusted once the + // active profile is known. + return expectedProfileId === undefined ? parsed.enabled : null + } + if ( + parsed.schemeVersion !== MARKER_SCHEME_VERSION || + typeof parsed.profileId !== 'string' || + parsed.profileId.length === 0 || + expectedProfileId === undefined || + parsed.profileId !== expectedProfileId + ) { return null } return parsed.enabled @@ -38,14 +62,28 @@ export function readHttp1CompatibilityMarker(userDataPath: string): boolean | nu } } -export function writeHttp1CompatibilityMarker(userDataPath: string, enabled: boolean): void { - if (readHttp1CompatibilityMarker(userDataPath) === enabled) { +export function writeHttp1CompatibilityMarker( + userDataPath: string, + enabled: boolean, + profileId?: string +): void { + if (readHttp1CompatibilityMarker(userDataPath, profileId) === enabled) { return } - const marker: Http1CompatibilityMarker = { schemeVersion: MARKER_SCHEME_VERSION, enabled } + const marker: Http1CompatibilityMarker = + profileId === undefined + ? { schemeVersion: LEGACY_MARKER_SCHEME_VERSION, enabled } + : { schemeVersion: MARKER_SCHEME_VERSION, enabled, profileId } try { - writeFileSync(markerPath(userDataPath), JSON.stringify(marker)) + const targetPath = markerPath(userDataPath) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, JSON.stringify(marker)) } catch { - // Best effort: a missing marker just costs the next launch the settings-file fallback. + // Best effort: a missing marker makes the next launch fail closed or read legacy JSON. } } + +/** Recovery must discard the old authority's cached setting before publishing JSON authority. */ +export function invalidateHttp1CompatibilityMarker(userDataPath: string): void { + rmSync(markerPath(userDataPath), { force: true }) + bestEffortFsyncDirectorySync(userDataPath) +} diff --git a/src/main/startup/http1-compatibility-profile-state.test.ts b/src/main/startup/http1-compatibility-profile-state.test.ts new file mode 100644 index 000000000000..27fe1c30a6a1 --- /dev/null +++ b/src/main/startup/http1-compatibility-profile-state.test.ts @@ -0,0 +1,141 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + hasMissingProfileStateDatabaseWithRetainedExport, + readActiveProfileId, + readPersistedHttp1CompatibilityMode +} from './http1-compatibility-profile-state' +import { profileStateJsonExportPath } from '../persistence/profile-state/profile-state-export-path' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createUserData(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-http1-profile-state-')) + temporaryDirectories.push(directory) + return directory +} + +function writeIndex(userDataPath: string, activeProfileId: string): void { + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: 1, + activeProfileId, + profiles: [{ id: activeProfileId }] + }) + ) +} + +describe('pre-ready profile-state compatibility lookup', () => { + it('uses the legacy install-level JSON before a profile index exists', () => { + const userDataPath = createUserData() + writeFileSync( + join(userDataPath, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + + expect(readActiveProfileId(userDataPath)).toBeUndefined() + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(true) + }) + + it('reads only the active profile JSON once the index is valid', () => { + const userDataPath = createUserData() + writeIndex(userDataPath, 'work') + const profileDirectory = join(userDataPath, 'profiles', 'work') + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(userDataPath, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + rmSync(join(userDataPath, 'orca-data.json')) + writeFileSync( + join(profileDirectory, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + + expect(readActiveProfileId(userDataPath)).toBe('work') + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(true) + }) + + it.each(['-wal', '-shm', '-journal'])('fails closed when only SQLite %s remains', (suffix) => { + const userDataPath = createUserData() + writeIndex(userDataPath, 'work') + const profileDirectory = join(userDataPath, 'profiles', 'work') + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(profileDirectory, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + writeFileSync(join(profileDirectory, `profile-state.db${suffix}`), 'orphaned') + + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(false) + + writeFileSync(join(userDataPath, 'orca-profile-index.json'), '{ malformed') + writeFileSync( + join(userDataPath, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + expect(readActiveProfileId(userDataPath)).toBe(null) + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(false) + }) + + it.each(['json-export', 'database-backup'])( + 'fails closed when SQLite is missing but a retained %s remains', + (artifact) => { + const userDataPath = createUserData() + writeIndex(userDataPath, 'work') + const profileDirectory = join(userDataPath, 'profiles', 'work') + mkdirSync(profileDirectory, { recursive: true }) + const dataFile = join(profileDirectory, 'orca-data.json') + writeFileSync( + dataFile, + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + writeFileSync( + artifact === 'json-export' + ? profileStateJsonExportPath(dataFile, 7) + : join( + profileDirectory, + 'profile-state.db.backup.1789999999999-00000000-0000-4000-8000-000000000000.db' + ), + readFileSync(dataFile) + ) + + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(false) + } + ) + + it.each(['json-export', 'database-backup'])( + 'detects a missing profile database with a retained %s', + (artifact) => { + const userDataPath = createUserData() + writeIndex(userDataPath, 'work') + const profileDirectory = join(userDataPath, 'profiles', 'work') + mkdirSync(profileDirectory, { recursive: true }) + const dataFile = join(profileDirectory, 'orca-data.json') + writeFileSync( + dataFile, + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + writeFileSync( + artifact === 'json-export' + ? profileStateJsonExportPath(dataFile, 7) + : join( + profileDirectory, + 'profile-state.db.backup.1789999999999-00000000-0000-4000-8000-000000000000.db' + ), + readFileSync(dataFile) + ) + + expect(hasMissingProfileStateDatabaseWithRetainedExport(userDataPath, 'work')).toBe(true) + } + ) +}) diff --git a/src/main/startup/http1-compatibility-profile-state.ts b/src/main/startup/http1-compatibility-profile-state.ts new file mode 100644 index 000000000000..f41569a73c8b --- /dev/null +++ b/src/main/startup/http1-compatibility-profile-state.ts @@ -0,0 +1,122 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { profileStateJsonExportPaths } from '../persistence/profile-state/profile-state-export-path' +import { profileStateDatabaseBackups } from '../persistence/profile-state/profile-state-backup-path' +import { hasProfileStateDatabaseFiles } from '../persistence/profile-state/profile-state-storage-classification' + +// Keep the pre-ready graph small; this stable ID mirrors DEFAULT_LOCAL_ORCA_PROFILE_ID. +const DEFAULT_LOCAL_PROFILE_ID = 'local-default' + +/** `null` means malformed index; `undefined` means a pre-profile legacy install. */ +export function readActiveProfileId(userDataPath: string): string | null | undefined { + const indexPath = join(userDataPath, 'orca-profile-index.json') + const candidates = [indexPath, `${indexPath}.bak`].filter(existsSync) + if (candidates.length === 0) { + return undefined + } + for (const candidate of candidates) { + try { + const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf-8')) + if (!isRecord(parsed) || typeof parsed.activeProfileId !== 'string') { + continue + } + if ( + /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(parsed.activeProfileId) && + Array.isArray(parsed.profiles) && + parsed.profiles.some( + (profile) => isRecord(profile) && profile.id === parsed.activeProfileId + ) + ) { + return parsed.activeProfileId + } + } catch { + // A torn primary can still have a valid recovery index. + } + } + return null +} + +/** Read JSON only when no profile database is present; pre-ready cannot open SQLite safely. */ +export function readPersistedHttp1CompatibilityMode(userDataPath: string): boolean { + const activeProfileId = readActiveProfileId(userDataPath) + if (activeProfileId === null) { + // A malformed profile index leaves the active profile unknowable. + return false + } + + const profileDataFile = + activeProfileId === undefined + ? undefined + : join(userDataPath, 'profiles', activeProfileId, 'orca-data.json') + const profileDatabaseFile = + activeProfileId === undefined + ? undefined + : join(userDataPath, 'profiles', activeProfileId, 'profile-state.db') + // SQLite is authoritative once present; a missing marker therefore fails closed. + if (profileDatabaseFile !== undefined && hasProfileStateDatabaseFiles(profileDatabaseFile)) { + return false + } + + if ( + activeProfileId !== undefined && + activeProfileId !== DEFAULT_LOCAL_PROFILE_ID && + (profileDataFile === undefined || !existsSync(profileDataFile)) + ) { + // A known but unseeded non-default profile has default settings. The + // install-level legacy file belongs to another profile and must not leak. + return false + } + const dataFile = profileDataFile ?? join(userDataPath, 'orca-data.json') + // A retained migration export proves SQLite was established. Do not let the + // pre-ready path read a stale JSON mirror while recovery is required. + try { + if ( + profileStateJsonExportPaths(dataFile).length > 0 || + profileStateDatabaseBackups( + profileDatabaseFile ?? join(dirname(dataFile), 'profile-state.db') + ).length > 0 + ) { + return false + } + } catch { + return false + } + if (!existsSync(dataFile)) { + return false + } + + try { + const parsed: unknown = JSON.parse(readFileSync(dataFile, 'utf-8')) + if (!isRecord(parsed) || !isRecord(parsed.settings)) { + return false + } + return parsed.settings.electronHttp1CompatibilityMode === true + } catch { + return false + } +} + +/** Return whether a retained SQLite export makes pre-ready JSON/marker state untrusted. */ +export function hasMissingProfileStateDatabaseWithRetainedExport( + userDataPath: string, + profileId: string +): boolean { + const profileDirectory = join(userDataPath, 'profiles', profileId) + const databaseFile = join(profileDirectory, 'profile-state.db') + if (hasProfileStateDatabaseFiles(databaseFile)) { + return false + } + const dataFile = join(profileDirectory, 'orca-data.json') + try { + return ( + profileStateJsonExportPaths(dataFile).length > 0 || + profileStateDatabaseBackups(databaseFile).length > 0 + ) + } catch { + return true + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index b0ee60f88c2f..a4000449250c 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -57,6 +57,16 @@ export function initializeMainProcessObservers(): void { } // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. initTelemetry(store) + const profileStateStartup = state.profileStateStartup + if (profileStateStartup) { + track('profile_state_authority_selected', { + backend: profileStateStartup.backend, + classification: profileStateStartup.classification, + authority_mode: profileStateStartup.authorityMode, + runtime: profileStateStartup.runtime, + migrated: profileStateStartup.migrated + }) + } // Why: the breadcrumb alone never leaves the machine — it rides crash reports, and a hang is not // a crash (the app is force-quit, so no report is ever generated). Without this the incidence // number the watchdog exists to produce would sit unread on the user's disk. Must run after diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 98d41093c5f3..b6e3237695e0 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -3,6 +3,7 @@ import { is } from '@electron-toolkit/utils' import os from 'node:os' import { join } from 'node:path' import { maybeRedirectCliLaunch } from './cli-launch-redirect' +import { runProfileStateRecoveryPreflight } from './profile-state-recovery-preflight' import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' import { configureDevUserDataPath, @@ -67,6 +68,8 @@ import { initDataPath, getCanonicalUserDataPath } from '../persistence' import { applyMacPressAndHoldDefaultAtStartup } from '../macos-press-and-hold-default' import { initSessionParseCachePersistence } from '../ai-vault/session-parse-cache-persistence' import { initOrcaProfilePaths } from '../orca-profiles/profile-index-store' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { recoverPendingProfileProjectMoves } from '../orca-profiles/profile-project-move-intent' import { initStatsPath } from '../stats/collector' import { initClaudeUsagePath } from '../claude-usage/store' import { initCodexUsagePath } from '../codex-usage/store' @@ -89,6 +92,7 @@ import { mainProcessState as state } from './main-process-state' import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' import { initializeBrowserProcessUserAgent } from '../browser/browser-process-user-agent' import { initializeBrowserIdentityModeStore } from '../browser/browser-identity-mode-store' +import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' export type MainProcessPreflightOptions = { focusExistingWindow: () => void @@ -97,6 +101,9 @@ export type MainProcessPreflightOptions = { /** Performs all module-scope work that must happen before Electron's ready event. */ export function runMainProcessPreflight(options: MainProcessPreflightOptions): boolean { + if (runProfileStateRecoveryPreflight()) { + return false + } // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. // The redirect runs before the serve-argv rewrite so it still matches on the launch argv verbatim. // Direct serve stays in-process so its signal handlers own all children. @@ -181,6 +188,14 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b // Why captured now: after the dev/E2E override above, and before app.setName('Orca') (whenReady) // changes how userData resolves on a case-sensitive filesystem. See persistence.ts:20-28. initDataPath() + // Keep admission until process death, including outstanding backup workers and final flushes. + try { + acquireProfileStateRuntimeAdmission(getCanonicalUserDataPath()) + } catch (error) { + console.error('[profile-state] Startup refused:', error) + app.exit(1) + return false + } // Why: Electron resolves the macOS safeStorage Keychain service name from the app name before // ready. Dev pins userData above, so applying its name here cannot shift the captured path. if (state.devInstanceIdentity && shouldApplyPreReadyAppName(state.devInstanceIdentity)) { @@ -282,6 +297,9 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b appVersion: app.getVersion() }) initOrcaProfilePaths() + // A crash can leave a cross-profile SQLite move between its two commits. Resolve + // that journal before any Store opens a profile, so no reader observes a half-move. + recoverPendingProfileProjectMoves(getProfileUserDataPath()) // Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28. initStatsPath() initClaudeUsagePath() diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 073791f6d3cb..557c995f4aea 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -200,7 +200,9 @@ function installWillQuitHandler(): void { const sshShutdown = beginSshShutdown() killAllPty() const watcherShutdown = shutdownWatchersOnce() - const storeFlush = state.store?.flushAsync() ?? Promise.resolve() + const storeFlush = + state.store?.flushAsync({ exportJsonCompatibility: updateQuitInProgress }) ?? + Promise.resolve() // Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a // scan completion would drop the final snapshot. Captured before any await; joins the barrier below. const usageCacheFlush = Promise.all([ diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 3c0e01fe09eb..f518cdf0fe11 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -11,7 +11,11 @@ import { } from '../hang-watchdog/hang-detection-marker' import { browserCertificateTrustController } from '../browser/browser-manager' import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' -import { Store, getCanonicalUserDataPath } from '../persistence' +import { getCanonicalUserDataPath } from '../persistence' +import { + createProfileStateStoreForStartup, + desktopProfileStateAuthorityMode +} from '../persistence/profile-state/profile-state-startup-authority' import { initializeBrowserClientHostId } from '../browser/browser-client-host-id' import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report' import { initSshHostKeyStoreFile } from '../ssh/ssh-host-key-store' @@ -134,10 +138,23 @@ export async function initializeReadyFoundation(): Promise { // Why this early: the first window stamps the hosting id into its renderer's argv, so the durable // read has to have happened by then or the renderer and the browser-host lease disagree. initializeBrowserClientHostId(profile.profileDirectory) - const store = new Store({ + const profileStateAuthorityMode = desktopProfileStateAuthorityMode() + const profileState = createProfileStateStoreForStartup({ dataFile: profile.dataFile, + databaseFile: profile.stateDatabaseFile, + profileId: profile.profile.id, + runtime: 'desktop', + authorityMode: profileStateAuthorityMode, storageAuthority: state.isServeMode ? 'runtime' : 'desktop' }) + state.profileStateStartup = { + backend: profileState.backend, + classification: profileState.classification, + authorityMode: profileStateAuthorityMode, + runtime: 'desktop', + migrated: profileState.migrated + } + const store = profileState.store state.store = store // Why: create pending readiness before the guard can observe the default session. // Why parked on state instead of awaited here: Dock/Launchpad launches don't inherit shell @@ -197,7 +214,8 @@ export async function initializeReadyFoundation(): Promise { // Why: pre-`ready` startup reads this flag from a marker so it never has to parse orca-data.json. writeHttp1CompatibilityMarker( canonicalUserDataPath, - store.getSettings().electronHttp1CompatibilityMode === true + store.getSettings().electronHttp1CompatibilityMode === true, + profile.profile.id ) // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) @@ -205,7 +223,8 @@ export async function initializeReadyFoundation(): Promise { if ('electronHttp1CompatibilityMode' in updates) { writeHttp1CompatibilityMarker( canonicalUserDataPath, - settings.electronHttp1CompatibilityMode === true + settings.electronHttp1CompatibilityMode === true, + profile.profile.id ) } if ('terminalWindowsWslDistro' in updates) { diff --git a/src/main/startup/main-process-ready-identity-write.test.ts b/src/main/startup/main-process-ready-identity-write.test.ts index 13011ccfb6b4..eb6c29c6555f 100644 --- a/src/main/startup/main-process-ready-identity-write.test.ts +++ b/src/main/startup/main-process-ready-identity-write.test.ts @@ -82,6 +82,17 @@ vi.mock('../persistence', () => ({ }, getCanonicalUserDataPath: () => mocks.userDataPath })) +vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({ + desktopProfileStateAuthorityMode: () => 'legacy', + createProfileStateStoreForStartup: () => ({ + store: { + getSettings: () => ({}), + onSettingsChanged: () => {}, + getClaudeLivePtySessionIds: () => [], + getSshTargets: () => [] + } + }) +})) // The registry reads the canonical path from this module, not from '../persistence'. vi.mock('../persistence/loading-store/user-data-path', () => ({ getCanonicalUserDataPath: () => mocks.userDataPath diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 49f86136e895..c0df4ad05336 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -45,6 +45,20 @@ import { } from '../crash-reporting/gpu-crash-fallback-decision' import type { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' import { createWebContentsTimedFlag } from './web-contents-timed-flag' +import type { ProfileStateStorageClassification } from '../persistence/profile-state/profile-state-storage-classification' +import type { ProfileStateStoreAuthorityMode } from '../persistence/profile-state/profile-state-store-factory' + +export type ProfileStateStartupMetadata = { + backend: 'json' | 'sqlite' + classification: ProfileStateStorageClassification + authorityMode: ProfileStateStoreAuthorityMode + runtime: 'desktop' | 'orcad' + migrated: boolean +} + +function createInitialProfileStateStartup(): ProfileStateStartupMetadata | null { + return null +} /** Mutable composition-root state shared by startup, window, serve, and quit phases. */ export const mainProcessState = { @@ -52,6 +66,7 @@ export const mainProcessState = { /** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */ isQuitting: false, store: null as Store | null, + profileStateStartup: createInitialProfileStateStartup(), stats: null as StatsCollector | null, claudeUsage: null as ClaudeUsageStore | null, codexUsage: null as CodexUsageStore | null, diff --git a/src/main/startup/main-window-core-services.test.ts b/src/main/startup/main-window-core-services.test.ts new file mode 100644 index 000000000000..f4b34ae48580 --- /dev/null +++ b/src/main/startup/main-window-core-services.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + attachMainWindowServicesMock, + initTccPromptNoticeMock, + preserveAgentAuthBeforeRestartMock, + registerCoreHandlersMock, + state, + store +} = vi.hoisted(() => { + const store = { + writeLatestProfileStateJsonCompatibilityExport: vi.fn(), + writeLatestProfileStateJsonExport: vi.fn(), + getSettings: vi.fn(() => ({})) + } + return { + attachMainWindowServicesMock: vi.fn(), + initTccPromptNoticeMock: vi.fn(), + preserveAgentAuthBeforeRestartMock: vi.fn(() => Promise.resolve()), + registerCoreHandlersMock: vi.fn(), + state: { + store, + runtime: {}, + stats: {}, + claudeUsage: {}, + codexUsage: {}, + openCodeUsage: {}, + museUsage: {}, + codexAccounts: {}, + claudeAccounts: {}, + rateLimits: { attach: vi.fn(), start: vi.fn() }, + automations: { setWebContents: vi.fn(), start: vi.fn() }, + keybindings: {}, + codexRuntimeHome: {}, + claudeRuntimeAuth: { prepareForClaudeLaunch: vi.fn() }, + agentAwakeService: null, + crashReports: null, + pluginService: null, + pluginMarketplaceService: null, + pluginMarketplaceInstaller: null, + desktopRelayService: null, + isServeMode: false, + localPtyStartupReady: Promise.resolve(), + localPtyProviderStartupReady: Promise.resolve() + }, + store + } +}) + +vi.mock('../ipc/register-core-handlers/register-core-handlers', () => ({ + registerCoreHandlers: registerCoreHandlersMock +})) +vi.mock('../window/attach-main-window-services', () => ({ + attachMainWindowServices: attachMainWindowServicesMock +})) +vi.mock('../macos-tcc-prompt-notice', () => ({ initTccPromptNotice: initTccPromptNoticeMock })) +vi.mock('../updater', () => ({ resolveUpdateInstallMode: vi.fn(() => 'interactive') })) +vi.mock('./main-process-state', () => ({ mainProcessState: state })) +vi.mock('../agent-auth-restart-preservation', () => ({ + preserveAgentAuthBeforeRestart: preserveAgentAuthBeforeRestartMock +})) +vi.mock('../codex/codex-ai-vault-session-resume', () => ({ + prepareCodexAiVaultSessionResume: vi.fn() +})) +vi.mock('../codex/codex-session-source-home', () => ({ + resolveHostCodexSessionSourceHome: vi.fn() +})) +vi.mock('./main-process-pty-startup', () => ({ + emitPluginWorktreeLifecycle: vi.fn(), + handleCodexHomePtySpawned: vi.fn(), + handlePtyExit: vi.fn() +})) +vi.mock('./codex-launch-preparation', () => ({ prepareCodexRuntimeHomeForLaunch: vi.fn() })) +vi.mock('./codex-session-resume-launch', () => ({ prepareCodexSessionResumeForLaunch: vi.fn() })) +vi.mock('./main-window-lifecycle-flags', () => ({ isRecoveryReloadInFlight: vi.fn() })) + +const { attachMainWindowCoreServices } = await import('./main-window-core-services') + +describe('main window profile-state update preparation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('publishes rollback and compatibility exports before an update quit', async () => { + const window = { webContents: { id: 17 } } + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: mocked BrowserWindow only needs webContents for this composition-root wiring test. + attachMainWindowCoreServices(window as never, { + markExpectedRendererReload: vi.fn(), + recordRendererReload: vi.fn() + }) + + const options = attachMainWindowServicesMock.mock.calls[0]?.[5] + if ( + typeof options !== 'object' || + options === null || + !('onBeforeUpdateQuit' in options) || + typeof options.onBeforeUpdateQuit !== 'function' + ) { + throw new Error('Expected update quit cleanup to be wired') + } + + await options.onBeforeUpdateQuit() + + expect(preserveAgentAuthBeforeRestartMock).toHaveBeenCalledWith({ + codexRuntimeHome: state.codexRuntimeHome, + claudeRuntimeAuth: state.claudeRuntimeAuth, + store + }) + expect(store.writeLatestProfileStateJsonExport).toHaveBeenCalledOnce() + expect(store.writeLatestProfileStateJsonCompatibilityExport).toHaveBeenCalledOnce() + expect(options).toHaveProperty('onBeforeUpdateQuitFailure', 'abort') + expect(store.writeLatestProfileStateJsonExport.mock.invocationCallOrder[0]).toBeLessThan( + store.writeLatestProfileStateJsonCompatibilityExport.mock.invocationCallOrder[0] + ) + }) +}) diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 8f9ef2118dfc..c19446aec6cd 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -126,8 +126,12 @@ export function attachMainWindowCoreServices( isRecoveryReloadInFlight, onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit, - onBeforeUpdateQuit: () => - preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), + onBeforeUpdateQuit: async () => { + await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) + store.writeLatestProfileStateJsonExport() + store.writeLatestProfileStateJsonCompatibilityExport() + }, + onBeforeUpdateQuitFailure: 'abort', updateInstallMode: resolveUpdateInstallMode(state.isServeMode), onWorktreeLifecycle: emitPluginWorktreeLifecycle } diff --git a/src/main/startup/profile-state-recovery-preflight.test.ts b/src/main/startup/profile-state-recovery-preflight.test.ts new file mode 100644 index 000000000000..5517580d3987 --- /dev/null +++ b/src/main/startup/profile-state-recovery-preflight.test.ts @@ -0,0 +1,269 @@ +import type * as NodeFs from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PROFILE_STATE_RECOVERY_FLAG, + PROFILE_STATE_RECOVERY_RESULT_PREFIX +} from '../../shared/profile-state-recovery-command' +import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' +import { profileStateJsonExportPath } from '../persistence/profile-state/profile-state-export-path' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from '../persistence/profile-state/profile-state-backup-path' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../persistence/profile-state/profile-state-database' +import { + importProfileStateJson, + readProfileStateSnapshot +} from '../persistence/profile-state/profile-state-documents' +import { writeProfileStateDatabaseSnapshotAsync } from '../persistence/profile-state/profile-state-database-snapshot' +import * as marker from './http1-compatibility-marker' +import { runProfileStateRecoveryPreflight } from './profile-state-recovery-preflight' + +const mocks = vi.hoisted(() => ({ + setPath: vi.fn(), + requestSingleInstanceLock: vi.fn(), + on: vi.fn(), + exit: vi.fn(), + background: vi.fn(), + output: vi.fn() +})) +vi.mock('electron', () => ({ app: mocks })) +vi.mock('../window/foreground-activation-policy', () => ({ + applyBackgroundActivationPolicy: mocks.background +})) +vi.mock('node:fs', async (original) => { + const fs = await original() + return { + ...fs, + writeFileSync: (...args: Parameters) => { + if (args[0] === 1) { + mocks.output(args[1]) + return + } + return fs.writeFileSync(...args) + } + } +}) + +const roots: string[] = [] +beforeEach(() => { + vi.clearAllMocks() + mocks.requestSingleInstanceLock.mockReturnValue(true) + vi.stubEnv('ORCA_USER_DATA_PATH', '/stale/inherited/root') + vi.stubEnv('ORCA_BYPASS_SINGLE_INSTANCE_LOCK', '1') + vi.stubEnv('ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK', '0') +}) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-recovery-bridge-'))) + roots.push(root) + const profileId = 'bridge-profile' + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + const exportFile = profileStateJsonExportPath(dataFile, 1) + const restored = { + settings: { electronHttp1CompatibilityMode: true }, + unknown: { sealed: 'unchanged', missing: null } + } + writeFileSync(dataFile, JSON.stringify({ old: true })) + writeFileSync(databaseFile, 'broken database') + writeFileSync(exportFile, JSON.stringify(restored)) + const argv = [ + 'Orca', + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ userDataPath: root, selector: { kind: 'json', revision: 1 } }) + ] + return { root, profileId, dataFile, databaseFile, exportFile, restored, argv } +} + +function response(): unknown { + const output: unknown = mocks.output.mock.calls[0]?.[0] + expect(typeof output).toBe('string') + if (typeof output !== 'string') { + throw new Error('Missing response') + } + return JSON.parse(output.slice(PROFILE_STATE_RECOVERY_RESULT_PREFIX.length)) +} + +describe('Electron recovery preflight', () => { + it('runs the recovery branch before CLI redirect or ordinary startup admission', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const start = source.indexOf('export function runMainProcessPreflight(') + const recovery = source.indexOf('if (runProfileStateRecoveryPreflight())', start) + const redirect = source.indexOf('const cliLaunchRedirect = maybeRedirectCliLaunch(', start) + const admission = source.indexOf('acquireProfileStateRuntimeAdmission(', start) + expect(start).toBeGreaterThanOrEqual(0) + expect(recovery).toBeGreaterThan(start) + expect(redirect).toBeGreaterThan(recovery) + expect(admission).toBeGreaterThan(redirect) + expect(source.slice(recovery, redirect)).toContain('return false') + }) + + it('leaves ordinary startup untouched', () => { + expect(runProfileStateRecoveryPreflight(['Orca', '--serve'])).toBe(false) + expect(mocks.background).not.toHaveBeenCalled() + expect(mocks.setPath).not.toHaveBeenCalled() + expect(mocks.exit).not.toHaveBeenCalled() + }) + + it('restores under both locks with an explicit root even when ordinary singleton checks bypass', () => { + const item = fixture() + mocks.requestSingleInstanceLock.mockImplementation(() => { + expect(mocks.setPath).toHaveBeenCalledWith('userData', item.root) + expect(() => acquireProfileStateRuntimeAdmission(item.root)).toThrow() + expect(readFileSync(item.databaseFile, 'utf8')).toBe('broken database') + return true + }) + expect(runProfileStateRecoveryPreflight(item.argv)).toBe(true) + expect(process.env.ORCA_USER_DATA_PATH).toBe(item.root) + expect(process.env.ORCA_BACKGROUND_LAUNCH).toBe('1') + expect(mocks.background).toHaveBeenCalledOnce() + expect(mocks.requestSingleInstanceLock).toHaveBeenCalledOnce() + expect(response()).toMatchObject({ + ok: true, + result: { storage: 'json', revision: 1, restoredPath: item.dataFile } + }) + expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual(item.restored) + expect(existsSync(item.databaseFile)).toBe(false) + expect(existsSync(item.exportFile)).toBe(false) + expect(mocks.exit).toHaveBeenCalledWith(0) + const runtime = acquireProfileStateRuntimeAdmission(item.root) + runtime.release() + }) + + it('refuses an old native singleton owner without modifying authority or exports', () => { + const item = fixture() + mocks.requestSingleInstanceLock.mockReturnValue(false) + expect(runProfileStateRecoveryPreflight(item.argv)).toBe(true) + expect(response()).toMatchObject({ + ok: false, + code: 'runtime_error', + message: expect.stringContaining('Stop Orca') + }) + expect(readFileSync(item.databaseFile, 'utf8')).toBe('broken database') + expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual({ old: true }) + expect(existsSync(item.exportFile)).toBe(true) + expect(mocks.exit).toHaveBeenCalledWith(1) + const runtime = acquireProfileStateRuntimeAdmission(item.root) + runtime.release() + }) + + it('exits quietly when the CLI closes its response pipe after a successful restore', () => { + const item = fixture() + mocks.output.mockImplementationOnce(() => { + throw new Error('EPIPE') + }) + expect(() => runProfileStateRecoveryPreflight(item.argv)).not.toThrow() + expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual(item.restored) + expect(mocks.exit).toHaveBeenCalledWith(1) + const runtime = acquireProfileStateRuntimeAdmission(item.root) + runtime.release() + }) + + it('restores a real SQLite backup and retains root exclusion through marker publication', async () => { + const item = fixture() + rmSync(item.databaseFile) + const source = openProfileStateDatabase(item.databaseFile, item.profileId) + const backupId = createProfileStateDatabaseBackupId() + const backupFile = profileStateDatabaseBackupPath(item.databaseFile, backupId) + try { + importProfileStateJson(source.db, JSON.stringify(item.restored)) + await writeProfileStateDatabaseSnapshotAsync(source.db, backupFile) + importProfileStateJson(source.db, JSON.stringify({ newer: true }), { expectedRevision: 1 }) + } finally { + source.db.close() + } + const markerWrite = marker.writeHttp1CompatibilityMarker + const write = vi + .spyOn(marker, 'writeHttp1CompatibilityMarker') + .mockImplementation((...args) => { + expect(() => acquireProfileStateRuntimeAdmission(item.root)).toThrow() + expect(mocks.requestSingleInstanceLock).toHaveBeenCalledOnce() + markerWrite(...args) + }) + const argv = [ + 'Orca', + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ userDataPath: item.root, selector: { kind: 'sqlite', backupId } }) + ] + expect(runProfileStateRecoveryPreflight(argv)).toBe(true) + expect(response()).toMatchObject({ + ok: true, + result: { storage: 'sqlite', backupId, revision: 1 } + }) + expect(write).toHaveBeenCalledWith(item.root, true, item.profileId) + expect(existsSync(item.dataFile)).toBe(false) + expect(existsSync(backupFile)).toBe(true) + const restored = openProfileStateDatabaseReadOnly(item.databaseFile, item.profileId) + try { + expect(JSON.parse(readProfileStateSnapshot(restored.db).json)).toEqual(item.restored) + } finally { + restored.db.close() + } + expect(mocks.exit).toHaveBeenCalledWith(0) + }) + + it('refuses a participating Node runtime before asking for the Electron lock', () => { + const item = fixture() + const runtime = acquireProfileStateRuntimeAdmission(item.root) + try { + expect(runProfileStateRecoveryPreflight(item.argv)).toBe(true) + expect(response()).toMatchObject({ ok: false, code: 'runtime_error' }) + expect(mocks.requestSingleInstanceLock).not.toHaveBeenCalled() + expect(existsSync(item.exportFile)).toBe(true) + } finally { + runtime.release() + } + }) + + it.each([ + ['Orca', PROFILE_STATE_RECOVERY_FLAG, '{}'], + ['Orca', '--serve', PROFILE_STATE_RECOVERY_FLAG], + ['Orca', '--serve', PROFILE_STATE_RECOVERY_FLAG, '{}'], + [ + 'Orca', + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ userDataPath: 'relative', selector: { kind: 'json', revision: 1 } }) + ], + ['Orca', '--serve', PROFILE_STATE_RECOVERY_FLAG, '{}', PROFILE_STATE_RECOVERY_FLAG, '{}'] + ])('fails closed for malformed launch %j', (...argv) => { + expect(runProfileStateRecoveryPreflight(argv)).toBe(true) + expect(response()).toMatchObject({ ok: false }) + expect(mocks.setPath).not.toHaveBeenCalled() + expect(mocks.requestSingleInstanceLock).not.toHaveBeenCalled() + expect(mocks.exit).toHaveBeenCalledWith(1) + }) +}) diff --git a/src/main/startup/profile-state-recovery-preflight.ts b/src/main/startup/profile-state-recovery-preflight.ts new file mode 100644 index 000000000000..68ccbe68a13d --- /dev/null +++ b/src/main/startup/profile-state-recovery-preflight.ts @@ -0,0 +1,76 @@ +import { writeFileSync, realpathSync } from 'node:fs' +import { isAbsolute } from 'node:path' +import { app } from 'electron' +import { + PROFILE_STATE_RECOVERY_FLAG, + PROFILE_STATE_RECOVERY_RESULT_PREFIX, + ProfileStateRecoveryCommandError, + isProfileStateRecoveryCommandError, + profileStateRecoveryRequestSchema, + type ProfileStateRecoveryResponse +} from '../../shared/profile-state-recovery-command' +import { acquireProfileStateMaintenance } from '../persistence/profile-state/profile-state-access' +import { rollbackProfileState } from '../persistence/profile-state/profile-state-recovery-command' +import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' +import { acquireSingleInstanceLock } from './single-instance-lock' + +/** The process owning both locks performs recovery before Electron can initialize a runtime. */ +export function runProfileStateRecoveryPreflight(argv: readonly string[] = process.argv): boolean { + const index = argv.indexOf(PROFILE_STATE_RECOVERY_FLAG) + if (index === -1) { + return false + } + process.env.ORCA_BACKGROUND_LAUNCH = '1' + applyBackgroundActivationPolicy() + let response: ProfileStateRecoveryResponse + try { + if (!argv.includes('--serve') || argv.lastIndexOf(PROFILE_STATE_RECOVERY_FLAG) !== index) { + throw new ProfileStateRecoveryCommandError( + 'invalid_argument', + 'Invalid profile-state recovery launch.' + ) + } + const raw: unknown = JSON.parse(argv[index + 1] ?? '') + const parsed = profileStateRecoveryRequestSchema.safeParse(raw) + if (!parsed.success || !isAbsolute(parsed.data.userDataPath)) { + throw new ProfileStateRecoveryCommandError( + 'invalid_argument', + 'Invalid profile-state recovery request.' + ) + } + const userDataPath = realpathSync(parsed.data.userDataPath) + app.setPath('userData', userDataPath) + process.env.ORCA_USER_DATA_PATH = userDataPath + const maintenance = acquireProfileStateMaintenance(userDataPath) + try { + // Force Electron's lock even when ordinary dev or diagnostic launches would bypass it. + if (!acquireSingleInstanceLock(app, () => {})) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + 'Stop Orca before profile-state rollback so no process can write the SQLite database.' + ) + } + response = { + ok: true, + result: rollbackProfileState(userDataPath, parsed.data.selector, maintenance) + } + } finally { + maintenance.release() + } + } catch (error) { + response = { + ok: false, + code: isProfileStateRecoveryCommandError(error) ? error.code : 'runtime_error', + message: error instanceof Error ? error.message : String(error) + } + } + let exitCode = response.ok ? 0 : 1 + try { + writeFileSync(1, `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify(response)}\n`) + } catch { + // The CLI may have exited while recovery held the locks; never open an Electron error dialog. + exitCode = 1 + } + app.exit(exitCode) + return true +} diff --git a/src/main/updater.quit-and-install.test.ts b/src/main/updater.quit-and-install.test.ts index 0080db58991e..25c356593ac2 100644 --- a/src/main/updater.quit-and-install.test.ts +++ b/src/main/updater.quit-and-install.test.ts @@ -183,6 +183,84 @@ describe('updater', () => { ) }) + it('aborts native install when required pre-quit cleanup fails', async () => { + vi.useFakeTimers() + + const onBeforeQuit = vi.fn().mockRejectedValue(new Error('profile state export failed')) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await loadUpdaterModule() + + setupAutoUpdater(mainWindow as never, { + onBeforeQuit, + onBeforeQuitFailure: 'abort' + }) + quitAndInstall() + + await vi.advanceTimersByTimeAsync(100) + + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(sendMock).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('Could not restart to install the update') + }) + ) + }) + + it('keeps optional pre-quit cleanup fail-and-continue behavior by default', async () => { + vi.useFakeTimers() + + const onBeforeQuit = vi.fn().mockRejectedValue(new Error('optional cleanup failed')) + const mainWindow = { webContents: { send: vi.fn() } } + const { setupAutoUpdater, quitAndInstall } = await loadUpdaterModule() + + setupAutoUpdater(mainWindow as never, { onBeforeQuit }) + quitAndInstall() + + await vi.advanceTimersByTimeAsync(100) + + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(killAllPtyMock).toHaveBeenCalledTimes(1) + }) + + it('aborts native install when required pre-quit cleanup times out', async () => { + vi.useFakeTimers() + + const onBeforeQuit = vi.fn(() => new Promise(() => {})) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await loadUpdaterModule() + + setupAutoUpdater(mainWindow as never, { + onBeforeQuit, + onBeforeQuitFailure: 'abort' + }) + quitAndInstall() + + await vi.advanceTimersByTimeAsync(100) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(2_500) + + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(sendMock).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('Could not restart to install the update') + }) + ) + }) + it('ignores duplicate quitAndInstall requests while the shared delay is pending', async () => { vi.useFakeTimers() diff --git a/src/main/updater.ts b/src/main/updater.ts index cc9f0fc2c98e..95c457b82c3b 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -12,12 +12,12 @@ import type { import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel' import type { ReleaseBuildListOptions } from './updater-release-build-cache' import { UpdaterSetup, type UpdaterSetupOptions } from './updater/updater-setup' -import type { UpdateInstallMode } from './updater/updater-state' +import type { PreQuitCleanupFailureMode, UpdateInstallMode } from './updater/updater-state' // Keep one service instance so all public API calls share updater state and event listeners. const updater = new UpdaterSetup() -export type { UpdateInstallMode, UpdaterSetupOptions } +export type { PreQuitCleanupFailureMode, UpdateInstallMode, UpdaterSetupOptions } export function resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { return updater.resolveUpdateInstallMode(isServeMode) diff --git a/src/main/updater/updater-install-support.ts b/src/main/updater/updater-install-support.ts index 175362dad057..c493d14eb73e 100644 --- a/src/main/updater/updater-install-support.ts +++ b/src/main/updater/updater-install-support.ts @@ -146,29 +146,44 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState { { errorType: error instanceof Error ? error.name : typeof error }, { level: 'warn', - message: 'Pre-quit cleanup failed; continuing update install' + message: + this.onBeforeQuitFailure === 'abort' + ? 'Pre-quit cleanup failed; aborting update install' + : 'Pre-quit cleanup failed; continuing update install' } ) + if (this.onBeforeQuitFailure === 'abort') { + throw error + } }) const timeoutResult = new Promise<'timeout'>((resolve) => { timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS) }) - const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) - if (result === 'timeout') { - recordUpdaterLifecycle( - 'pre_quit_cleanup_timeout', - { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, - { - level: 'warn', - message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` + try { + const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) + if (result === 'timeout') { + recordUpdaterLifecycle( + 'pre_quit_cleanup_timeout', + { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, + { + level: 'warn', + message: + this.onBeforeQuitFailure === 'abort' + ? `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; aborting update install` + : `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` + } + ) + if (this.onBeforeQuitFailure === 'abort') { + throw new Error( + `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms before update install` + ) } - ) - return - } - - if (timeout) { - clearTimeout(timeout) + } + } finally { + if (timeout) { + clearTimeout(timeout) + } } } diff --git a/src/main/updater/updater-setup.ts b/src/main/updater/updater-setup.ts index 8ebbc49b169c..2b43964d5c0f 100644 --- a/src/main/updater/updater-setup.ts +++ b/src/main/updater/updater-setup.ts @@ -20,7 +20,7 @@ import { getServeUpdateHandoffFailure } from '../serve-update-handoff' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { AUTO_UPDATE_CHECK_INTERVAL_MS } from './updater-state' import { UpdaterDownloadInstall } from './updater-download-install' -import type { UpdateInstallMode } from './updater-state' +import type { PreQuitCleanupFailureMode, UpdateInstallMode } from './updater-state' export type UpdaterSetupOptions = { getLastUpdateCheckAt?: () => number | null @@ -32,6 +32,7 @@ export type UpdaterSetupOptions = { setDismissedUpdateNudgeId?: (id: string | null) => void getReleaseChannelOverride?: () => ReleaseChannel | null installMode?: UpdateInstallMode + onBeforeQuitFailure?: PreQuitCleanupFailureMode } /** Initializes electron-updater and attaches lifecycle/event bridges. */ @@ -113,6 +114,7 @@ export class UpdaterSetup extends UpdaterDownloadInstall { setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void { this.mainWindowRef = mainWindow this.onBeforeQuitCleanup = opts?.onBeforeQuit ?? null + this.onBeforeQuitFailure = opts?.onBeforeQuitFailure ?? 'continue' this.persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null this._getLastUpdateCheckAt = opts?.getLastUpdateCheckAt ?? null this._getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null diff --git a/src/main/updater/updater-state.ts b/src/main/updater/updater-state.ts index d15ce42520c0..2fbe2b6e25f5 100644 --- a/src/main/updater/updater-state.ts +++ b/src/main/updater/updater-state.ts @@ -24,6 +24,7 @@ export type UpdateInstallMode = | 'interactive' | 'supervised-headless-serve' | 'unsupported-headless-serve' +export type PreQuitCleanupFailureMode = 'continue' | 'abort' // Why: expected preflight outcomes need typed context so UI routing never depends on matching error text. export class ReleaseFeedPreflightError extends Error { @@ -42,6 +43,7 @@ export abstract class UpdaterState { protected currentStatus: UpdateStatus = { state: 'idle' } protected userInitiatedCheck = false protected onBeforeQuitCleanup: (() => void | Promise) | null = null + protected onBeforeQuitFailure: PreQuitCleanupFailureMode = 'continue' protected autoUpdaterInitialized = false // Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped. protected includePrereleaseActive = false diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index ae83f5b77124..9e5c5493f095 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' const { onMock, @@ -154,7 +155,7 @@ type MainWindowStub = { type RuntimeStub = { attachWindow: MockFn - setNotifier: MockFn + setNotifier: ReturnType void>> markRendererReloading: MockFn markRendererReloadCancelled: MockFn markGraphReloadFailed: MockFn @@ -195,7 +196,7 @@ function createStore(): Store & { flushPendingAsync: MockFn } { function createRuntime(): RuntimeStub { return { attachWindow: vi.fn(), - setNotifier: vi.fn(), + setNotifier: vi.fn<(notifier: RuntimeNotifier | null) => void>(), markRendererReloading: vi.fn(), markRendererReloadCancelled: vi.fn(), markGraphReloadFailed: vi.fn(), @@ -290,8 +291,7 @@ describe('attachMainWindowServices', () => { await providerStartup.promise await Promise.resolve() - expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledOnce() - expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledWith(store) + expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledExactlyOnceWith(store) }) it('passes injected update quit cleanup to the auto-updater', async () => { @@ -305,17 +305,23 @@ describe('attachMainWindowServices', () => { createRuntime() as never, undefined, undefined, - { onBeforeUpdateQuit, updateInstallMode: 'supervised-headless-serve' } + { + onBeforeUpdateQuit, + onBeforeUpdateQuitFailure: 'abort', + updateInstallMode: 'supervised-headless-serve' + } ) // Deferred to first paint — must not be configured at attach time. expect(setupAutoUpdaterMock).not.toHaveBeenCalled() await fireReadyToShow(mainWindow) expect(setupAutoUpdaterMock).toHaveBeenCalledTimes(1) - expect(setupAutoUpdaterMock).toHaveBeenCalledWith( - mainWindow, - expect.objectContaining({ installMode: 'supervised-headless-serve' }) - ) + const [updaterWindow, updaterOptions] = setupAutoUpdaterMock.mock.calls[0] + expect(updaterWindow).toBe(mainWindow) + expect(updaterOptions).toMatchObject({ + installMode: 'supervised-headless-serve', + onBeforeQuitFailure: 'abort' + }) await setupAutoUpdaterMock.mock.calls[0][1].onBeforeQuit() expect(onBeforeUpdateQuit).toHaveBeenCalledTimes(1) @@ -758,14 +764,9 @@ describe('attachMainWindowServices', () => { attachMainWindowServices(mainWindow as never, createStore(), runtime as never) expect(runtime.setNotifier).toHaveBeenCalledTimes(1) - const notifier = runtime.setNotifier.mock.calls[0][0] as { - worktreesChanged: (repoId: string) => void - reposChanged: () => void - activateWorktree: ( - repoId: string, - worktreeId: string, - setup?: { runnerScriptPath: string; envVars: Record } - ) => void + const notifier = runtime.setNotifier.mock.calls[0][0] + if (!notifier) { + throw new Error('Missing runtime notifier') } notifier.worktreesChanged('repo-1') diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 78496abc1fc9..5a7aab6b145e 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -24,7 +24,7 @@ import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' import { browserManager } from '../browser/browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access' import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' -import type { UpdateInstallMode } from '../updater' +import type { PreQuitCleanupFailureMode, UpdateInstallMode } from '../updater' import { scheduleHistoryGc } from '../terminal-history-gc' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -64,6 +64,7 @@ export function attachMainWindowServices( onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void onPtyExit?: (id: string, exitSequence: number) => void onBeforeUpdateQuit?: () => void | Promise + onBeforeUpdateQuitFailure?: PreQuitCleanupFailureMode updateInstallMode?: UpdateInstallMode onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } diff --git a/src/main/window/history-gc-profile-worktree-ids.test.ts b/src/main/window/history-gc-profile-worktree-ids.test.ts index b3f0960eab98..3c01d0f2ea23 100644 --- a/src/main/window/history-gc-profile-worktree-ids.test.ts +++ b/src/main/window/history-gc-profile-worktree-ids.test.ts @@ -3,11 +3,14 @@ * segment, while the Store the GC consults holds one profile's ids. Without * these, switching profiles makes every other profile's history look orphaned. */ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { folderWorkspaceKey } from '../../shared/workspace-scope' +import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { getOrcaProfileStateDatabaseFile } from '../orca-profiles/profile-storage-paths' import { getOtherProfileWorktreeIdsForHistoryGc } from './history-gc-profile-worktree-ids' const roots: string[] = [] @@ -103,6 +106,153 @@ describe('getOtherProfileWorktreeIdsForHistoryGc', () => { expect(getOtherProfileWorktreeIdsForHistoryGc(root).unreadableProfiles).toBe(1) }) + it('prefers the profile database over a stale JSON export', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { + id: 'other', + state: { worktreeMeta: { 'repo::/json': {} }, folderWorkspaces: [] } + } + ]) + const database = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile('other', root), + 'other' + ) + try { + importProfileStateJson( + database.db, + JSON.stringify({ + worktreeMeta: { 'repo::/database': {} }, + folderWorkspaces: [{ id: 'folder-database' }] + }) + ) + } finally { + database.db.close() + } + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 0, + ids: new Set(['repo::/database', folderWorkspaceKey('folder-database')]) + }) + }) + + it('falls back to JSON without creating a database', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { worktreeMeta: { 'repo::/json': {} } } } + ]) + const databaseFile = getOrcaProfileStateDatabaseFile('other', root) + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 0, + ids: new Set(['repo::/json']) + }) + expect(existsSync(databaseFile)).toBe(false) + }) + + it.each([true, false])( + 'refuses history pruning when SQLite is missing but a retained export exists (JSON: %s)', + (hasJson) => { + const state = { worktreeMeta: { 'repo::/stale-json': {} } } + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', ...(hasJson ? { state } : {}) } + ]) + const dataFile = join(root, 'profiles', 'other', 'orca-data.json') + const exportPath = `${dataFile}.sqlite-export.1.json` + const exportJson = JSON.stringify({ worktreeMeta: { 'repo::/export': {} } }) + writeFileSync(exportPath, exportJson) + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 1, + ids: new Set() + }) + expect(existsSync(getOrcaProfileStateDatabaseFile('other', root))).toBe(false) + expect(existsSync(dataFile)).toBe(hasJson) + if (hasJson) { + expect(readFileSync(dataFile, 'utf8')).toBe(JSON.stringify(state)) + } + expect(readFileSync(exportPath, 'utf8')).toBe(exportJson) + } + ) + + it('reads SQLite-only profiles with retained exports without blocking history pruning', () => { + const root = userDataWithProfiles('active', [{ id: 'active', state: {} }, { id: 'other' }]) + const database = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile('other', root), + 'other' + ) + try { + importProfileStateJson( + database.db, + JSON.stringify({ worktreeMeta: { 'repo::/database': {} } }) + ) + } finally { + database.db.close() + } + const dataFile = join(root, 'profiles', 'other', 'orca-data.json') + writeFileSync( + `${dataFile}.sqlite-export.1.json`, + JSON.stringify({ worktreeMeta: { 'repo::/stale-export': {} } }) + ) + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 0, + ids: new Set(['repo::/database']) + }) + expect(existsSync(dataFile)).toBe(false) + }) + + it('does not fall back to JSON when an existing database is corrupt', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { worktreeMeta: { 'repo::/json': {} } } } + ]) + writeFileSync(getOrcaProfileStateDatabaseFile('other', root), 'not a sqlite database') + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 1, + ids: new Set() + }) + }) + + it.each(['-wal', '-shm', '-journal'])( + 'does not fall back to JSON when only %s remains', + (suffix) => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { worktreeMeta: { 'repo::/json': {} } } } + ]) + writeFileSync( + `${getOrcaProfileStateDatabaseFile('other', root)}${suffix}`, + 'orphaned evidence' + ) + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 1, + ids: new Set() + }) + } + ) + + it('refuses history pruning for a future profile database schema', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { worktreeMeta: { 'repo::/json': {} } } } + ]) + const database = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile('other', root), + 'other' + ) + database.db.pragma('user_version = 99') + database.db.close() + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 1, + ids: new Set() + }) + }) + // A single-profile install must not pay for this, and no index at all is the // pre-profiles layout rather than an error. it('is empty and complete when there is no profile index', () => { diff --git a/src/main/window/history-gc-profile-worktree-ids.ts b/src/main/window/history-gc-profile-worktree-ids.ts index 6f82b96fc376..07a115b0ad86 100644 --- a/src/main/window/history-gc-profile-worktree-ids.ts +++ b/src/main/window/history-gc-profile-worktree-ids.ts @@ -1,10 +1,13 @@ -import { readFileSync } from 'node:fs' +import { lstatSync, readFileSync } from 'node:fs' import { folderWorkspaceKey } from '../../shared/workspace-scope' import { getOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile, getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { getOrcaProfileIndexPath, readProfileIndex } from '../orca-profiles/profile-index-store' +import { readProfileStateDomains } from '../persistence/profile-state/profile-state-domain-reader' +import { assertNoRetainedProfileStateExports } from '../persistence/profile-state/profile-state-recovery-required' /** * Worktree ids owned by Orca profiles OTHER than the running one. @@ -16,7 +19,7 @@ import { getOrcaProfileIndexPath, readProfileIndex } from '../orca-profiles/prof * switch every other profile's history looks orphaned, and the GC deletes shell * history those profiles are still using. * - * Reading their data files directly is deliberate: a Store per profile would + * Reading their persisted state directly is deliberate: a Store per profile would * run migrations and normalization against state another profile owns. Only the * two id-bearing collections are read, and any unreadable profile is skipped — * a profile whose ids cannot be established must widen the live set's @@ -37,7 +40,7 @@ export function getOtherProfileWorktreeIdsForHistoryGc(userDataPath = getProfile if (profile.id === index.activeProfileId) { continue } - const collected = readProfileWorktreeIds(getOrcaProfileDataFile(profile.id, userDataPath)) + const collected = readProfileWorktreeIds(profile.id, userDataPath) if (!collected) { unreadableProfiles += 1 continue @@ -49,7 +52,99 @@ export function getOtherProfileWorktreeIdsForHistoryGc(userDataPath = getProfile return { ids, unreadableProfiles } } -function readProfileWorktreeIds(dataFile: string): Set | null { +function readProfileWorktreeIds(profileId: string, userDataPath: string): Set | null { + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + // A present database is authoritative. In particular, do not fall back to a + // stale JSON export after corruption or a future schema, because that could + // make live history look orphaned and delete it. + const databasePresence = profileStateDatabasePresence(databaseFile) + if (databasePresence === 'present') { + return readProfileWorktreeIdsFromDatabase(databaseFile, profileId) + } + if (databasePresence === 'unreadable') { + return null + } + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + try { + assertNoRetainedProfileStateExports({ dataFile, databaseFile, profileId }) + } catch { + return null + } + return readProfileWorktreeIdsFromJson(dataFile) +} + +function profileStateDatabasePresence(path: string): 'absent' | 'present' | 'unreadable' { + let mainDatabasePresent = false + try { + lstatSync(path) + mainDatabasePresent = true + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + mainDatabasePresent = false + } else { + return 'unreadable' + } + } + if (mainDatabasePresent) { + return 'present' + } + for (const sidecar of [`${path}-wal`, `${path}-shm`, `${path}-journal`]) { + try { + lstatSync(sidecar) + return 'unreadable' + } catch (error) { + if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT') { + return 'unreadable' + } + } + } + return 'absent' +} + +function readProfileWorktreeIdsFromDatabase( + databaseFile: string, + profileId: string +): Set | null { + const domains = readProfileStateDomains(databaseFile, profileId, [ + 'worktreeMeta', + 'folderWorkspaces' + ]) + if (domains.kind === 'unreadable') { + return null + } + + const ids = new Set() + const worktreeMeta = domains.values.get('worktreeMeta') + if (worktreeMeta !== undefined) { + if (worktreeMeta === null) { + // Explicit null is a valid legacy state value and means no metadata. + } else if (!isRecord(worktreeMeta)) { + return null + } else { + for (const id of Object.keys(worktreeMeta)) { + ids.add(id) + } + } + } + const folderWorkspaces = domains.values.get('folderWorkspaces') + if (folderWorkspaces !== undefined) { + if (folderWorkspaces === null) { + // Explicit null is a valid legacy state value and means no workspaces. + } else if (!Array.isArray(folderWorkspaces)) { + return null + } else { + for (const workspace of folderWorkspaces) { + const id = isRecord(workspace) ? workspace.id : undefined + if (typeof id === 'string' && id) { + ids.add(folderWorkspaceKey(id)) + } + } + } + } + return ids +} + +function readProfileWorktreeIdsFromJson(dataFile: string): Set | null { let parsed: unknown try { parsed = JSON.parse(readFileSync(dataFile, 'utf8')) @@ -78,3 +173,7 @@ function readProfileWorktreeIds(dataFile: string): Set | null { } return ids } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/window/main-window-updater.ts b/src/main/window/main-window-updater.ts index f0298ed115d8..7cdcdd8cd732 100644 --- a/src/main/window/main-window-updater.ts +++ b/src/main/window/main-window-updater.ts @@ -16,6 +16,7 @@ import { quitAndInstall, setupAutoUpdater, showLinuxPackage, + type PreQuitCleanupFailureMode, type UpdateInstallMode } from '../updater' @@ -33,6 +34,7 @@ export function scheduleMainWindowAutoUpdaterSetup( store: Store, options?: { onBeforeUpdateQuit?: () => void | Promise + onBeforeUpdateQuitFailure?: PreQuitCleanupFailureMode updateInstallMode?: UpdateInstallMode } ): void { @@ -69,6 +71,7 @@ export function scheduleMainWindowAutoUpdaterSetup( store.updateUI({ dismissedUpdateNudgeId: id }) }, getReleaseChannelOverride: () => store.getUI().releaseChannelOverride ?? null, + onBeforeQuitFailure: options?.onBeforeUpdateQuitFailure, installMode: options?.updateInstallMode }) logStartupMilestone('updater-setup-done') diff --git a/src/main/worker-thread-entry-path.ts b/src/main/worker-thread-entry-path.ts index 9c9de40eb801..265c3048b3ad 100644 --- a/src/main/worker-thread-entry-path.ts +++ b/src/main/worker-thread-entry-path.ts @@ -46,7 +46,10 @@ export function resolveWorkerThreadEntryPath( export function currentWorkerEntryLayout(moduleDir: string): WorkerEntryLayout { return { isPackaged: hasAppEnvironment() && getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, + resourcesPath: + 'resourcesPath' in process && typeof process.resourcesPath === 'string' + ? process.resourcesPath + : undefined, moduleDir } } diff --git a/src/shared/profile-state-recovery-command.ts b/src/shared/profile-state-recovery-command.ts new file mode 100644 index 000000000000..a7733017567b --- /dev/null +++ b/src/shared/profile-state-recovery-command.ts @@ -0,0 +1,70 @@ +import { z } from 'zod' + +export const PROFILE_STATE_RECOVERY_FLAG = '--profile-state-recovery' +export const PROFILE_STATE_RECOVERY_RESULT_PREFIX = '[profile-state-recovery] ' + +const positiveInteger = z.number().int().positive().max(Number.MAX_SAFE_INTEGER) +const selectorSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('json'), revision: positiveInteger }).strict(), + z.object({ kind: z.literal('sqlite'), backupId: z.string().min(1) }).strict() +]) + +export const profileStateRecoveryRequestSchema = z + .object({ + userDataPath: z.string().min(1), + selector: selectorSchema + }) + .strict() + +const exportsSchema = z.object({ + profileId: z.string(), + dataFile: z.string(), + databaseFile: z.string(), + exportPaths: z.array(z.string()).readonly(), + backups: z + .array(z.object({ id: z.string(), path: z.string(), createdAtMs: positiveInteger })) + .readonly() +}) +const rollbackSchema = exportsSchema.extend({ + revision: positiveInteger, + quarantineDirectory: z.string(), + removedDatabaseFiles: z.array(z.string()).readonly(), + storage: z.enum(['json', 'sqlite']), + restoredPath: z.string(), + backupId: z.string().optional() +}) + +export const profileStateRecoveryResponseSchema = z.discriminatedUnion('ok', [ + z.object({ ok: z.literal(true), result: rollbackSchema }), + z.object({ + ok: z.literal(false), + code: z.enum(['invalid_argument', 'runtime_error']), + message: z.string() + }) +]) + +export type ProfileStateRecoverySelector = z.infer +export type ProfileStateRecoveryRequest = z.infer +export type ProfileStateRecoveryResponse = z.infer +export type ProfileStateExportsResult = z.infer +export type ProfileStateRollbackResult = z.infer + +export class ProfileStateRecoveryCommandError extends Error { + constructor( + readonly code: 'invalid_argument' | 'runtime_error', + message: string + ) { + super(message) + this.name = 'ProfileStateRecoveryCommandError' + } +} + +export function isProfileStateRecoveryCommandError( + error: unknown +): error is Error & { code: 'invalid_argument' | 'runtime_error' } { + return ( + error instanceof Error && + 'code' in error && + (error.code === 'invalid_argument' || error.code === 'runtime_error') + ) +} diff --git a/src/shared/profile-state-storage-paths.ts b/src/shared/profile-state-storage-paths.ts new file mode 100644 index 000000000000..0dea3dbb0a51 --- /dev/null +++ b/src/shared/profile-state-storage-paths.ts @@ -0,0 +1,16 @@ +import { join } from 'node:path' + +export const PROFILE_STATE_DATABASE_FILE_NAME = 'profile-state.db' + +/** Pure profile-state path helpers shared by the offline CLI and main process. */ +export function profileStateDatabaseFile(profileDirectory: string): string { + return join(profileDirectory, PROFILE_STATE_DATABASE_FILE_NAME) +} + +export function getOrcaProfileDataFile(profileId: string, userDataPath: string): string { + return join(userDataPath, 'profiles', profileId, 'orca-data.json') +} + +export function getOrcaProfileStateDatabaseFile(profileId: string, userDataPath: string): string { + return profileStateDatabaseFile(join(userDataPath, 'profiles', profileId)) +} diff --git a/src/shared/profile-state-telemetry-schema.test.ts b/src/shared/profile-state-telemetry-schema.test.ts new file mode 100644 index 000000000000..63c9dd88ab01 --- /dev/null +++ b/src/shared/profile-state-telemetry-schema.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { eventSchemas } from './telemetry-events' + +describe('profile state authority telemetry', () => { + it('accepts the bounded startup selection payload', () => { + expect( + eventSchemas.profile_state_authority_selected.safeParse({ + backend: 'sqlite', + classification: 'json-only', + authority_mode: 'sqlite-candidate', + runtime: 'desktop', + migrated: true + }).success + ).toBe(true) + }) + + it('rejects paths or other unbounded diagnostic fields', () => { + expect( + eventSchemas.profile_state_authority_selected.safeParse({ + backend: 'sqlite', + classification: 'sqlite-only', + authority_mode: 'sqlite-established', + runtime: 'desktop', + migrated: false, + database_path: '/private/profile.sqlite' + }).success + ).toBe(false) + }) +}) diff --git a/src/shared/telemetry-daemon-event-schemas.ts b/src/shared/telemetry-daemon-event-schemas.ts index 26762e5a52e1..9632e75bd05b 100644 --- a/src/shared/telemetry-daemon-event-schemas.ts +++ b/src/shared/telemetry-daemon-event-schemas.ts @@ -201,6 +201,19 @@ export const settingsChangedSchema = z }) .strict() +// Why: profile-state cutover needs fleet-level evidence that authority selection and migration +// agree with the rollout plan. Keep this enum-only: paths, profile IDs, and serialized state never +// belong in telemetry. +export const profileStateAuthoritySelectedSchema = z + .object({ + backend: z.enum(['json', 'sqlite']), + classification: z.enum(['neither', 'json-only', 'sqlite-only', 'both']), + authority_mode: z.enum(['legacy', 'sqlite-candidate', 'sqlite-established']), + runtime: z.enum(['desktop', 'orcad']), + migrated: z.boolean() + }) + .strict() + // Managed-hook installer label from `AGENT_HOOK_TARGETS`, distinct from `AGENT_KIND_VALUES`; `claude` (not `claude-code`) is intentional. export const hookInstallAgentSchema = z.enum(AGENT_HOOK_TARGETS) export type HookInstallAgent = z.infer diff --git a/src/shared/telemetry-event-registry.ts b/src/shared/telemetry-event-registry.ts index 810ba8b35d13..cba4ad9a2088 100644 --- a/src/shared/telemetry-event-registry.ts +++ b/src/shared/telemetry-event-registry.ts @@ -21,6 +21,7 @@ import { daemonPtyCwdVerdictSchema, daemonStartFailedSchema, mainThreadHangDetectedSchema, + profileStateAuthoritySelectedSchema, remoteOutboundBudgetCloseSchema, runtimeRpcStartFailedSchema, settingsChangedSchema @@ -132,6 +133,7 @@ export const eventSchemas = { daemon_audit_eligibility: daemonAuditEligibilitySchema, runtime_rpc_start_failed: runtimeRpcStartFailedSchema, remote_outbound_budget_close: remoteOutboundBudgetCloseSchema, + profile_state_authority_selected: profileStateAuthoritySelectedSchema, codex_trust_grant: codexTrustGrantSchema, diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index c2cf9036dc0b..d1bec760ecd2 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -49,6 +49,7 @@ export { } from './telemetry-app-event-schemas' export { hookInstallAgentSchema, + profileStateAuthoritySelectedSchema, runtimeRpcStartErrorClassSchema } from './telemetry-daemon-event-schemas' export type { HookInstallAgent, RuntimeRpcStartErrorClass } from './telemetry-daemon-event-schemas' diff --git a/src/shared/uuid-v4.test.ts b/src/shared/uuid-v4.test.ts new file mode 100644 index 000000000000..a12396acccac --- /dev/null +++ b/src/shared/uuid-v4.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createUuidV4 } from './uuid-v4' + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +describe('createUuidV4', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('uses the browser crypto fallback when randomUUID is unavailable', () => { + vi.stubGlobal('crypto', { + getRandomValues: (bytes: Uint8Array) => { + bytes.fill(0xab) + return bytes + } + }) + + expect(createUuidV4()).toMatch(UUID_V4) + }) + + it('still returns a UUID when Web Crypto is unavailable', () => { + vi.stubGlobal('crypto', undefined) + + expect(createUuidV4()).toMatch(UUID_V4) + }) +}) diff --git a/src/shared/uuid-v4.ts b/src/shared/uuid-v4.ts new file mode 100644 index 000000000000..2bda84ec25eb --- /dev/null +++ b/src/shared/uuid-v4.ts @@ -0,0 +1 @@ +export { createNonSecureContextUuid as createUuidV4 } from './non-secure-context-uuid' diff --git a/tests/e2e/agent-session-live-force-exit-resume.spec.ts b/tests/e2e/agent-session-live-force-exit-resume.spec.ts index 18e984090948..b6609c29df71 100644 --- a/tests/e2e/agent-session-live-force-exit-resume.spec.ts +++ b/tests/e2e/agent-session-live-force-exit-resume.spec.ts @@ -1,5 +1,9 @@ +import { + readPersistedProfileState, + mutateStoppedProfileState +} from './helpers/persisted-profile-state' import { execFileSync } from 'node:child_process' -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import type { ChildProcess } from 'node:child_process' import type { ElectronApplication } from '@stablyai/playwright-test' @@ -16,7 +20,6 @@ import { import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { PROTOCOL_VERSION } from '../../src/main/daemon/types' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const PROVIDER_SESSION_ID = 'e2e-live-force-exit-session' @@ -41,17 +44,9 @@ type PersistedData = { workspaceSession?: PersistedWorkspaceSession } -function dataFilePath(userDataDir: string): string { - // Fresh sessions migrate the seeded legacy file, then persist only here. - return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') -} - function readPersistedData(userDataDir: string): PersistedData { - return JSON.parse(readFileSync(dataFilePath(userDataDir), 'utf8')) as PersistedData -} - -function writePersistedData(userDataDir: string, data: PersistedData): void { - writeFileSync(dataFilePath(userDataDir), `${JSON.stringify(data, null, 2)}\n`, 'utf8') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + return readPersistedProfileState(userDataDir) as PersistedData } function daemonPidPath(userDataDir: string): string { @@ -115,29 +110,31 @@ function killPid(pid: number): void { } function stripPersistedPtyOwnership(userDataDir: string): void { - const data = readPersistedData(userDataDir) - const session = data.workspaceSession - if (!session) { - throw new Error('Expected persisted workspace session') - } - for (const tabs of Object.values(session.tabsByWorktree ?? {})) { - for (const tab of tabs) { - tab.ptyId = null + return mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = state as PersistedData + const session = data.workspaceSession + if (!session) { + throw new Error('Expected persisted workspace session') } - } - // Why: this models the updater/crash artifact from #6370: the UI tab and - // live resume record survive, but no pane has the old stable leaf key or - // daemon session to own resume. - session.terminalLayoutsByTabId = {} - session.activeWorktreeIdsOnShutdown = [] - for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { - if (record.providerSession?.id === PROVIDER_SESSION_ID) { - // Why: the e2e proof should verify Orca launches the resumed command, - // not depend on a developer machine having a real Codex CLI installed. - record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + for (const tabs of Object.values(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + tab.ptyId = null + } } - } - writePersistedData(userDataDir, data) + // Why: this models the updater/crash artifact from #6370: the UI tab and + // live resume record survive, but no pane has the old stable leaf key or + // daemon session to own resume. + session.terminalLayoutsByTabId = {} + session.activeWorktreeIdsOnShutdown = [] + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + if (record.providerSession?.id === PROVIDER_SESSION_ID) { + // Why: the e2e proof should verify Orca launches the resumed command, + // not depend on a developer machine having a real Codex CLI installed. + record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + } + } + }) } function persistedLiveRecordExists(userDataDir: string): boolean { diff --git a/tests/e2e/agent-session-quit-resume.spec.ts b/tests/e2e/agent-session-quit-resume.spec.ts index 17c68f01ca78..b4a8573904fa 100644 --- a/tests/e2e/agent-session-quit-resume.spec.ts +++ b/tests/e2e/agent-session-quit-resume.spec.ts @@ -1,4 +1,5 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { mutateStoppedProfileState } from './helpers/persisted-profile-state' +import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import type { ElectronApplication } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' @@ -14,40 +15,35 @@ import { import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { PROTOCOL_VERSION } from '../../src/main/daemon/types' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const PROVIDER_SESSION_ID = 'e2e-quit-resume-session' function stubPersistedResumeCommand(userDataDir: string): void { - const dataPath = path.join( - userDataDir, - 'profiles', - DEFAULT_LOCAL_ORCA_PROFILE_ID, - 'orca-data.json' - ) - const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { - workspaceSession?: { - sleepingAgentSessionsByPaneKey?: Record< - string, - { - providerSession?: { id?: unknown } - launchConfig?: { - agentCommand?: string - agentArgs?: string - agentEnv?: Record + return mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = state as { + workspaceSession?: { + sleepingAgentSessionsByPaneKey?: Record< + string, + { + providerSession?: { id?: unknown } + launchConfig?: { + agentCommand?: string + agentArgs?: string + agentEnv?: Record + } } - } - > + > + } } - } - const record = Object.values(data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {}).find( - (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID - ) - if (!record) { - throw new Error('Expected a persisted resumable agent session') - } - record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } - writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8') + const record = Object.values(data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {}).find( + (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID + ) + if (!record) { + throw new Error('Expected a persisted resumable agent session') + } + record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + }) } function readDaemonPid(userDataDir: string): number { diff --git a/tests/e2e/finished-agent-ghost-resume.spec.ts b/tests/e2e/finished-agent-ghost-resume.spec.ts index 5d135161f086..6022edbf2016 100644 --- a/tests/e2e/finished-agent-ghost-resume.spec.ts +++ b/tests/e2e/finished-agent-ghost-resume.spec.ts @@ -1,3 +1,7 @@ +import { + readPersistedProfileState, + mutateStoppedProfileState +} from './helpers/persisted-profile-state' /** * A LOCAL agent that FINISHED its turn must not be respawned when the app * reopens the workspace. @@ -15,8 +19,7 @@ * pnpm exec playwright test tests/e2e/finished-agent-ghost-resume.spec.ts \ * --config tests/playwright.config.ts --project electron-headless --workers=1 */ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' +import { existsSync, readFileSync } from 'node:fs' import type { ElectronApplication } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { TEST_REPO_PATH_FILE } from './global-setup' @@ -31,7 +34,6 @@ import { import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { createHostRendererTerminalTab } from './helpers/host-created-terminal-retention-oracle' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const PROVIDER_SESSION_ID = 'e2e-finished-agent-session' @@ -43,13 +45,8 @@ type PersistedRecord = { } function readPersistedRecords(userDataDir: string): Record { - const dataPath = path.join( - userDataDir, - 'profiles', - DEFAULT_LOCAL_ORCA_PROFILE_ID, - 'orca-data.json' - ) - const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = readPersistedProfileState(userDataDir) as { workspaceSession?: { sleepingAgentSessionsByPaneKey?: Record } } return data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {} @@ -57,24 +54,20 @@ function readPersistedRecords(userDataDir: string): Record } - } - const record = Object.values(data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {}).find( - (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID - ) - if (!record) { - throw new Error('Expected the finished agent turn to leave a persisted record') - } - record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } - writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8') - return record + return mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = state as { + workspaceSession?: { sleepingAgentSessionsByPaneKey?: Record } + } + const record = Object.values(data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {}).find( + (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID + ) + if (!record) { + throw new Error('Expected the finished agent turn to leave a persisted record') + } + record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + return record + }) } test.describe.configure({ mode: 'serial' }) diff --git a/tests/e2e/headless-serve-desktop-activation.spec.ts b/tests/e2e/headless-serve-desktop-activation.spec.ts index 1d4b4a0f82c3..673f548d18c8 100644 --- a/tests/e2e/headless-serve-desktop-activation.spec.ts +++ b/tests/e2e/headless-serve-desktop-activation.spec.ts @@ -1,3 +1,4 @@ +import { readPersistedProfileState } from './helpers/persisted-profile-state' import { spawn, type ChildProcess } from 'node:child_process' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' @@ -31,7 +32,6 @@ import type { } from '../../src/shared/runtime-types' import { PROTOCOL_VERSION } from '../../src/main/daemon/types' import { parsePaneKey } from '../../src/shared/stable-pane-id' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const electronPackageDir = path.join(process.cwd(), 'node_modules', 'electron') const electronPath = path.join( @@ -76,12 +76,8 @@ function readPersistedPromotionBinding( leafId: string ): { tabId: string; leafId: string; ptyId: string } | null { try { - const persisted = JSON.parse( - readFileSync( - path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json'), - 'utf8' - ) - ) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const persisted = readPersistedProfileState(userDataDir) as { workspaceSession?: { tabsByWorktree?: Record terminalLayoutsByTabId?: Record }> diff --git a/tests/e2e/helpers/completed-worker-retirement-fixture.ts b/tests/e2e/helpers/completed-worker-retirement-fixture.ts index d3ef3fb8195a..5ac0d8c1948e 100644 --- a/tests/e2e/helpers/completed-worker-retirement-fixture.ts +++ b/tests/e2e/helpers/completed-worker-retirement-fixture.ts @@ -1,3 +1,4 @@ +import { readPersistedProfileState } from './persisted-profile-state' import { execFileSync } from 'node:child_process' import { chmodSync, @@ -11,7 +12,6 @@ import { import os from 'node:os' import path from 'node:path' import type { RuntimeClient } from '../../../src/cli/runtime-client' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../src/shared/orca-profiles' import type { RuntimeTerminalListResult, RuntimeTerminalSummary @@ -194,16 +194,8 @@ export async function listRuntimeTerminals( } export function readPersistedWorkerRecoveryRecord(userDataDir: string, paneKey: string) { - const dataPath = path.join( - userDataDir, - 'profiles', - DEFAULT_LOCAL_ORCA_PROFILE_ID, - 'orca-data.json' - ) - if (!existsSync(dataPath)) { - return null - } - const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = readPersistedProfileState(userDataDir) as { workspaceSession?: { sleepingAgentSessionsByPaneKey?: Record< string, diff --git a/tests/e2e/helpers/electron-launch-args.ts b/tests/e2e/helpers/electron-launch-args.ts index 6868f48b083e..e749945eddb4 100644 --- a/tests/e2e/helpers/electron-launch-args.ts +++ b/tests/e2e/helpers/electron-launch-args.ts @@ -1,12 +1,14 @@ import { dirname } from 'node:path' +export function getElectronIsolatedKeychainArgs(): string[] { + // Isolated macOS profiles must not invoke the system keychain UI. + return process.platform === 'darwin' ? ['--password-store=basic', '--use-mock-keychain'] : [] +} + export function getOrcaElectronLaunchArgs(mainPath: string, headful: boolean): string[] { // Launch through package.json so app version and resource paths match a packaged app. const appPath = dirname(dirname(dirname(mainPath))) - // Isolated macOS profiles must not invoke the system keychain UI. Without - // these Chromium switches startup can block before the first renderer target. - const keychainArgs = - process.platform === 'darwin' ? ['--password-store=basic', '--use-mock-keychain'] : [] + const keychainArgs = getElectronIsolatedKeychainArgs() if (process.platform === 'darwin') { // Crash tests must not block later launches on AppKit's saved-window recovery dialog. return [...keychainArgs, appPath, '-ApplePersistenceIgnoreState', 'YES'] diff --git a/tests/e2e/helpers/electron-launch-args.unit.test.ts b/tests/e2e/helpers/electron-launch-args.unit.test.ts index c538d6f9a850..301548a9f4e7 100644 --- a/tests/e2e/helpers/electron-launch-args.unit.test.ts +++ b/tests/e2e/helpers/electron-launch-args.unit.test.ts @@ -1,10 +1,24 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getOrcaElectronLaunchArgs } from './electron-launch-args' +import { getElectronIsolatedKeychainArgs, getOrcaElectronLaunchArgs } from './electron-launch-args' describe('getOrcaElectronLaunchArgs', () => { afterEach(() => vi.unstubAllGlobals()) + it.each(['darwin', 'linux', 'win32'])( + 'isolates packaged and source test keychains on %s', + (platform) => { + vi.stubGlobal('process', { ...process, platform }) + const args = getElectronIsolatedKeychainArgs() + expect(args).toEqual( + platform === 'darwin' ? ['--password-store=basic', '--use-mock-keychain'] : [] + ) + expect(getOrcaElectronLaunchArgs(join('orca', 'out', 'main', 'index.js'), false)).toEqual( + expect.arrayContaining(args) + ) + } + ) + it.each([ ['linux', 'true', true, true], ['linux', undefined, true, false], diff --git a/tests/e2e/helpers/electron-process-shutdown.ts b/tests/e2e/helpers/electron-process-shutdown.ts index f9b642a676e9..5d78b761b966 100644 --- a/tests/e2e/helpers/electron-process-shutdown.ts +++ b/tests/e2e/helpers/electron-process-shutdown.ts @@ -157,11 +157,20 @@ async function forceKillProcessTree(proc: ChildProcess): Promise { * Use `closeElectronAppForE2E` for an ordinary quit — this exists for specs that need a client to * vanish without unwinding its sockets or subscriptions. */ -export async function forceQuitElectronAppForE2E(app: ElectronApplication): Promise { +export async function forceQuitElectronAppForE2E( + app: ElectronApplication, + options: { preserveDaemons?: boolean } = {} +): Promise { const proc = app.process() const pid = proc.pid if (pid) { - if (process.platform === 'win32') { + if (options.preserveDaemons) { + // Chromium helpers hold Windows profile handles; Electron's list excludes detached daemons. + const appPids = await app.evaluate(({ app }) => app.getAppMetrics().map(({ pid }) => pid)) + for (const targetPid of new Set([pid, ...appPids])) { + killPid(targetPid, 'SIGKILL') + } + } else if (process.platform === 'win32') { try { execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' @@ -178,8 +187,12 @@ export async function forceQuitElectronAppForE2E(app: ElectronApplication): Prom } await waitForExit(proc, PROCESS_EXIT_TIMEOUT_MS) releaseExitedProcessPipes(proc) - // Hands the dead app back to Playwright so worker teardown has nothing left to wait on. - await app.close().catch(() => undefined) + // Playwright close can remain pending after an external force-kill. + await withTimeout( + app.close(), + PROCESS_EXIT_TIMEOUT_MS, + 'Timed out releasing killed Electron app' + ).catch(() => undefined) } export async function closeElectronAppForE2E(app: ElectronApplication): Promise { diff --git a/tests/e2e/helpers/orca-restart.ts b/tests/e2e/helpers/orca-restart.ts index 2560fde154d8..6ca5e7873024 100644 --- a/tests/e2e/helpers/orca-restart.ts +++ b/tests/e2e/helpers/orca-restart.ts @@ -190,17 +190,21 @@ export function createRestartSession( } try { const resolvedHome = await retryTransientMainEvaluate(() => - app.evaluate(({ app }) => app.getPath('home')) + app.evaluate(({ app }) => { + // This fixture owns every launch; native relaunch leaves an unattached Playwright child. + app.relaunch = () => {} + return app.getPath('home') + }) ) assertElectronResolvedIsolatedHome(resolvedHome, homeIsolation) + const page = await app.firstWindow({ timeout: 120_000 }) + await page.waitForLoadState('domcontentloaded') + await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) + return { app, page } } catch (error) { await closeElectronAppForE2E(app) throw error } - const page = await app.firstWindow({ timeout: 120_000 }) - await page.waitForLoadState('domcontentloaded') - await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) - return { app, page } } const close = async (app: ElectronApplication): Promise => { diff --git a/tests/e2e/helpers/persisted-profile-state.ts b/tests/e2e/helpers/persisted-profile-state.ts new file mode 100644 index 000000000000..02e00a89873d --- /dev/null +++ b/tests/e2e/helpers/persisted-profile-state.ts @@ -0,0 +1,60 @@ +import { join } from 'node:path' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../src/shared/orca-profiles' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from '../../../src/main/persistence/profile-state/profile-state-access' +import { openProfileStateDatabaseReadOnly } from '../../../src/main/persistence/profile-state/profile-state-database' +import { readProfileStateParsedSnapshot } from '../../../src/main/persistence/profile-state/profile-state-documents' +import { parseProfileStateRoot } from '../../../src/main/persistence/profile-state/profile-state-document-validation' +import { ProfileStateSqliteAuthority } from '../../../src/main/persistence/profile-state/profile-state-sqlite-authority' + +/** Read committed storage without consulting a retained migration JSON snapshot. */ +export function readPersistedProfileState( + userDataDir: string, + profileId = DEFAULT_LOCAL_ORCA_PROFILE_ID +): Record { + const admission = acquireProfileStateRuntimeAdmission(userDataDir) + try { + const opened = openProfileStateDatabaseReadOnly( + join(userDataDir, 'profiles', profileId, 'profile-state.db'), + profileId + ) + try { + return readProfileStateParsedSnapshot(opened.db).state + } finally { + opened.db.close() + } + } finally { + admission.release() + } +} + +/** Seed restart artifacts only while no runtime or other fixture writer owns the profile. */ +export function mutateStoppedProfileState( + userDataDir: string, + mutate: (state: Record) => T, + profileId = DEFAULT_LOCAL_ORCA_PROFILE_ID +): T { + const maintenance = acquireProfileStateMaintenance(userDataDir) + const authority = new ProfileStateSqliteAuthority( + join(userDataDir, 'profiles', profileId, 'profile-state.db'), + profileId + ) + try { + const serialized = authority.readSerializedState() + if (serialized === undefined) { + throw new Error('Expected an established profile before seeding restart state') + } + const state = parseProfileStateRoot(serialized) + const result = mutate(state) + authority.writeSerializedState(Buffer.from(JSON.stringify(state))) + return result + } finally { + try { + authority.close() + } finally { + maintenance.release() + } + } +} diff --git a/tests/e2e/helpers/persisted-profile-state.unit.test.ts b/tests/e2e/helpers/persisted-profile-state.unit.test.ts new file mode 100644 index 000000000000..50588174d27b --- /dev/null +++ b/tests/e2e/helpers/persisted-profile-state.unit.test.ts @@ -0,0 +1,71 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { openProfileStateDatabase } from '../../../src/main/persistence/profile-state/profile-state-database' +import { importProfileStateJson } from '../../../src/main/persistence/profile-state/profile-state-documents' +import { acquireProfileStateRuntimeAdmission } from '../../../src/main/persistence/profile-state/profile-state-access' +import { mutateStoppedProfileState, readPersistedProfileState } from './persisted-profile-state' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-persisted-test-state-')) + roots.push(root) + const directory = join(root, 'profiles', 'local-default') + mkdirSync(directory, { recursive: true }) + const databaseFile = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const state = { settings: { sealed: 'ciphertext' }, unknown: { kept: null, output: '\ud800' } } + const opened = openProfileStateDatabase(databaseFile, 'local-default') + try { + importProfileStateJson(opened.db, JSON.stringify(state)) + } finally { + opened.db.close() + } + writeFileSync(dataFile, '{"retained":"old migration snapshot"}') + return { root, databaseFile, dataFile, state } +} + +it('reads committed SQLite and changes stopped fixtures without rewriting retained JSON', () => { + const item = fixture() + const before = readFileSync(item.dataFile) + expect(readPersistedProfileState(item.root)).toEqual(item.state) + const result = mutateStoppedProfileState(item.root, (state) => { + expect(() => acquireProfileStateRuntimeAdmission(item.root)).toThrow() + state.fixture = { changed: true } + return 'complete' + }) + expect(result).toBe('complete') + expect(readPersistedProfileState(item.root)).toEqual({ + ...item.state, + fixture: { changed: true } + }) + expect(readFileSync(item.dataFile)).toEqual(before) +}) + +it('refuses a fixture write while a runtime is admitted and releases the refused owner', () => { + const item = fixture() + const runtime = acquireProfileStateRuntimeAdmission(item.root) + try { + expect(() => mutateStoppedProfileState(item.root, () => {})).toThrow() + expect(readPersistedProfileState(item.root)).toEqual(item.state) + } finally { + runtime.release() + } + expect(() => mutateStoppedProfileState(item.root, () => {})).not.toThrow() +}) + +it('does not create missing authority when a test points at the wrong profile', () => { + const item = fixture() + rmSync(item.databaseFile) + expect(() => mutateStoppedProfileState(item.root, () => {})).toThrow() + expect(existsSync(item.databaseFile)).toBe(false) + const runtime = acquireProfileStateRuntimeAdmission(item.root) + runtime.release() +}) diff --git a/tests/e2e/helpers/terminal-restart-persistence.ts b/tests/e2e/helpers/terminal-restart-persistence.ts new file mode 100644 index 000000000000..46ba4efda9fb --- /dev/null +++ b/tests/e2e/helpers/terminal-restart-persistence.ts @@ -0,0 +1,186 @@ +import { readFileSync, existsSync } from 'node:fs' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './orca-app' +import { TEST_REPO_PATH_FILE } from '../global-setup' +import { attachRepoAndOpenTerminal } from './orca-restart' +import { discoverActivePtyId, waitForActiveTerminalManager, waitForPaneCount } from './terminal' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveWorktreeId, + getActiveTabId, + ensureTerminalVisible +} from './store' + +const REQUIRE_WINDOWS_TERMINAL_RESTART_E2E = + process.env.ORCA_REQUIRE_WINDOWS_TERMINAL_RESTART_E2E === '1' +const MISSING_SEEDED_REPO_MESSAGE = 'Global setup did not produce a seeded test repo' + +export function seededRepoPathOrSkip(): string { + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + : '' + const unavailable = !repoPath || !existsSync(repoPath) + if (unavailable && REQUIRE_WINDOWS_TERMINAL_RESTART_E2E) { + throw new Error('Required Windows restart E2E seeded repo is unavailable') + } + test.skip(unavailable, MISSING_SEEDED_REPO_MESSAGE) + return repoPath +} + +/** + * Shared bootstrap for a *first* launch: attach the seeded test repo, + * activate its worktree, ensure a terminal is mounted, and return the + * PTY id we can drive with `execInTerminal`. + * + * Why: every test in this file needs the exact same starting state on the + * first launch. Inlining it would obscure the thing each test is actually + * asserting about the *second* launch. + */ +export async function bootstrapFirstLaunch( + page: Page, + repoPath: string +): Promise<{ worktreeId: string; ptyId: string }> { + const worktreeId = await attachRepoAndOpenTerminal(page, repoPath) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + + const hasPaneManager = await waitForActiveTerminalManager(page, 30_000) + .then(() => true) + .catch(() => false) + if (!hasPaneManager && REQUIRE_WINDOWS_TERMINAL_RESTART_E2E) { + throw new Error('Required Windows restart E2E TerminalPane manager did not mount') + } + test.skip( + !hasPaneManager, + 'Electron automation in this environment never mounts the TerminalPane manager, so restart-persistence assertions would only fail on harness setup.' + ) + await waitForPaneCount(page, 1, 30_000) + + const ptyId = await discoverActivePtyId(page) + return { worktreeId, ptyId } +} + +/** + * Shared bootstrap for a *second* launch: just wait for the session to + * restore, and confirm the previously-active worktree is the active one + * again so downstream assertions operate against the right worktree. + */ +export async function bootstrapRestoredLaunch( + page: Page, + expectedWorktreeId: string +): Promise { + await waitForSessionReady(page) + await expect + .poll(async () => getActiveWorktreeId(page), { timeout: 10_000 }) + .toBe(expectedWorktreeId) + await ensureTerminalVisible(page) + // Why: the PaneManager remounts asynchronously after session hydration. The + // restored terminal surface is what we're about to assert against, so make + // sure it exists before any content/layout assertion races. + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) +} + +export async function setPaneTitleFromTerminalMenu(page: Page, title: string): Promise { + const modifiers: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = + process.platform === 'win32' ? ['Control'] : [] + await page + .locator('.xterm:visible') + .first() + .click({ button: 'right', position: { x: 40, y: 40 }, modifiers }) + await page.getByText('Set Title…', { exact: true }).click() + const titleInput = page.locator('.pane-title-input').first() + await expect(titleInput).toBeVisible() + await titleInput.fill(title) + await titleInput.press('Enter') +} + +export async function getTabCustomTitle( + page: Page, + worktreeId: string, + tabId: string +): Promise { + return page.evaluate( + ({ targetWorktreeId, targetTabId }) => { + const state = window.__store!.getState() + const tab = (state.tabsByWorktree[targetWorktreeId] ?? []).find( + (entry) => entry.id === targetTabId + ) + return tab?.customTitle ?? null + }, + { targetWorktreeId: worktreeId, targetTabId: tabId } + ) +} + +export async function readTerminalActiveLine(page: Page): Promise { + const tabId = await getActiveTabId(page) + if (!tabId) { + return null + } + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const buffer = pane?.terminal?.buffer.active + if (!buffer) { + return null + } + const cursorLine = buffer.baseY + buffer.cursorY + return buffer.getLine(cursorLine)?.translateToString(true) ?? null + }, tabId) +} + +export async function waitForTerminalActiveLine(page: Page, expectedText: string): Promise { + await expect + .poll(async () => (await readTerminalActiveLine(page))?.includes(expectedText), { + timeout: 15_000, + message: `Terminal cursor line did not contain "${expectedText}"` + }) + .toBe(true) + + const activeLine = await readTerminalActiveLine(page) + if (activeLine === null) { + throw new Error('Terminal cursor line disappeared after settling') + } + return activeLine +} + +export async function waitForElectronProcessExit(app: ElectronApplication): Promise { + const process = app.process() + if (process.exitCode !== null || process.signalCode !== null) { + return + } + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + process.off('exit', onExit) + reject(new Error('Electron profile-switch relaunch did not exit')) + }, 15_000) + const onExit = (): void => { + clearTimeout(timeout) + process.off('exit', onExit) + resolve() + } + process.once('exit', onExit) + }) +} + +export async function expectSavedLayoutToContainTitle( + page: Page, + tabId: string, + title: string +): Promise { + await expect + .poll( + () => + page.evaluate( + ({ targetTabId, title }) => { + const layout = window.__store!.getState().terminalLayoutsByTabId[targetTabId] + return Object.values(layout?.titlesByLeafId ?? {}).includes(title) + }, + { targetTabId: tabId, title } + ), + { timeout: 3_000 } + ) + .toBe(true) +} diff --git a/tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts b/tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts index 73527afa50dc..28d8f5565df5 100644 --- a/tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts +++ b/tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts @@ -1,3 +1,4 @@ +import { readPersistedProfileState } from './helpers/persisted-profile-state' import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -17,7 +18,6 @@ import { DaemonClient } from '../../src/main/daemon/client' import { getDaemonSocketPath, getDaemonTokenPath } from '../../src/main/daemon/daemon-spawner' import Database from '../../src/main/sqlite/sync-database' import { LEGACY_CONTRACT_VERSION } from '../../src/main/runtime/orchestration/db' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types' import { buildFakeAgentCommandOverride, @@ -144,12 +144,9 @@ async function detachedDaemonSessionExists(userDataDir: string, ptyId: string): } } -function persistedDataPath(userDataDir: string): string { - return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') -} - function hasPersistedResumeRecord(userDataDir: string, paneKey: string): boolean { - const data = JSON.parse(readFileSync(persistedDataPath(userDataDir), 'utf8')) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = readPersistedProfileState(userDataDir) as { workspaceSession?: { sleepingAgentSessionsByPaneKey?: Record } diff --git a/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts b/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts index 68c508eca629..a7090c0fd924 100644 --- a/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts +++ b/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts @@ -1,3 +1,7 @@ +import { + readPersistedProfileState, + mutateStoppedProfileState +} from './helpers/persisted-profile-state' import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -21,7 +25,6 @@ import { LEGACY_CONTRACT_VERSION, LEGACY_RUN_ID } from '../../src/main/runtime/orchestration/db' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types' import { listAllOrchestrationRuns } from './orchestration-run-pages' import { @@ -231,12 +234,9 @@ function isProcessAlive(pid: number): boolean { } } -function persistedDataPath(userDataDir: string): string { - return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') -} - function readPersistedData(userDataDir: string): PersistedData { - return JSON.parse(readFileSync(persistedDataPath(userDataDir), 'utf8')) as PersistedData + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + return readPersistedProfileState(userDataDir) as PersistedData } function hasPersistedResumeRecord(userDataDir: string, paneKey: string): boolean { @@ -273,42 +273,44 @@ function stripLegacyWorkerRendererBinding( workerPaneKey: string } ): void { - const data = readPersistedData(userDataDir) - const session = data.workspaceSession - if (!session) { - throw new Error('Expected a persisted workspace session') - } - const sleeping = session.sleepingAgentSessionsByPaneKey?.[input.workerPaneKey] - if (sleeping?.providerSession?.id !== PROVIDER_SESSION_ID) { - throw new Error('Expected the legacy worker resume record before removing its tab binding') - } - session.tabsByWorktree = { - ...session.tabsByWorktree, - [input.worktreeId]: (session.tabsByWorktree?.[input.worktreeId] ?? []).filter( - (tab) => tab.id !== input.workerTabId - ) - } - delete session.terminalLayoutsByTabId?.[input.workerTabId] - if (session.unifiedTabs?.[input.worktreeId]) { - session.unifiedTabs[input.worktreeId] = session.unifiedTabs[input.worktreeId].filter( - (tab) => tab.id !== input.workerTabId && tab.entityId !== input.workerTabId - ) - } - for (const group of session.tabGroups?.[input.worktreeId] ?? []) { - group.tabOrder = group.tabOrder.filter((tabId) => tabId !== input.workerTabId) - group.recentTabIds = group.recentTabIds?.filter((tabId) => tabId !== input.workerTabId) - if (group.activeTabId === input.workerTabId) { - group.activeTabId = input.coordinatorTabId + return mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = state as PersistedData + const session = data.workspaceSession + if (!session) { + throw new Error('Expected a persisted workspace session') } - } - session.activeTabId = input.coordinatorTabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [input.worktreeId]: input.coordinatorTabId - } - delete session.terminalPtyIncarnationsByPaneKey?.[input.workerPaneKey] - delete session.terminalSurfaceTombstonesByPaneKey?.[input.workerPaneKey] - writeFileSync(persistedDataPath(userDataDir), `${JSON.stringify(data, null, 2)}\n`, 'utf8') + const sleeping = session.sleepingAgentSessionsByPaneKey?.[input.workerPaneKey] + if (sleeping?.providerSession?.id !== PROVIDER_SESSION_ID) { + throw new Error('Expected the legacy worker resume record before removing its tab binding') + } + session.tabsByWorktree = { + ...session.tabsByWorktree, + [input.worktreeId]: (session.tabsByWorktree?.[input.worktreeId] ?? []).filter( + (tab) => tab.id !== input.workerTabId + ) + } + delete session.terminalLayoutsByTabId?.[input.workerTabId] + if (session.unifiedTabs?.[input.worktreeId]) { + session.unifiedTabs[input.worktreeId] = session.unifiedTabs[input.worktreeId].filter( + (tab) => tab.id !== input.workerTabId && tab.entityId !== input.workerTabId + ) + } + for (const group of session.tabGroups?.[input.worktreeId] ?? []) { + group.tabOrder = group.tabOrder.filter((tabId) => tabId !== input.workerTabId) + group.recentTabIds = group.recentTabIds?.filter((tabId) => tabId !== input.workerTabId) + if (group.activeTabId === input.workerTabId) { + group.activeTabId = input.coordinatorTabId + } + } + session.activeTabId = input.coordinatorTabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [input.worktreeId]: input.coordinatorTabId + } + delete session.terminalPtyIncarnationsByPaneKey?.[input.workerPaneKey] + delete session.terminalSurfaceTombstonesByPaneKey?.[input.workerPaneKey] + }) } function assertDispatchRemainsCurrent( diff --git a/tests/e2e/paired-client-hosted-browser-ghost-close.spec.ts b/tests/e2e/paired-client-hosted-browser-ghost-close.spec.ts index b9cd2b35dc7d..bc6514a39041 100644 --- a/tests/e2e/paired-client-hosted-browser-ghost-close.spec.ts +++ b/tests/e2e/paired-client-hosted-browser-ghost-close.spec.ts @@ -1,4 +1,5 @@ -import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { readdirSync, existsSync } from 'node:fs' +import { mutateStoppedProfileState } from './helpers/persisted-profile-state' import path from 'node:path' import { expect, test } from './helpers/orca-app' import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' @@ -35,61 +36,44 @@ const RECONNECT_GRACE_OVERSHOOT_MS = 20_000 * nothing on the host answers for. */ function forgetPersistedClientHostedPages(userDataDir: string): number { - return listOrcaDataFiles(userDataDir).reduce( - (total, dataFile) => total + forgetPersistedClientHostedPagesIn(dataFile), - 0 - ) -} - -/** - * Every orca-data.json under a user-data dir. - * - * The live one is `profiles//orca-data.json`; the root file is only the harness's onboarding - * seed, which the first boot migrates from. Reading the seed alone made the strip a no-op that - * looked exactly like a runtime that had persisted nothing. - */ -function listOrcaDataFiles(userDataDir: string): string[] { const profilesDir = path.join(userDataDir, 'profiles') - let profileFiles: string[] = [] - try { - profileFiles = readdirSync(profilesDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(profilesDir, entry.name, 'orca-data.json')) - } catch { - // No profile directory yet; only the harness seed exists. - } - return [path.join(userDataDir, 'orca-data.json'), ...profileFiles].filter((file) => { - try { - readFileSync(file, 'utf8') - return true - } catch { - return false - } - }) -} - -function forgetPersistedClientHostedPagesIn(dataFile: string): number { - const state = JSON.parse(readFileSync(dataFile, 'utf8')) as { - workspaceSession?: { clientHostedBrowserPagesByWorktree?: Record } - workspaceSessionsByHostId?: Record< - string, - { clientHostedBrowserPagesByWorktree?: Record } - > - } - let forgotten = 0 - for (const session of [ - state.workspaceSession, - ...Object.values(state.workspaceSessionsByHostId ?? {}) - ]) { - const rows = session?.clientHostedBrowserPagesByWorktree - if (!rows) { - continue - } - forgotten += Object.values(rows).reduce((total, list) => total + list.length, 0) - delete session.clientHostedBrowserPagesByWorktree - } - writeFileSync(dataFile, `${JSON.stringify(state, null, 2)}\n`) - return forgotten + return readdirSync(profilesDir, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && existsSync(path.join(profilesDir, entry.name, 'profile-state.db')) + ) + .reduce( + (total, entry) => + total + + mutateStoppedProfileState( + userDataDir, + (raw) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const state = raw as { + workspaceSession?: { clientHostedBrowserPagesByWorktree?: Record } + workspaceSessionsByHostId?: Record< + string, + { clientHostedBrowserPagesByWorktree?: Record } + > + } + let forgotten = 0 + for (const session of [ + state.workspaceSession, + ...Object.values(state.workspaceSessionsByHostId ?? {}) + ]) { + const rows = session?.clientHostedBrowserPagesByWorktree + if (!rows) { + continue + } + forgotten += Object.values(rows).reduce((count, list) => count + list.length, 0) + delete session.clientHostedBrowserPagesByWorktree + } + return forgotten + }, + entry.name + ), + 0 + ) } /** diff --git a/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts b/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts index b9d2242de223..7047f8a6dcb2 100644 --- a/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts +++ b/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts @@ -8,11 +8,13 @@ * tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts \ * --config tests/playwright.config.ts --project electron-headless --workers=1 */ -import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { getHistorySessionDirName } from '../../src/main/daemon/history-paths' import { LOG_HEADER_BYTES } from '../../src/main/daemon/terminal-history-log' +import { profileStateDatabaseFile } from '../../src/main/persistence/profile-state/profile-state-database' +import { ProfileStateSqliteAuthority } from '../../src/main/persistence/profile-state/profile-state-sqlite-authority' import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import { toRemoteRuntimePtyId } from '../../src/shared/remote-runtime-pty-id' @@ -87,6 +89,80 @@ test.afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) +test('SQLite candidate retains a remote automation run across serve restart', async ({ + testRepoPath +}) => { + test.setTimeout(240_000) + + const host = await launchHeadlessPairedRuntimeHost({ pinnedServePort: true }) + try { + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: testRepoPath, + kind: 'git' + }) + const automation = await host.client.call<{ automation: { id: string } }>('automation.create', { + agentId: 'codex', + name: `remote-sqlite-restart-${Date.now()}`, + prompt: 'Retain this remote automation run through a SQLite-only serve restart.', + repo: `id:${added.result.repo.id}`, + runContext: { + kind: 'workspace-run', + projectId: added.result.repo.id, + hostId: 'runtime:missing', + projectHostSetupId: `missing-${Date.now()}`, + repoId: added.result.repo.id, + path: testRepoPath + }, + workspaceMode: 'new_per_run', + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: Date.now(), + enabled: false, + missedRunGraceMinutes: 720 + }) + const run = await host.client.call<{ run: { id: string; status: string } }>( + 'automation.runNow', + { id: automation.result.automation.id } + ) + expect(['dispatching', 'dispatched']).toContain(run.result.run.status) + + const beforeRestart = await host.client.call<{ runs: { id: string }[] }>('automation.runs', { + automationId: automation.result.automation.id + }) + expect(beforeRestart.result.runs.some((entry) => entry.id === run.result.run.id)).toBe(true) + + const profileDirectory = path.join(host.userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + const profileJsonPath = path.join(profileDirectory, 'orca-data.json') + const rootJsonPath = path.join(host.userDataDir, 'orca-data.json') + const databasePath = profileStateDatabaseFile(profileDirectory) + expect(existsSync(databasePath)).toBe(true) + await host.restartServeProcess({ + betweenProcesses: () => { + rmSync(profileJsonPath, { force: true }) + rmSync(rootJsonPath, { force: true }) + } + }) + expect(existsSync(profileJsonPath)).toBe(false) + expect(existsSync(rootJsonPath)).toBe(false) + + const afterRestartDefinitions = await host.client.call<{ + automations: { id: string }[] + }>('automation.list') + expect( + afterRestartDefinitions.result.automations.some( + (entry) => entry.id === automation.result.automation.id + ) + ).toBe(true) + const afterRestart = await host.client.call<{ runs: { id: string }[] }>('automation.runs', { + automationId: automation.result.automation.id + }) + expect(afterRestart.result.runs.some((entry) => entry.id === run.result.run.id)).toBe(true) + } finally { + await host.dispose() + } +}) + type HostSurface = { leafId: string parentTabId: string @@ -125,14 +201,35 @@ function removePersistedTerminalBinding( terminal: Pick ): void { const dataPath = persistedDataPath(userDataDir) - const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedData - const bindings = - data.workspaceSession?.terminalLayoutsByTabId?.[terminal.parentTabId]?.ptyIdsByLeafId - if (bindings?.[terminal.leafId] !== terminal.ptyId) { - throw new Error('Expected the live terminal binding before removing it from persisted state') + const authority = new ProfileStateSqliteAuthority( + profileStateDatabaseFile(path.dirname(dataPath)), + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + try { + const serialized = authority.readSerializedState() + if (!serialized) { + throw new Error('Expected established SQLite state before removing terminal binding') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SQLite authority validates the complete storage snapshot before returning it. + const session = JSON.parse(serialized) as PersistedData + const layout = session.workspaceSession?.terminalLayoutsByTabId?.[terminal.parentTabId] + const bindings = layout?.ptyIdsByLeafId + if (!layout || !bindings || bindings[terminal.leafId] !== terminal.ptyId) { + throw new Error('Expected the live terminal binding before removing it from SQLite state') + } + const nextBindings = { ...bindings } + delete nextBindings[terminal.leafId] + session.workspaceSession = { + ...session.workspaceSession, + terminalLayoutsByTabId: { + ...session.workspaceSession?.terminalLayoutsByTabId, + [terminal.parentTabId]: { ...layout, ptyIdsByLeafId: nextBindings } + } + } + authority.writeSerializedState(Buffer.from(JSON.stringify(session))) + } finally { + authority.close?.() } - delete bindings[terminal.leafId] - writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8') } function readHistoryLogEvidence(outputLogPath: string, marker: string): HistoryLogEvidence { diff --git a/tests/e2e/persisted-session-production-upgrade.spec.ts b/tests/e2e/persisted-session-production-upgrade.spec.ts index 3f63366b25f7..d18c31cf92bd 100644 --- a/tests/e2e/persisted-session-production-upgrade.spec.ts +++ b/tests/e2e/persisted-session-production-upgrade.spec.ts @@ -1,12 +1,29 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' import path from 'node:path' -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { + _electron as electron, + type ElectronApplication, + type Page, + type TestInfo +} from '@stablyai/playwright-test' import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' -import { test, expect } from './helpers/orca-app' +import { forwardElectronProcessLogs, test, expect } from './helpers/orca-app' import { TEST_REPO_PATH_FILE } from './global-setup' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { cleanupE2EDaemons, closeElectronAppForE2E } from './helpers/electron-process-shutdown' +import { getElectronIsolatedKeychainArgs } from './helpers/electron-launch-args' +import { + areSameHomePath, + assertElectronResolvedIsolatedHome, + createElectronHomeIsolation +} from './helpers/electron-home-isolation' import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { openProfileStateDatabaseReadOnly } from '../../src/main/persistence/profile-state/profile-state-database' +import { readProfileStateSnapshot } from '../../src/main/persistence/profile-state/profile-state-documents' +import { ProfileStateSqliteAuthority } from '../../src/main/persistence/profile-state/profile-state-sqlite-authority' +import { acquireProfileStateMaintenance } from '../../src/main/persistence/profile-state/profile-state-access' +import { restoreProfileStateJsonExport } from '../../src/main/persistence/profile-state/profile-state-recovery' import { discoverActivePtyId, execInTerminal, @@ -26,6 +43,7 @@ const FIXTURE_PATH = path.join( ) // This fixture captures a legacy production schema boundary; the test runs the current build. const RESTORED_TITLE = 'Production agent session' +const PACKAGED_OLD_EXECUTABLE_ENV = 'ORCA_PROFILE_STATE_PACKAGED_OLD_EXECUTABLE' type FixtureSession = { _fixtureProvenance?: unknown @@ -75,6 +93,125 @@ function installProductionSessionFixture( writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}\n`) } +function materializeLegacyProfileJson(userDataDir: string): void { + const profileDirectory = path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + const databasePath = path.join(profileDirectory, 'profile-state.db') + const dataPath = path.join(profileDirectory, 'orca-data.json') + const opened = openProfileStateDatabaseReadOnly(databasePath, DEFAULT_LOCAL_ORCA_PROFILE_ID) + try { + writeFileSync(dataPath, `${readProfileStateSnapshot(opened.db).json}\n`) + } finally { + opened.db.close() + } +} + +function publishLegacyCompatibilitySnapshot(userDataDir: string): string { + const profileDirectory = path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + const dataPath = path.join(profileDirectory, 'orca-data.json') + const databasePath = path.join(profileDirectory, 'profile-state.db') + const authority = new ProfileStateSqliteAuthority(databasePath, DEFAULT_LOCAL_ORCA_PROFILE_ID) + try { + authority.readSerializedState() + const revision = authority.writeJsonCompatibilityExport(dataPath) + if (revision === undefined) { + throw new Error('Expected the candidate profile to have a persisted revision') + } + return dataPath + } finally { + authority.close() + } +} + +function restoreLegacyProfileJson(userDataDir: string): void { + const profileDirectory = path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + const databasePath = path.join(profileDirectory, 'profile-state.db') + const dataFile = path.join(profileDirectory, 'orca-data.json') + const maintenance = acquireProfileStateMaintenance(userDataDir) + try { + restoreProfileStateJsonExport({ + maintenance, + databasePath, + dataFile, + exportPath: dataFile, + profileId: DEFAULT_LOCAL_ORCA_PROFILE_ID + }) + } finally { + maintenance.release() + } +} + +async function launchPackagedOldProfile(args: { + executablePath: string + userDataDir: string + testInfo: TestInfo +}): Promise<{ app: ElectronApplication; page: Page }> { + const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env + void _unused + const homeIsolation = createElectronHomeIsolation({ + inheritedEnv: cleanEnv, + launchEnv: {}, + extraEnv: {}, + userDataDir: args.userDataDir + }) + const app = await electron.launch({ + executablePath: args.executablePath, + args: [...getElectronIsolatedKeychainArgs(), `--user-data-dir=${args.userDataDir}`], + env: { + ...homeIsolation.env, + NODE_ENV: 'production', + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_E2E_HEADLESS: '1', + ORCA_BYPASS_SINGLE_INSTANCE_LOCK: '1' + } + }) + forwardElectronProcessLogs(app, args.testInfo) + try { + assertElectronResolvedIsolatedHome( + await app.evaluate(({ app: electronApp }) => electronApp.getPath('home')), + homeIsolation + ) + const resolvedUserDataDir = await app.evaluate(({ app: electronApp }) => + electronApp.getPath('userData') + ) + if (!areSameHomePath(resolvedUserDataDir, args.userDataDir)) { + throw new Error('Packaged old build escaped the disposable user-data boundary') + } + await app.firstWindow({ timeout: 120_000 }) + let apiPage: Page | undefined + await expect + .poll( + async () => { + for (const candidate of app.windows()) { + const hasSettingsApi = await Promise.race([ + candidate.evaluate(() => Boolean(window.api?.settings?.get)).catch(() => false), + new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), 2_000) + timeout.unref?.() + }) + ]) + if (hasSettingsApi) { + apiPage = candidate + return true + } + } + return false + }, + // Older packaged builds can spend longer in their first-run renderer bootstrap + // while the candidate daemon from the same test worker is shutting down. + { timeout: 120_000 } + ) + .toBe(true) + if (!apiPage) { + throw new Error('Packaged old build did not expose its renderer API') + } + await apiPage.waitForLoadState('domcontentloaded') + return { app, page: apiPage } + } catch (error) { + await closeElectronAppForE2E(app).catch(() => {}) + throw error + } +} + async function expectProductionSessionRestored( page: Page, expected: { marker: string; ptyId: string; repoId: string; worktreeId: string } @@ -147,7 +284,11 @@ test('upgrades a legacy daemon session and keeps it stable after relaunch', asyn await session.close(oldApp) oldApp = null + // Recreate a pre-cutover profile while retaining its live terminal identity. + materializeLegacyProfileJson(session.userDataDir) installProductionSessionFixture(session.userDataDir, repoId, worktreeId, ptyId) + await cleanupE2EDaemons(session.userDataDir) + restoreLegacyProfileJson(session.userDataDir) const currentLaunch = await session.launch() currentApp = currentLaunch.app @@ -169,3 +310,305 @@ test('upgrades a legacy daemon session and keeps it stable after relaunch', asyn await session.dispose() } }) + +// oxlint-disable-next-line no-empty-pattern -- This mixed-version test owns its Electron launches. +test('restores a JSON compatibility snapshot and migrates it on normal restart', async ({}, testInfo) => { + test.setTimeout(240_000) + + const session = createRestartSession(testInfo) + let candidateApp: ElectronApplication | null = null + let reupgradedApp: ElectronApplication | null = null + try { + const candidateLaunch = await session.launch() + candidateApp = candidateLaunch.app + await waitForSessionReady(candidateLaunch.page) + const marker = 17 + await candidateLaunch.page.evaluate(async (terminalFontSize) => { + const updateSettings = window.__store?.getState().updateSettingsOrThrow + if (!updateSettings) { + throw new Error('Candidate renderer did not expose settings persistence') + } + await updateSettings({ terminalFontSize }) + }, marker) + await expect + .poll( + () => + candidateLaunch.page.evaluate( + () => window.__store?.getState().settings?.terminalFontSize + ), + { timeout: 15_000 } + ) + .toBe(marker) + + const profileDirectory = path.join( + session.userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + const databasePath = path.join(profileDirectory, 'profile-state.db') + await session.close(candidateApp) + candidateApp = null + const dataPath = publishLegacyCompatibilitySnapshot(session.userDataDir) + expect(existsSync(databasePath)).toBe(true) + expect(existsSync(dataPath)).toBe(true) + + // Restore the exported JSON, then exercise normal first migration again. + await cleanupE2EDaemons(session.userDataDir) + restoreLegacyProfileJson(session.userDataDir) + expect(existsSync(databasePath)).toBe(false) + expect(JSON.parse(readFileSync(dataPath, 'utf8')).settings.terminalFontSize).toBe(marker) + const reupgradedLaunch = await session.launch() + reupgradedApp = reupgradedLaunch.app + await waitForSessionReady(reupgradedLaunch.page) + await expect + .poll( + () => + reupgradedLaunch.page.evaluate( + () => window.__store?.getState().settings?.terminalFontSize + ), + { timeout: 15_000 } + ) + .toBe(marker) + expect(existsSync(databasePath)).toBe(true) + } finally { + for (const app of [reupgradedApp, candidateApp]) { + if (app) { + await session.close(app).catch(() => {}) + } + } + await session.dispose() + } +}) + +test('real packaged old build reads compatibility JSON before candidate re-import', async ({ + browserName: _browserName +}, testInfo) => { + test.setTimeout(300_000) + const executablePath = process.env[PACKAGED_OLD_EXECUTABLE_ENV] + test.skip( + !executablePath || !existsSync(executablePath), + `${PACKAGED_OLD_EXECUTABLE_ENV} must point at an older packaged Orca executable` + ) + + const session = createRestartSession(testInfo) + let candidateApp: ElectronApplication | null = null + let oldApp: ElectronApplication | null = null + let reupgradedApp: ElectronApplication | null = null + try { + const candidateLaunch = await session.launch() + candidateApp = candidateLaunch.app + const marker = 19 + await candidateLaunch.page.evaluate(async (terminalFontSize) => { + const updateSettings = window.__store?.getState().updateSettingsOrThrow + if (!updateSettings) { + throw new Error('Candidate renderer did not expose settings persistence') + } + await updateSettings({ terminalFontSize }) + }, marker) + await expect + .poll(() => + candidateLaunch.page.evaluate(() => window.__store?.getState().settings?.terminalFontSize) + ) + .toBe(marker) + await session.close(candidateApp) + candidateApp = null + + const databasePath = path.join( + session.userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'profile-state.db' + ) + const compatibilityPath = publishLegacyCompatibilitySnapshot(session.userDataDir) + expect(existsSync(databasePath)).toBe(true) + expect(existsSync(compatibilityPath)).toBe(true) + await cleanupE2EDaemons(session.userDataDir) + // A pre-migration build reads the canonical JSON restored by rollback. + restoreLegacyProfileJson(session.userDataDir) + + const oldLaunch = await launchPackagedOldProfile({ + executablePath: executablePath!, + userDataDir: session.userDataDir, + testInfo + }) + oldApp = oldLaunch.app + await expect + .poll(() => oldLaunch.page.evaluate(() => window.api.settings.get())) + .toMatchObject({ + terminalFontSize: marker + }) + await closeElectronAppForE2E(oldApp) + oldApp = null + + const reupgradedLaunch = await session.launch() + reupgradedApp = reupgradedLaunch.app + await expect + .poll( + () => + reupgradedLaunch.page.evaluate( + () => window.__store?.getState().settings?.terminalFontSize + ), + { timeout: 30_000 } + ) + .toBe(marker) + expect(existsSync(databasePath)).toBe(true) + } finally { + for (const app of [reupgradedApp, oldApp, candidateApp]) { + if (app) { + await closeElectronAppForE2E(app).catch(() => {}) + } + } + await session.dispose() + } +}) + +test('fails closed when a packaged old build mutates live SQLite compatibility JSON', async ({ + browserName: _browserName +}, testInfo) => { + test.setTimeout(300_000) + const executablePath = process.env[PACKAGED_OLD_EXECUTABLE_ENV] + test.skip( + !executablePath || !existsSync(executablePath), + `${PACKAGED_OLD_EXECUTABLE_ENV} must point at an older packaged Orca executable` + ) + + const session = createRestartSession(testInfo) + let candidateApp: ElectronApplication | null = null + let oldApp: ElectronApplication | null = null + try { + const candidateLaunch = await session.launch() + candidateApp = candidateLaunch.app + await waitForSessionReady(candidateLaunch.page) + await candidateLaunch.page.evaluate(async () => { + const updateSettings = window.__store?.getState().updateSettingsOrThrow + if (!updateSettings) { + throw new Error('Candidate renderer did not expose settings persistence') + } + await updateSettings({ terminalFontSize: 19 }) + }) + await expect + .poll(() => + candidateLaunch.page.evaluate(() => window.__store?.getState().settings?.terminalFontSize) + ) + .toBe(19) + await session.close(candidateApp) + candidateApp = null + + const profileDirectory = path.join( + session.userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + const databasePath = path.join(profileDirectory, 'profile-state.db') + const compatibilityPath = publishLegacyCompatibilitySnapshot(session.userDataDir) + expect(existsSync(databasePath)).toBe(true) + expect(existsSync(compatibilityPath)).toBe(true) + + const oldLaunch = await launchPackagedOldProfile({ + executablePath: executablePath!, + userDataDir: session.userDataDir, + testInfo + }) + oldApp = oldLaunch.app + await expect + .poll(() => oldLaunch.page.evaluate(() => window.api.settings.get())) + .toMatchObject({ terminalFontSize: 19 }) + await oldLaunch.page.evaluate(async () => { + await window.api.settings.set({ terminalFontSize: 23 }) + }) + await expect + .poll(() => oldLaunch.page.evaluate(() => window.api.settings.get())) + .toMatchObject({ terminalFontSize: 23 }) + await closeElectronAppForE2E(oldApp) + oldApp = null + + const mutatedJson = JSON.parse(readFileSync(compatibilityPath, 'utf8')) + expect(mutatedJson).toMatchObject({ settings: { terminalFontSize: 23 } }) + expect(existsSync(databasePath)).toBe(true) + + let stderr = '' + let launchError: unknown + try { + await session.launch({ + onStderr: (chunk) => { + stderr += chunk + } + }) + } catch (error) { + launchError = error + } + expect(launchError).toBeDefined() + expect(stderr).toContain('both JSON and SQLite storage without a matching acceptance marker') + expect(existsSync(databasePath)).toBe(true) + const opened = openProfileStateDatabaseReadOnly(databasePath, DEFAULT_LOCAL_ORCA_PROFILE_ID) + try { + expect(JSON.parse(readProfileStateSnapshot(opened.db).json)).toMatchObject({ + settings: { terminalFontSize: 19 } + }) + } finally { + opened.db.close() + } + } finally { + for (const app of [oldApp, candidateApp]) { + if (app) { + await closeElectronAppForE2E(app).catch(() => {}) + } + } + await session.dispose() + } +}) + +// oxlint-disable-next-line no-empty-pattern -- This recovery test owns its Electron launches. +test('fails closed on a corrupt established SQLite profile and retains recovery evidence', async ({}, testInfo) => { + test.setTimeout(180_000) + + const session = createRestartSession(testInfo) + let app: ElectronApplication | null = null + try { + const initialLaunch = await session.launch() + app = initialLaunch.app + await waitForSessionReady(initialLaunch.page) + await session.close(app) + app = null + + const profileDirectory = path.join( + session.userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + const dataFile = path.join(profileDirectory, 'orca-data.json') + const databaseFile = path.join(profileDirectory, 'profile-state.db') + const retainedExports = readdirSync(profileDirectory).filter((name) => + /^orca-data\.json\.sqlite-export\.\d+\.json$/.test(name) + ) + expect(retainedExports.length).toBeGreaterThan(0) + const jsonBeforeCorruption = readFileSync(dataFile) + + writeFileSync(databaseFile, 'corrupt profile-state database') + let stderr = '' + let launchError: unknown + try { + await session.launch({ + onStderr: (chunk) => { + stderr += chunk + } + }) + } catch (error) { + launchError = error + } + + expect(launchError).toBeDefined() + expect(stderr).toContain('cannot safely open the active profile') + expect(readFileSync(dataFile)).toEqual(jsonBeforeCorruption) + expect( + readdirSync(profileDirectory).filter((name) => + /^orca-data\.json\.sqlite-export\.\d+\.json$/.test(name) + ) + ).toEqual(retainedExports) + } finally { + if (app) { + await session.close(app).catch(() => {}) + } + await session.dispose() + } +}) diff --git a/tests/e2e/profile-state-automatic-backup-recovery.spec.ts b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts new file mode 100644 index 000000000000..ef50e173ca65 --- /dev/null +++ b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts @@ -0,0 +1,187 @@ +import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' +import { runProcess } from '../../src/shared/child-process/run-process' +import { openProfileStateDatabaseReadOnly } from '../../src/main/persistence/profile-state/profile-state-database' +import { readProfileStateSnapshot } from '../../src/main/persistence/profile-state/profile-state-documents' +import { profileStateDatabaseBackups } from '../../src/main/persistence/profile-state/profile-state-backup-path' +import { test, expect } from './helpers/orca-app' +import { createRestartSession } from './helpers/orca-restart' +import { getE2ECompletedOnboardingProfile } from './helpers/e2e-completed-onboarding-profile' +import { createElectronHomeIsolation } from './helpers/electron-home-isolation' +import { cleanupE2EDaemons } from './helpers/electron-process-shutdown' +import { waitForSessionReady } from './helpers/store' + +function readSnapshot(databasePath: string) { + const opened = openProfileStateDatabaseReadOnly(databasePath, DEFAULT_LOCAL_ORCA_PROFILE_ID) + try { + return readProfileStateSnapshot(opened.db) + } finally { + opened.db.close() + } +} + +function rollbackProfileBackup(userDataDir: string, backupId: string, executable?: string) { + const cliIsolation = createElectronHomeIsolation({ + inheritedEnv: process.env, + launchEnv: { ORCA_USER_DATA_PATH: userDataDir, ORCA_BACKGROUND_LAUNCH: '1' }, + extraEnv: executable + ? { ORCA_APP_EXECUTABLE: executable, ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1' } + : {}, + userDataDir + }) + return runProcess({ + program: process.execPath, + args: [ + path.join(process.cwd(), 'out', 'cli', 'index.js'), + 'profile', + 'state', + 'rollback', + '--backup', + backupId, + '--json' + ], + env: cliIsolation.env, + timeoutMs: 30_000, + maxOutputBytes: 64 * 1024 + }) +} + +for (const recoveryRuntime of ['node', 'electron'] as const) { + // oxlint-disable-next-line no-empty-pattern -- This test owns both hidden Electron launches. + test(`restores an automatic SQLite backup through the ${recoveryRuntime} CLI after primary corruption`, async ({}, testInfo) => { + test.setTimeout(180_000) + const session = createRestartSession(testInfo, { ORCA_BACKGROUND_LAUNCH: '1' }) + const rootJson = path.join(session.userDataDir, 'orca-data.json') + const profileDirectory = path.join( + session.userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + const databasePath = path.join(profileDirectory, 'profile-state.db') + const dataFile = path.join(profileDirectory, 'orca-data.json') + const marker = `automatic-backup-${Date.now()}` + const seed = getE2ECompletedOnboardingProfile() + writeFileSync( + rootJson, + JSON.stringify({ + ...seed, + settings: { ...seed.settings, terminalFontSize: 19, theme: 'light' }, + backupRecoveryMarker: { marker } + }) + ) + let firstApp: ElectronApplication | null = null + let restoredApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + await waitForSessionReady(first.page) + expect( + await first.app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + await first.page.evaluate(async () => { + const update = window.__store?.getState().updateSettingsOrThrow + if (!update) { + throw new Error('Renderer settings persistence is unavailable') + } + await update({ theme: 'dark' }) + }) + + // Only normal app writes create this recovery point; the test never calls a snapshot writer. + await expect + .poll(() => profileStateDatabaseBackups(databasePath).length, { timeout: 30_000 }) + .toBeGreaterThan(0) + const backup = profileStateDatabaseBackups(databasePath)[0] + const chosen = readSnapshot(backup.path) + expect(JSON.parse(chosen.json)).toMatchObject({ + settings: { terminalFontSize: 19 }, + backupRecoveryMarker: { marker } + }) + const backupBytes = readFileSync(backup.path) + await first.page.evaluate(async () => { + const update = window.__store?.getState().updateSettingsOrThrow + if (!update) { + throw new Error('Renderer settings persistence is unavailable') + } + await update({ terminalFontSize: 23 }) + }) + await expect + .poll(() => JSON.parse(readSnapshot(databasePath).json).settings.terminalFontSize) + .toBe(23) + expect(readFileSync(backup.path)).toEqual(backupBytes) + const beforeRefusal = readSnapshot(databasePath) + const recoveryExecutable = + recoveryRuntime === 'electron' + ? await first.app.evaluate(() => process.execPath) + : undefined + const refused = await rollbackProfileBackup( + session.userDataDir, + backup.id, + recoveryExecutable + ) + expect(refused.code).toBe(1) + expect(refused.stdout + refused.stderr).toContain( + recoveryRuntime === 'electron' ? 'Stop Orca' : 'in use' + ) + expect(readSnapshot(databasePath)).toEqual(beforeRefusal) + expect(readFileSync(backup.path)).toEqual(backupBytes) + await session.close(firstApp) + firstApp = null + await cleanupE2EDaemons(session.userDataDir) + + for (const file of [dataFile, rootJson, `${databasePath}-wal`, `${databasePath}-shm`]) { + rmSync(file, { force: true }) + } + writeFileSync(databasePath, 'deliberately corrupt SQLite primary') + expect(() => readSnapshot(databasePath)).toThrow() + const restored = await rollbackProfileBackup( + session.userDataDir, + backup.id, + recoveryExecutable + ) + expect(restored.code, restored.stderr || restored.stdout).toBe(0) + expect(JSON.parse(restored.stdout)).toMatchObject({ + ok: true, + result: { + storage: 'sqlite', + backupId: backup.id, + revision: chosen.revision, + profileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + restoredPath: recoveryRuntime === 'electron' ? realpathSync(databasePath) : databasePath + } + }) + expect(readSnapshot(databasePath)).toEqual(chosen) + expect(existsSync(dataFile)).toBe(false) + + const relaunched = await session.launch() + restoredApp = relaunched.app + await waitForSessionReady(relaunched.page) + await expect + .poll(() => + relaunched.page.evaluate(() => window.__store?.getState().settings?.terminalFontSize) + ) + .toBe(19) + expect(JSON.parse(readSnapshot(databasePath).json).backupRecoveryMarker).toEqual({ marker }) + expect( + await relaunched.app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + expect(existsSync(dataFile)).toBe(false) + } finally { + try { + if (restoredApp) { + await session.close(restoredApp) + } + if (firstApp) { + await session.close(firstApp) + } + } finally { + await session.dispose() + } + } + }) +} diff --git a/tests/e2e/profile-state-terminal-restart-persistence.spec.ts b/tests/e2e/profile-state-terminal-restart-persistence.spec.ts new file mode 100644 index 000000000000..a0ac975a35ad --- /dev/null +++ b/tests/e2e/profile-state-terminal-restart-persistence.spec.ts @@ -0,0 +1,566 @@ +/** Candidate SQLite terminal restart/profile journeys. */ + +import { readFileSync, existsSync, rmSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { getRepoIdFromWorktreeId } from '../../src/shared/worktree/id' +import { test, expect } from './helpers/orca-app' +import { forceQuitElectronAppForE2E } from './helpers/electron-process-shutdown' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { execInTerminal, waitForTerminalOutput, waitForActivePanePtyId } from './helpers/terminal' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveWorktreeId, + getWorktreeTabs +} from './helpers/store' +import { + seededRepoPathOrSkip, + bootstrapFirstLaunch, + bootstrapRestoredLaunch, + waitForElectronProcessExit +} from './helpers/terminal-restart-persistence' + +test.describe.configure({ mode: 'serial' }) + +test.describe('SQLite candidate terminal restart persistence', () => { + test('SQLite survives restart after legacy JSON is removed', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. + {}, testInfo) => { + const repoPath = seededRepoPathOrSkip() + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const { worktreeId, ptyId } = await bootstrapFirstLaunch(firstLaunch.page, repoPath) + + const automationName = `sqlite-candidate-restart-${Date.now()}` + const automationPrompt = 'Persist this candidate automation across an SQLite-only restart.' + const automationLifecycle = await firstLaunch.page.evaluate( + async ({ name, prompt }) => { + const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + const repo = window.__store?.getState().repos[0] + if (!repo) { + throw new Error('SQLite candidate E2E did not find a seeded repository') + } + const response = await window.api.runtime.call({ + method: 'automation.create', + params: { + agentId: 'codex', + name, + prompt, + repo: `id:${repo.id}`, + // Keep a full run context in the persisted definition so `runNow` + // exercises the same runtime RPC and normalized automationRuns + // path used by real scheduled work. + runContext: { + kind: 'workspace-run', + projectId: repo.id, + hostId: 'runtime:missing', + projectHostSetupId: `missing-${Date.now()}`, + repoId: repo.id, + path: repo.path + }, + workspaceMode: 'new_per_run', + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: Date.now(), + enabled: false, + missedRunGraceMinutes: 720 + } + }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + const createResult = isRecord(response.result) ? response.result : null + const automation = + createResult && isRecord(createResult.automation) ? createResult.automation : null + if (!automation || typeof automation.id !== 'string') { + throw new Error('automation.create returned an invalid automation') + } + const runResponse = await window.api.runtime.call({ + method: 'automation.runNow', + params: { id: automation.id } + }) + if (!runResponse.ok) { + throw new Error(`${runResponse.error.code}: ${runResponse.error.message}`) + } + const runResult = isRecord(runResponse.result) ? runResponse.result : null + const run = runResult && isRecord(runResult.run) ? runResult.run : null + if (!run || typeof run.id !== 'string' || typeof run.status !== 'string') { + throw new Error('automation.runNow returned an invalid run') + } + return { + automationId: automation.id, + runId: run.id, + runStatus: run.status + } + }, + { name: automationName, prompt: automationPrompt } + ) + expect(automationLifecycle.runStatus).toBe('dispatching') + + const profileIndex: unknown = JSON.parse( + readFileSync(path.join(session.userDataDir, 'orca-profile-index.json'), 'utf8') + ) + if ( + typeof profileIndex !== 'object' || + profileIndex === null || + Array.isArray(profileIndex) || + !('activeProfileId' in profileIndex) || + typeof profileIndex.activeProfileId !== 'string' + ) { + throw new Error('SQLite cutover E2E did not find an active profile id') + } + const profileDirectory = path.join( + session.userDataDir, + 'profiles', + profileIndex.activeProfileId + ) + const legacyProfileState = path.join(profileDirectory, 'orca-data.json') + const legacyRootState = path.join(session.userDataDir, 'orca-data.json') + const databasePath = path.join(profileDirectory, 'profile-state.db') + expect(existsSync(databasePath)).toBe(true) + expect(existsSync(legacyProfileState)).toBe(true) + + // Prove the next launch has only the SQLite authority available. + rmSync(legacyProfileState, { force: true }) + rmSync(legacyRootState, { force: true }) + expect(existsSync(legacyProfileState)).toBe(false) + expect(existsSync(legacyRootState)).toBe(false) + + await session.close(firstApp) + firstApp = null + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + await bootstrapRestoredLaunch(secondLaunch.page, worktreeId) + expect(existsSync(legacyProfileState)).toBe(false) + expect(existsSync(legacyRootState)).toBe(false) + // The daemon survives the clean Electron restart, so a SQLite-only launch + // must reattach the same PTY binding instead of silently spawning a new shell. + await expect + .poll(() => waitForActivePanePtyId(secondLaunch.page), { timeout: 15_000 }) + .toBe(ptyId) + const ptyMarker = `SQLITE_PTY_REATTACHED_${Date.now()}` + await execInTerminal(secondLaunch.page, ptyId, `echo ${ptyMarker}`) + await waitForTerminalOutput(secondLaunch.page, ptyMarker) + await expect + .poll( + async () => + await secondLaunch.page.evaluate( + async ({ name, prompt }) => { + const response = await window.api.runtime.call({ method: 'automation.list' }) + if (!response.ok) { + return false + } + if ( + typeof response.result !== 'object' || + response.result === null || + !('automations' in response.result) || + !Array.isArray(response.result.automations) + ) { + return false + } + return response.result.automations.some((automation) => { + if (typeof automation !== 'object' || automation === null) { + return false + } + return ( + 'name' in automation && + 'prompt' in automation && + automation.name === name && + automation.prompt === prompt + ) + }) + }, + { name: automationName, prompt: automationPrompt } + ), + { timeout: 10_000 } + ) + .toBe(true) + await expect + .poll( + async () => + await secondLaunch.page.evaluate(async ({ automationId, runId }) => { + const response = await window.api.runtime.call({ + method: 'automation.runs', + params: { automationId } + }) + if ( + !response.ok || + typeof response.result !== 'object' || + response.result === null || + !('runs' in response.result) || + !Array.isArray(response.result.runs) + ) { + return false + } + return response.result.runs.some( + (run) => typeof run === 'object' && run !== null && 'id' in run && run.id === runId + ) + }, automationLifecycle), + { timeout: 10_000 } + ) + .toBe(true) + await expect + .poll(async () => (await getWorktreeTabs(secondLaunch.page, worktreeId)).length, { + timeout: 10_000 + }) + .toBeGreaterThanOrEqual(1) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } + }) + + test('SQLite profile switch keeps independent profiles isolated', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. + {}, testInfo) => { + const repoPath = seededRepoPathOrSkip() + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + let thirdApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const defaultWorktreeId = await attachRepoAndOpenTerminal(firstLaunch.page, repoPath) + await waitForSessionReady(firstLaunch.page) + const defaultProfileId = await firstLaunch.page.evaluate(async () => { + const profiles = await window.api.orcaProfiles.list() + return profiles.activeProfileId + }) + const targetProfileId = await firstLaunch.page.evaluate(async () => { + const created = await window.api.orcaProfiles.createLocal({ + name: `SQLite switch target ${Date.now()}` + }) + return created.profile.id + }) + + const defaultProfileDirectory = path.join(session.userDataDir, 'profiles', defaultProfileId) + const defaultJson = path.join(defaultProfileDirectory, 'orca-data.json') + const rootJson = path.join(session.userDataDir, 'orca-data.json') + const defaultDatabase = path.join(defaultProfileDirectory, 'profile-state.db') + expect(existsSync(defaultDatabase)).toBe(true) + + // The target switch must flush and publish the index before relaunching. + await expect( + firstLaunch.page.evaluate( + (profileId) => window.api.orcaProfiles.switchProfile({ profileId }), + targetProfileId + ) + ).resolves.toEqual({ status: 'relaunching' }) + await waitForElectronProcessExit(firstApp) + firstApp = null + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + await waitForSessionReady(secondLaunch.page) + const targetList = await secondLaunch.page.evaluate(() => window.api.orcaProfiles.list()) + expect(targetList.activeProfileId).toBe(targetProfileId) + const targetProfileDirectory = path.join(session.userDataDir, 'profiles', targetProfileId) + const targetDatabase = path.join(targetProfileDirectory, 'profile-state.db') + const targetJson = path.join(targetProfileDirectory, 'orca-data.json') + expect(existsSync(targetDatabase)).toBe(true) + + // Seed an independent target-profile document before removing its JSON mirror. + await attachRepoAndOpenTerminal(secondLaunch.page, repoPath) + await waitForSessionReady(secondLaunch.page) + rmSync(targetJson, { force: true }) + rmSync(defaultJson, { force: true }) + rmSync(rootJson, { force: true }) + expect(existsSync(targetJson)).toBe(false) + expect(existsSync(defaultJson)).toBe(false) + expect(existsSync(rootJson)).toBe(false) + + await expect( + secondLaunch.page.evaluate( + (profileId) => window.api.orcaProfiles.switchProfile({ profileId }), + defaultProfileId + ) + ).resolves.toEqual({ status: 'relaunching' }) + await waitForElectronProcessExit(secondApp) + secondApp = null + + const thirdLaunch = await session.launch() + thirdApp = thirdLaunch.app + await waitForSessionReady(thirdLaunch.page) + const finalList = await thirdLaunch.page.evaluate(() => window.api.orcaProfiles.list()) + expect(finalList.activeProfileId).toBe(defaultProfileId) + expect(existsSync(defaultDatabase)).toBe(true) + expect(existsSync(targetDatabase)).toBe(true) + expect(existsSync(defaultJson)).toBe(false) + expect(existsSync(targetJson)).toBe(false) + await waitForActiveWorktree(thirdLaunch.page) + await expect + .poll(() => getActiveWorktreeId(thirdLaunch.page), { timeout: 10_000 }) + .toBe(defaultWorktreeId) + } finally { + if (thirdApp) { + await session.close(thirdApp) + } + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } + }) + + test('SQLite moves a project across JSON-free profiles', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. + {}, testInfo) => { + const repoPath = seededRepoPathOrSkip() + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + let thirdApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const worktreeId = await attachRepoAndOpenTerminal(firstLaunch.page, repoPath) + await waitForSessionReady(firstLaunch.page) + const profileState = await firstLaunch.page.evaluate(async (repoId) => { + const profiles = await window.api.orcaProfiles.list() + const repo = window.__store?.getState().repos.find((entry) => entry.id === repoId) + if (!repo) { + throw new Error('SQLite profile move E2E did not find the seeded repository') + } + const target = await window.api.orcaProfiles.createLocal({ + name: `SQLite move target ${Date.now()}` + }) + return { + sourceProfileId: profiles.activeProfileId, + targetProfileId: target.profile.id, + repoId: repo.id + } + }, getRepoIdFromWorktreeId(worktreeId)) + + const sourceDirectory = path.join( + session.userDataDir, + 'profiles', + profileState.sourceProfileId + ) + const targetDirectory = path.join( + session.userDataDir, + 'profiles', + profileState.targetProfileId + ) + const sourceDatabase = path.join(sourceDirectory, 'profile-state.db') + const targetDatabase = path.join(targetDirectory, 'profile-state.db') + const sourceJson = path.join(sourceDirectory, 'orca-data.json') + const targetJson = path.join(targetDirectory, 'orca-data.json') + const rootJson = path.join(session.userDataDir, 'orca-data.json') + expect(existsSync(sourceDatabase)).toBe(true) + + // Visit the target once so candidate startup establishes its own database before the move. + await expect( + firstLaunch.page.evaluate( + (profileId) => window.api.orcaProfiles.switchProfile({ profileId }), + profileState.targetProfileId + ) + ).resolves.toEqual({ status: 'relaunching' }) + await waitForElectronProcessExit(firstApp) + firstApp = null + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + await waitForSessionReady(secondLaunch.page) + expect(await secondLaunch.page.evaluate(() => window.api.orcaProfiles.list())).toMatchObject({ + activeProfileId: profileState.targetProfileId + }) + expect(existsSync(targetDatabase)).toBe(true) + + await expect( + secondLaunch.page.evaluate( + (profileId) => window.api.orcaProfiles.switchProfile({ profileId }), + profileState.sourceProfileId + ) + ).resolves.toEqual({ status: 'relaunching' }) + await waitForElectronProcessExit(secondApp) + secondApp = null + + const thirdLaunch = await session.launch() + thirdApp = thirdLaunch.app + await waitForSessionReady(thirdLaunch.page) + const moveResult = await thirdLaunch.page.evaluate( + (args) => window.api.orcaProfiles.transferProject(args), + { + sourceProfileId: profileState.sourceProfileId, + targetProfileId: profileState.targetProfileId, + repoId: profileState.repoId, + mode: 'move' as const + } + ) + expect(moveResult).toMatchObject({ + status: 'transferred', + mode: 'move', + willRelaunch: true + }) + await waitForElectronProcessExit(thirdApp) + thirdApp = null + + // The move commits both SQLite participants before relaunch. Remove every JSON mirror to + // prove the target and source are both reopened from their databases alone. + for (const legacyPath of [sourceJson, targetJson, rootJson]) { + rmSync(legacyPath, { force: true }) + } + expect(existsSync(sourceJson)).toBe(false) + expect(existsSync(targetJson)).toBe(false) + + const targetLaunch = await session.launch() + secondApp = targetLaunch.app + await waitForSessionReady(targetLaunch.page) + expect(await targetLaunch.page.evaluate(() => window.api.orcaProfiles.list())).toMatchObject({ + activeProfileId: profileState.targetProfileId + }) + expect( + await targetLaunch.page.evaluate( + (repoId) => window.__store?.getState().repos.some((repo) => repo.id === repoId), + profileState.repoId + ) + ).toBe(true) + expect(existsSync(targetDatabase)).toBe(true) + + await expect( + targetLaunch.page.evaluate( + (profileId) => window.api.orcaProfiles.switchProfile({ profileId }), + profileState.sourceProfileId + ) + ).resolves.toEqual({ status: 'relaunching' }) + await waitForElectronProcessExit(secondApp) + secondApp = null + + const sourceLaunch = await session.launch() + thirdApp = sourceLaunch.app + await waitForSessionReady(sourceLaunch.page) + expect(await sourceLaunch.page.evaluate(() => window.api.orcaProfiles.list())).toMatchObject({ + activeProfileId: profileState.sourceProfileId + }) + expect( + await sourceLaunch.page.evaluate( + (repoId) => window.__store?.getState().repos.some((repo) => repo.id === repoId), + profileState.repoId + ) + ).toBe(false) + expect(existsSync(sourceDatabase)).toBe(true) + expect(existsSync(sourceJson)).toBe(false) + expect(existsSync(targetJson)).toBe(false) + } finally { + if (thirdApp) { + await session.close(thirdApp) + } + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } + }) + + test('SQLite survives abrupt process termination after legacy JSON is removed', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. + {}, testInfo) => { + const repoPath = seededRepoPathOrSkip() + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const { worktreeId, ptyId } = await bootstrapFirstLaunch(firstLaunch.page, repoPath) + + const profileIndex: unknown = JSON.parse( + readFileSync(path.join(session.userDataDir, 'orca-profile-index.json'), 'utf8') + ) + if ( + typeof profileIndex !== 'object' || + profileIndex === null || + Array.isArray(profileIndex) || + !('activeProfileId' in profileIndex) || + typeof profileIndex.activeProfileId !== 'string' + ) { + throw new Error('SQLite crash E2E did not find an active profile id') + } + const profileDirectory = path.join( + session.userDataDir, + 'profiles', + profileIndex.activeProfileId + ) + const legacyProfileState = path.join(profileDirectory, 'orca-data.json') + const legacyRootState = path.join(session.userDataDir, 'orca-data.json') + const databasePath = path.join(profileDirectory, 'profile-state.db') + expect(existsSync(databasePath)).toBe(true) + + // Remove both JSON mirrors before the kill. Any state recovered by the next + // launch must therefore come from SQLite, including the terminal topology. + rmSync(legacyProfileState, { force: true }) + rmSync(legacyRootState, { force: true }) + expect(existsSync(legacyProfileState)).toBe(false) + expect(existsSync(legacyRootState)).toBe(false) + + const mainPid = await firstApp.evaluate(() => process.pid) + await forceQuitElectronAppForE2E(firstApp, { preserveDaemons: true }) + // Windows Playwright exposes a cmd wrapper; verify the actual Electron process exited. + await expect + .poll(() => { + try { + process.kill(mainPid, 0) + return false + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ESRCH') { + return true + } + throw error + } + }) + .toBe(true) + firstApp = null + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + await bootstrapRestoredLaunch(secondLaunch.page, worktreeId) + expect(existsSync(legacyProfileState)).toBe(false) + expect(existsSync(legacyRootState)).toBe(false) + // The daemon survives the Electron crash, so a SQLite-only restart must + // reattach the same PTY binding instead of silently spawning a new shell. + await expect + .poll(() => waitForActivePanePtyId(secondLaunch.page), { timeout: 15_000 }) + .toBe(ptyId) + const ptyMarker = `SQLITE_PTY_REATTACHED_AFTER_KILL_${Date.now()}` + await execInTerminal(secondLaunch.page, ptyId, `echo ${ptyMarker}`) + await waitForTerminalOutput(secondLaunch.page, ptyMarker) + await expect + .poll(async () => (await getWorktreeTabs(secondLaunch.page, worktreeId)).length, { + timeout: 10_000 + }) + .toBeGreaterThanOrEqual(1) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } + }) +}) diff --git a/tests/e2e/settled-worker-tab-survives-restart.spec.ts b/tests/e2e/settled-worker-tab-survives-restart.spec.ts index db3b03409dd6..eb86dfdf96ae 100644 --- a/tests/e2e/settled-worker-tab-survives-restart.spec.ts +++ b/tests/e2e/settled-worker-tab-survives-restart.spec.ts @@ -1,8 +1,9 @@ +import { parseWorkspaceSession } from '../../src/shared/workspace-session-schema' +import { readPersistedProfileState } from './helpers/persisted-profile-state' import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import { DaemonClient } from '../../src/main/daemon/client' import { getDaemonSocketPath, getDaemonTokenPath } from '../../src/main/daemon/daemon-spawner' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { TEST_REPO_PATH_FILE } from './global-setup' @@ -87,21 +88,20 @@ async function backgroundMountTab(page: Page, worktreeId: string, tabId: string) } function readPersistedSession(userDataDir: string) { - return JSON.parse( - readFileSync( - path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json'), - 'utf8' - ) - ).workspaceSession + const parsed = parseWorkspaceSession(readPersistedProfileState(userDataDir).workspaceSession) + if (!parsed.ok) { + throw new Error(`Invalid persisted workspace session: ${parsed.error}`) + } + return parsed.value } function expectNoPersistedWorkerFence(userDataDir: string, paneKey: string): void { - const persisted = readPersistedSession(userDataDir) + const persisted = readPersistedProfileState(userDataDir).workspaceSession // Keep the baseline running through reveal even when it still writes the withdrawn policy. expect - .soft(persisted.sleepingAgentSessionsByPaneKey?.[paneKey] ?? {}) - .not.toHaveProperty('automaticResumeBlockedBy') - expect.soft(persisted.legacyWorkerResumeFencesByPaneKey ?? {}).not.toHaveProperty(paneKey) + .soft(persisted) + .not.toHaveProperty(['sleepingAgentSessionsByPaneKey', paneKey, 'automaticResumeBlockedBy']) + expect.soft(persisted).not.toHaveProperty(['legacyWorkerResumeFencesByPaneKey', paneKey]) } // A restored worker must attach through main so revealing it never fabricates a missing PTY. @@ -479,7 +479,7 @@ for (const daemonSessionGone of [false, true]) { const paneKeys = await second.page.evaluate((tabId) => { const layout = window.__store?.getState().terminalLayoutsByTabId[tabId] const leaves: string[] = [] - const visit = (node: NonNullable['root']) => { + const visit = (node: NonNullable['root']>) => { if (node.type === 'leaf') { leaves.push(`${tabId}:${node.leafId}`) } else { @@ -514,7 +514,7 @@ for (const daemonSessionGone of [false, true]) { expect(persisted.terminalLayoutsByTabId[workerTabId]).toBeDefined() if (!daemonSessionGone) { expect( - Object.values(persisted.terminalLayoutsByTabId[workerTabId].ptyIdsByLeafId) + Object.values(persisted.terminalLayoutsByTabId[workerTabId].ptyIdsByLeafId ?? {}) ).toContain(workerPtyId) } } finally { diff --git a/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts b/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts index 83a7ae66f652..7939320d666a 100644 --- a/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts +++ b/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts @@ -1,8 +1,6 @@ -import path from 'node:path' -import { readFileSync } from 'node:fs' +import { readPersistedProfileState } from './helpers/persisted-profile-state' import type { ElectronApplication } from '@playwright/test' import { test, expect } from './helpers/orca-app' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import { sshRemotePtyLeaseAllowsReattach, type SshRemotePtyLease } from '../../src/shared/ssh-types' import { toRelaySshPtyId } from '../../src/shared/ssh-pty-id' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' @@ -60,13 +58,8 @@ const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' * drift from the fan-out it exists to bound. */ function readSshLeases(userDataDir: string, targetId: string): SshRemotePtyLease[] { - const dataPath = path.join( - userDataDir, - 'profiles', - DEFAULT_LOCAL_ORCA_PROFILE_ID, - 'orca-data.json' - ) - const parsed = JSON.parse(readFileSync(dataPath, 'utf8')) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const parsed = readPersistedProfileState(userDataDir) as { sshRemotePtyLeases?: SshRemotePtyLease[] } return (parsed.sshRemotePtyLeases ?? []).filter((lease) => lease.targetId === targetId) diff --git a/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts b/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts index 2eec7356f65a..48a0f7a118c0 100644 --- a/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts +++ b/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts @@ -1,9 +1,8 @@ +import { mutateStoppedProfileState } from './helpers/persisted-profile-state' import { randomUUID } from 'node:crypto' -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' +import { existsSync, readFileSync } from 'node:fs' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import type { TerminalLayoutSnapshot } from '../../src/shared/terminal-tab-types' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import { test, expect } from './helpers/orca-app' import { findMarkerFrame, @@ -59,39 +58,36 @@ setInterval(() => { `.trim() } -function persistedDataPath(userDataDir: string): string { - return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') -} - function seedDuplicatePtyOwnership(userDataDir: string): void { - const dataPath = persistedDataPath(userDataDir) - const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedData - const session = data.workspaceSession - const tabId = session?.activeTabId - const layout = tabId ? session?.terminalLayoutsByTabId?.[tabId] : undefined - const retainedLeafId = layout?.activeLeafId - const ptyId = retainedLeafId ? layout?.ptyIdsByLeafId?.[retainedLeafId] : undefined - if (!session?.terminalLayoutsByTabId || !tabId || !layout || !retainedLeafId || !ptyId) { - throw new Error('Persisted terminal ownership was unavailable for duplicate-layout seeding') - } + return mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = state as PersistedData + const session = data.workspaceSession + const tabId = session?.activeTabId + const layout = tabId ? session?.terminalLayoutsByTabId?.[tabId] : undefined + const retainedLeafId = layout?.activeLeafId + const ptyId = retainedLeafId ? layout?.ptyIdsByLeafId?.[retainedLeafId] : undefined + if (!session?.terminalLayoutsByTabId || !tabId || !layout || !retainedLeafId || !ptyId) { + throw new Error('Persisted terminal ownership was unavailable for duplicate-layout seeding') + } - const duplicateLeafId = randomUUID() - session.terminalLayoutsByTabId[tabId] = { - ...layout, - root: { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: retainedLeafId }, - second: { type: 'leaf', leafId: duplicateLeafId } - }, - activeLeafId: retainedLeafId, - expandedLeafId: null, - ptyIdsByLeafId: { - [retainedLeafId]: ptyId, - [duplicateLeafId]: ptyId + const duplicateLeafId = randomUUID() + session.terminalLayoutsByTabId[tabId] = { + ...layout, + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: retainedLeafId }, + second: { type: 'leaf', leafId: duplicateLeafId } + }, + activeLeafId: retainedLeafId, + expandedLeafId: null, + ptyIdsByLeafId: { + [retainedLeafId]: ptyId, + [duplicateLeafId]: ptyId + } } - } - writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`) + }) } async function waitForRestoredTerminal(page: Page, worktreeId: string): Promise { diff --git a/tests/e2e/terminal-restart-persistence.spec.ts b/tests/e2e/terminal-restart-persistence.spec.ts index b9cbd6a00b6e..95bc5a4dd5de 100644 --- a/tests/e2e/terminal-restart-persistence.spec.ts +++ b/tests/e2e/terminal-restart-persistence.spec.ts @@ -24,187 +24,33 @@ * - Crash/SIGKILL recovery — that is covered by daemon history checkpoints. */ -import { readFileSync, existsSync } from 'node:fs' -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { ElectronApplication } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' -import { TEST_REPO_PATH_FILE } from './global-setup' import { - discoverActivePtyId, execInTerminal, - waitForActiveTerminalManager, waitForTerminalOutput, waitForPaneCount, getTerminalContent, splitActiveTerminalPane } from './helpers/terminal' -import { - waitForSessionReady, - waitForActiveWorktree, - getActiveWorktreeId, - getActiveTabId, - getWorktreeTabs, - ensureTerminalVisible -} from './helpers/store' -import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { getActiveTabId, getWorktreeTabs } from './helpers/store' +import { createRestartSession } from './helpers/orca-restart' import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' - -const REQUIRE_WINDOWS_TERMINAL_RESTART_E2E = - process.env.ORCA_REQUIRE_WINDOWS_TERMINAL_RESTART_E2E === '1' -const MISSING_SEEDED_REPO_MESSAGE = 'Global setup did not produce a seeded test repo' - -// Why: each test in this file does a full quit→relaunch cycle, which spawns -// two Electron instances back-to-back. Running in serial keeps the isolated -// userDataDirs from competing for the same Electron cache lock on cold start -// and keeps the failure mode interpretable when something goes wrong. +import { + seededRepoPathOrSkip, + bootstrapFirstLaunch, + bootstrapRestoredLaunch, + waitForTerminalActiveLine, + readTerminalActiveLine, + setPaneTitleFromTerminalMenu, + getTabCustomTitle, + expectSavedLayoutToContainTitle +} from './helpers/terminal-restart-persistence' + +// Each test performs a full quit/relaunch cycle; serialize them to avoid +// competing Electron cache locks and to keep failures interpretable. test.describe.configure({ mode: 'serial' }) -function seededRepoPathOrSkip(): string { - const repoPath = existsSync(TEST_REPO_PATH_FILE) - ? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() - : '' - const unavailable = !repoPath || !existsSync(repoPath) - if (unavailable && REQUIRE_WINDOWS_TERMINAL_RESTART_E2E) { - throw new Error('Required Windows restart E2E seeded repo is unavailable') - } - test.skip(unavailable, MISSING_SEEDED_REPO_MESSAGE) - return repoPath -} - -/** - * Shared bootstrap for a *first* launch: attach the seeded test repo, - * activate its worktree, ensure a terminal is mounted, and return the - * PTY id we can drive with `execInTerminal`. - * - * Why: every test in this file needs the exact same starting state on the - * first launch. Inlining it would obscure the thing each test is actually - * asserting about the *second* launch. - */ -async function bootstrapFirstLaunch( - page: Page, - repoPath: string -): Promise<{ worktreeId: string; ptyId: string }> { - const worktreeId = await attachRepoAndOpenTerminal(page, repoPath) - await waitForSessionReady(page) - await waitForActiveWorktree(page) - await ensureTerminalVisible(page) - - const hasPaneManager = await waitForActiveTerminalManager(page, 30_000) - .then(() => true) - .catch(() => false) - if (!hasPaneManager && REQUIRE_WINDOWS_TERMINAL_RESTART_E2E) { - throw new Error('Required Windows restart E2E TerminalPane manager did not mount') - } - test.skip( - !hasPaneManager, - 'Electron automation in this environment never mounts the TerminalPane manager, so restart-persistence assertions would only fail on harness setup.' - ) - await waitForPaneCount(page, 1, 30_000) - - const ptyId = await discoverActivePtyId(page) - return { worktreeId, ptyId } -} - -/** - * Shared bootstrap for a *second* launch: just wait for the session to - * restore, and confirm the previously-active worktree is the active one - * again so downstream assertions operate against the right worktree. - */ -async function bootstrapRestoredLaunch(page: Page, expectedWorktreeId: string): Promise { - await waitForSessionReady(page) - await expect - .poll(async () => getActiveWorktreeId(page), { timeout: 10_000 }) - .toBe(expectedWorktreeId) - await ensureTerminalVisible(page) - // Why: the PaneManager remounts asynchronously after session hydration. The - // restored terminal surface is what we're about to assert against, so make - // sure it exists before any content/layout assertion races. - await waitForActiveTerminalManager(page, 30_000) - await waitForPaneCount(page, 1, 30_000) -} - -async function setPaneTitleFromTerminalMenu(page: Page, title: string): Promise { - const modifiers: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = - process.platform === 'win32' ? ['Control'] : [] - await page - .locator('.xterm:visible') - .first() - .click({ button: 'right', position: { x: 40, y: 40 }, modifiers }) - await page.getByText('Set Title…', { exact: true }).click() - const titleInput = page.locator('.pane-title-input').first() - await expect(titleInput).toBeVisible() - await titleInput.fill(title) - await titleInput.press('Enter') -} - -async function getTabCustomTitle( - page: Page, - worktreeId: string, - tabId: string -): Promise { - return page.evaluate( - ({ targetWorktreeId, targetTabId }) => { - const state = window.__store!.getState() - const tab = (state.tabsByWorktree[targetWorktreeId] ?? []).find( - (entry) => entry.id === targetTabId - ) - return tab?.customTitle ?? null - }, - { targetWorktreeId: worktreeId, targetTabId: tabId } - ) -} - -async function readTerminalActiveLine(page: Page): Promise { - const tabId = await getActiveTabId(page) - if (!tabId) { - return null - } - return page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const buffer = pane?.terminal?.buffer.active - if (!buffer) { - return null - } - const cursorLine = buffer.baseY + buffer.cursorY - return buffer.getLine(cursorLine)?.translateToString(true) ?? null - }, tabId) -} - -async function waitForTerminalActiveLine(page: Page, expectedText: string): Promise { - await expect - .poll(async () => (await readTerminalActiveLine(page))?.includes(expectedText), { - timeout: 15_000, - message: `Terminal cursor line did not contain "${expectedText}"` - }) - .toBe(true) - - const activeLine = await readTerminalActiveLine(page) - if (activeLine === null) { - throw new Error('Terminal cursor line disappeared after settling') - } - return activeLine -} - -async function expectSavedLayoutToContainTitle( - page: Page, - tabId: string, - title: string -): Promise { - await expect - .poll( - () => - page.evaluate( - ({ targetTabId, title }) => { - const layout = window.__store!.getState().terminalLayoutsByTabId[targetTabId] - return Object.values(layout?.titlesByLeafId ?? {}).includes(title) - }, - { targetTabId: tabId, title } - ), - { timeout: 3_000 } - ) - .toBe(true) -} - test.describe('Terminal restart persistence', () => { test('scrollback survives clean quit and relaunch', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. {}, testInfo) => { diff --git a/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts b/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts index 50c3d153101c..26ce06e0da8e 100644 --- a/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts +++ b/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts @@ -1,7 +1,9 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' +import { + readPersistedProfileState, + mutateStoppedProfileState +} from './helpers/persisted-profile-state' +import { existsSync, readFileSync } from 'node:fs' import type { ElectronApplication, Page } from '@stablyai/playwright-test' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import { test, expect } from './helpers/orca-app' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { ensureTerminalVisible, getActiveWorktreeId, waitForSessionReady } from './helpers/store' @@ -18,34 +20,32 @@ type PersistedData = { } } -function persistedDataPath(userDataDir: string): string { - return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') -} - function injectTruncatedTab(userDataDir: string, worktreeId: string, startupCwd: string): void { - const dataPath = persistedDataPath(userDataDir) - const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedData - const tabs = data.workspaceSession?.tabsByWorktree?.[worktreeId] - if (!tabs) { - throw new Error('Persisted terminal tabs were unavailable for corruption seeding') - } - tabs.push({ - id: CORRUPT_TAB_ID, - ptyId: null, - worktreeId, - title: 'Truncated terminal', - sortOrder: 999, - generation: 3, - startupCwd + return mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = state as PersistedData + const tabs = data.workspaceSession?.tabsByWorktree?.[worktreeId] + if (!tabs) { + throw new Error('Persisted terminal tabs were unavailable for corruption seeding') + } + tabs.push({ + id: CORRUPT_TAB_ID, + ptyId: null, + worktreeId, + title: 'Truncated terminal', + sortOrder: 999, + generation: 3, + startupCwd + }) }) - writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`) } function persistedSessionEvidence( userDataDir: string, worktreeId: string ): { corruptLegacyTabPresent: boolean; unifiedTabIds: string[] } { - const data = JSON.parse(readFileSync(persistedDataPath(userDataDir), 'utf8')) as PersistedData + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = readPersistedProfileState(userDataDir) as PersistedData const legacyTabs = data.workspaceSession?.tabsByWorktree?.[worktreeId] ?? [] const unifiedTabs = data.workspaceSession?.unifiedTabs?.[worktreeId] ?? [] return { From 95f5e46f9d3466f1e5f2ae6dd560f633e8b31c29 Mon Sep 17 00:00:00 2001 From: m4air Date: Wed, 23 Sep 2026 13:38:53 -0700 Subject: [PATCH 02/43] Reduce SQLite profile allocations and write only transferred domains Share fully checked streaming reads across snapshots and backup/recovery, and produce checkpoint strings through the existing secret serializer. Copy and move only changed domains with fenced writes and compatible versioned crash-recovery intents. Preserve schema, hashes and durability. --- .../profile-active-transfer.test.ts | 17 +- .../profile-project-domain-changes.ts | 144 +++++ .../profile-project-domain-move-intent.ts | 74 +++ .../profile-project-domain-state.ts | 84 +++ .../profile-project-domain-transfer.test.ts | 521 ++++++++++++++++++ .../profile-project-move-intent.ts | 127 ++--- .../profile-project-state-file.ts | 11 +- ...profile-project-transfer-migration.test.ts | 6 +- .../profile-project-transfer-migration.ts | 11 +- .../profile-project-transfer.test.ts | 37 +- .../orca-profiles/profile-project-transfer.ts | 80 ++- .../profile-state-authority-writes.ts | 4 +- .../secret-sentinel-substitution.test.ts | 67 ++- .../secret-sentinel-substitution.ts | 91 +-- .../profile-state-automation-runs-reader.ts | 87 +-- .../profile-state/profile-state-backup-job.ts | 4 +- .../profile-state-database-recovery.ts | 8 +- .../profile-state-document-reader.ts | 43 +- .../profile-state-document-validation.ts | 3 +- .../profile-state/profile-state-documents.ts | 9 + .../profile-state-domain-reader.test.ts | 34 +- .../profile-state-domain-reader.ts | 19 +- .../profile-state-fragment-validation.test.ts | 8 +- .../profile-state-parsed-snapshot.test.ts | 5 +- .../profile-state-read-concurrency.test.ts | 24 +- ...profile-state-streaming-validation.test.ts | 145 +++++ 26 files changed, 1443 insertions(+), 220 deletions(-) create mode 100644 src/main/orca-profiles/profile-project-domain-changes.ts create mode 100644 src/main/orca-profiles/profile-project-domain-move-intent.ts create mode 100644 src/main/orca-profiles/profile-project-domain-state.ts create mode 100644 src/main/orca-profiles/profile-project-domain-transfer.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-streaming-validation.test.ts diff --git a/src/main/orca-profiles/profile-active-transfer.test.ts b/src/main/orca-profiles/profile-active-transfer.test.ts index ecb5562bf02f..f6966ca487cf 100644 --- a/src/main/orca-profiles/profile-active-transfer.test.ts +++ b/src/main/orca-profiles/profile-active-transfer.test.ts @@ -8,6 +8,7 @@ import { openProfileStateDatabase } from '../persistence/profile-state/profile-s import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents' import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' import * as stateFiles from './profile-project-state-file' +import * as domainState from './profile-project-domain-state' import * as moveIntents from './profile-project-move-intent' import { transferActiveProfileProject } from './profile-active-transfer' import { transferOrcaProfileProject } from './profile-project-transfer' @@ -63,13 +64,15 @@ function openStore() { } function interruptSourceCommit() { - const originalWrite = stateFiles.writeProfileState - return vi.spyOn(stateFiles, 'writeProfileState').mockImplementation((profileId, ...rest) => { - if (profileId === 'source') { - throw new Error('source commit interrupted') - } - return originalWrite(profileId, ...rest) - }) + const originalWrite = domainState.writeProfileProjectDomainChanges + return vi + .spyOn(domainState, 'writeProfileProjectDomainChanges') + .mockImplementation((profileId, ...rest) => { + if (profileId === 'source') { + throw new Error('source commit interrupted') + } + return originalWrite(profileId, ...rest) + }) } beforeEach(() => { diff --git a/src/main/orca-profiles/profile-project-domain-changes.ts b/src/main/orca-profiles/profile-project-domain-changes.ts new file mode 100644 index 000000000000..6022649d1532 --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-changes.ts @@ -0,0 +1,144 @@ +import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { + isRecord, + type ProfileStateParsedDocument +} from '../persistence/profile-state/profile-state-document-validation' +import { prepareProfileStateDomainMutation } from '../persistence/profile-state/profile-state-domain-write-validation' +import type { ProfileStateDomainMutation } from '../persistence/profile-state/profile-state-domain-writes' +import type { TransferProfileState } from './profile-project-state-file' + +export type ProfileProjectDomainDigest = { domain: string; hash: string } + +export type ProfileProjectDomainChanges = { + expectedRevision: number + before: ProfileProjectDomainDigest[] + afterHash: string + replacements: { domain: string; payload: string | null }[] +} + +export function profileProjectDomainFingerprint( + domains: readonly ProfileProjectDomainDigest[] +): string { + const pairs = domains.map(({ domain, hash }) => [domain, hash]) + pairs.sort(([left = ''], [right = '']) => (left < right ? -1 : left > right ? 1 : 0)) + return hashProfileStateJson(`orca-profile-move-domains-v2:${JSON.stringify(pairs)}`) +} + +export function profileProjectDomainDigests( + documents: readonly ProfileStateParsedDocument[] +): ProfileProjectDomainDigest[] { + return documents.map(({ domain, contentHash }) => ({ domain, hash: contentHash })) +} + +export function prepareProfileProjectDomainChanges( + revision: number, + documents: readonly ProfileStateParsedDocument[], + state: TransferProfileState +): ProfileProjectDomainChanges { + const originals = new Map(documents.map((document) => [document.domain, document])) + const replacements: ProfileProjectDomainChanges['replacements'] = [] + for (const [domain, value] of Object.entries(state)) { + const original = originals.get(domain) + // Transfer projections retain unchanged values; do not serialize unrelated history/output. + if (original && Object.is(original.value, value)) { + continue + } + const payload = JSON.stringify(value) ?? null + if ( + payload === null + ? original !== undefined + : hashProfileStateJson(payload) !== original?.contentHash + ) { + replacements.push({ domain, payload }) + } + } + for (const domain of originals.keys()) { + if (!Object.hasOwn(state, domain)) { + replacements.push({ domain, payload: null }) + } + } + const before = profileProjectDomainDigests(documents) + return { + expectedRevision: revision, + before, + afterHash: profileProjectDomainFingerprint(applyDomainDigests(before, replacements)), + replacements + } +} + +function applyDomainDigests( + before: readonly ProfileProjectDomainDigest[], + replacements: readonly ProfileStateDomainMutation[] +): ProfileProjectDomainDigest[] { + const digests = new Map(before.map(({ domain, hash }) => [domain, hash])) + for (const { domain, payload } of replacements) { + if (payload === null) { + digests.delete(domain) + } else { + digests.set(domain, hashProfileStateJson(payload)) + } + } + return [...digests].map(([domain, hash]) => ({ domain, hash })) +} + +export function validateProfileProjectDomainChanges( + value: unknown +): asserts value is ProfileProjectDomainChanges { + if ( + !isRecord(value) || + typeof value.expectedRevision !== 'number' || + !Number.isSafeInteger(value.expectedRevision) || + value.expectedRevision < 0 || + !Number.isSafeInteger(value.expectedRevision + 1) || + !Array.isArray(value.before) || + !Array.isArray(value.replacements) || + value.replacements.length === 0 || + !isHash(value.afterHash) + ) { + throw new Error('Profile move domain changes are malformed') + } + const before: ProfileProjectDomainDigest[] = [] + const domains = new Set() + for (const digest of value.before) { + if ( + !isRecord(digest) || + typeof digest.domain !== 'string' || + !isHash(digest.hash) || + domains.has(digest.domain) + ) { + throw new Error('Profile move domain manifest is malformed') + } + domains.add(digest.domain) + before.push({ domain: digest.domain, hash: digest.hash }) + } + const replacements: ProfileStateDomainMutation[] = [] + domains.clear() + for (const replacement of value.replacements) { + if ( + !isRecord(replacement) || + !isDomain(replacement.domain) || + (replacement.payload !== null && typeof replacement.payload !== 'string') || + domains.has(replacement.domain) + ) { + throw new Error('Profile move domain replacement is malformed') + } + domains.add(replacement.domain) + const mutation = { domain: replacement.domain, payload: replacement.payload } + prepareProfileStateDomainMutation(mutation) + replacements.push(mutation) + } + if ( + profileProjectDomainFingerprint(applyDomainDigests(before, replacements)) !== value.afterHash || + profileProjectDomainFingerprint(before) === value.afterHash + ) { + throw new Error('Profile move domain changes do not match their fingerprint') + } +} + +function isDomain(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function isHash(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) +} diff --git a/src/main/orca-profiles/profile-project-domain-move-intent.ts b/src/main/orca-profiles/profile-project-domain-move-intent.ts new file mode 100644 index 000000000000..cbc07cbd3370 --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-move-intent.ts @@ -0,0 +1,74 @@ +import { randomUUID } from 'node:crypto' +import type { ProfileProjectMoveIdentity } from './profile-project-move-intent' +import { + profileProjectDomainDigests, + profileProjectDomainFingerprint, + validateProfileProjectDomainChanges, + type ProfileProjectDomainChanges +} from './profile-project-domain-changes' +import { + readProfileProjectTransferState, + type ReadProfileProjectTransferResult +} from './profile-project-domain-state' + +export type ProfileProjectDomainMoveIntent = ProfileProjectMoveIdentity & { + version: 2 + source: ProfileProjectDomainChanges + target: ProfileProjectDomainChanges +} + +export function createProfileProjectDomainMoveIntent(args: { + sourceProfileId: string + targetProfileId: string + source: ProfileProjectDomainChanges + target: ProfileProjectDomainChanges +}): ProfileProjectDomainMoveIntent { + return { version: 2, id: randomUUID(), ...args } +} + +export function readProfileProjectDomainMoveState( + userDataPath: string, + intent: ProfileProjectDomainMoveIntent +): { sourceBefore: boolean; sourceAfter: boolean; targetBefore: boolean; targetAfter: boolean } { + const source = readProfileProjectTransferState(intent.sourceProfileId, userDataPath) + const target = readProfileProjectTransferState(intent.targetProfileId, userDataPath) + if (source.documents === undefined || target.documents === undefined) { + throw new Error(`Profile move ${intent.id} no longer has two SQLite participants`) + } + return { + sourceBefore: matches( + source, + intent.source.expectedRevision, + profileProjectDomainFingerprint(intent.source.before) + ), + targetBefore: matches( + target, + intent.target.expectedRevision, + profileProjectDomainFingerprint(intent.target.before) + ), + sourceAfter: matches(source, intent.source.expectedRevision + 1, intent.source.afterHash), + targetAfter: matches(target, intent.target.expectedRevision + 1, intent.target.afterHash) + } +} + +function matches( + snapshot: ReadProfileProjectTransferResult, + revision: number, + hash: string +): boolean { + return ( + snapshot.revision === revision && + snapshot.documents !== undefined && + profileProjectDomainFingerprint(profileProjectDomainDigests(snapshot.documents)) === hash + ) +} + +export function validateProfileProjectDomainMoveIntent( + value: ProfileProjectMoveIdentity & Record +): asserts value is ProfileProjectDomainMoveIntent { + if (value.version !== 2) { + throw new Error('Profile move intent is malformed') + } + validateProfileProjectDomainChanges(value.source) + validateProfileProjectDomainChanges(value.target) +} diff --git a/src/main/orca-profiles/profile-project-domain-state.ts b/src/main/orca-profiles/profile-project-domain-state.ts new file mode 100644 index 000000000000..e788b4b0def1 --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-state.ts @@ -0,0 +1,84 @@ +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../persistence/profile-state/profile-state-database' +import { + readProfileStateDocuments, + readProfileStateRevision +} from '../persistence/profile-state/profile-state-documents' +import type { ProfileStateParsedDocument } from '../persistence/profile-state/profile-state-document-validation' +import { writeProfileStateDomains } from '../persistence/profile-state/profile-state-domain-writes' +import { withProfileStateReadSnapshot } from '../persistence/profile-state/profile-state-read-snapshot' +import { getOrcaProfileStateDatabaseFile } from './profile-storage-paths' +import { + normalizeProfileProjectState, + profileStateStorage, + readProfileStateWithRevision, + type ReadProfileStateResult +} from './profile-project-state-file' +import { + validateProfileProjectDomainChanges, + type ProfileProjectDomainChanges +} from './profile-project-domain-changes' + +export type ReadProfileProjectTransferResult = ReadProfileStateResult & { + documents?: readonly ProfileStateParsedDocument[] +} + +/** Keep checked domain values for transfer without joining and reparsing the complete profile. */ +export function readProfileProjectTransferState( + profileId: string, + userDataPath: string +): ReadProfileProjectTransferResult { + if (profileStateStorage(profileId, userDataPath) === 'json') { + return readProfileStateWithRevision(profileId, userDataPath) + } + const opened = openProfileStateDatabaseReadOnly( + getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId + ) + try { + return withProfileStateReadSnapshot(opened.db, () => { + const revision = readProfileStateRevision(opened.db) + const documents = readProfileStateDocuments(opened.db, { + profileRevision: revision, + representation: 'parsed' + }) + return { + revision, + documents, + state: normalizeProfileProjectState( + Object.fromEntries(documents.map(({ domain, value }) => [domain, value])) + ) + } + }) + } finally { + opened.db.close() + } +} + +export function writeProfileProjectDomainChanges( + profileId: string, + userDataPath: string, + changes: ProfileProjectDomainChanges +): void { + validateProfileProjectDomainChanges(changes) + if (profileStateStorage(profileId, userDataPath) !== 'sqlite') { + throw new Error('Profile domain transfer requires an established SQLite participant') + } + const opened = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId + ) + try { + const result = writeProfileStateDomains(opened.db, { + expectedRevision: changes.expectedRevision, + replacements: changes.replacements.map(({ domain, payload }) => ({ domain, payload })) + }) + if (!result.changed || result.revision !== changes.expectedRevision + 1) { + throw new Error('Profile domain transfer did not commit its expected revision') + } + } finally { + opened.db.close() + } +} diff --git a/src/main/orca-profiles/profile-project-domain-transfer.test.ts b/src/main/orca-profiles/profile-project-domain-transfer.test.ts new file mode 100644 index 000000000000..e26d78623117 --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-transfer.test.ts @@ -0,0 +1,521 @@ +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildSync } from 'esbuild' +import { runProcess } from '../../shared/child-process/run-process' +import { getDefaultPersistedState } from '../../shared/constants' +import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' +import type { Repo } from '../../shared/repo-types' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateSnapshot +} from '../persistence/profile-state/profile-state-documents' +import { writeProfileStateDomain } from '../persistence/profile-state/profile-state-domain-writes' +import { + prepareProfileProjectDomainChanges, + profileProjectDomainFingerprint, + validateProfileProjectDomainChanges +} from './profile-project-domain-changes' +import * as domainState from './profile-project-domain-state' +import { createProfileProjectDomainMoveIntent } from './profile-project-domain-move-intent' +import { + persistProfileProjectMoveIntent, + recoverPendingProfileProjectMoves +} from './profile-project-move-intent' +import { normalizeProfileProjectState } from './profile-project-state-file' +import { removeSourceRepo } from './profile-project-source-removal' +import { + applyPayloadToTarget, + createTargetRepo, + createTransferPayload +} from './profile-project-transfer-payload' +import { transferOrcaProfileProject } from './profile-project-transfer' + +let root: string +let crashRoot: string +let crashBundle: string +let crashScript: string +const repo: Repo = { + id: 'repo-1', + path: '/project', + displayName: 'Project', + badgeColor: 'neutral', + addedAt: 1, + kind: 'git', + connectionId: null +} + +function dbPath(id: string): string { + return join(root, 'profiles', id, 'profile-state.db') +} + +function withDatabase( + id: string, + action: (db: ReturnType['db']) => T +): T { + const opened = openProfileStateDatabase(dbPath(id), id) + try { + return action(opened.db) + } finally { + opened.db.close() + } +} + +function seed(id: string, repos: Repo[] = []): void { + mkdirSync(join(root, 'profiles', id), { recursive: true }) + withDatabase(id, (db) => + importProfileStateJson( + db, + JSON.stringify({ + futureOpaque: { z: ['\ud800', null, id], a: 'x'.repeat(100_000) }, + ['']: { keep: true }, + ['__proto__']: { inert: true }, + settings: { opencodeSessionCookie: 'enc:v1:sealed-inactive', unknownSetting: [2, 1] }, + repos, + projects: null, + projectHostSetups: null, + workspaceSessionsByHostId: null, + automationRuns: [], + futureNull: null, + futureDelete: { remove: true } + }) + ) + ) +} + +function raw(id: string) { + return withDatabase(id, (db) => readProfileStateSnapshot(db)) +} + +function transfer(mode: 'copy' | 'move' = 'move') { + return transferOrcaProfileProject( + { sourceProfileId: 'source', targetProfileId: 'target', repoId: repo.id, mode }, + root + ) +} + +function frozen(value: T): T { + if (value !== null && typeof value === 'object') { + for (const nested of Object.values(value)) { + frozen(nested) + } + Object.freeze(value) + } + return value +} + +function preparedMove() { + const source = domainState.readProfileProjectTransferState('source', root) + const target = domainState.readProfileProjectTransferState('target', root) + if ( + source.revision === undefined || + target.revision === undefined || + !source.documents || + !target.documents + ) { + throw new Error('Missing SQL fixture') + } + const sourceRepo = source.state.repos[0] + if (!sourceRepo) { + throw new Error('Missing source repo') + } + const targetRepo = createTargetRepo(sourceRepo, target.state, false) + const payload = createTransferPayload({ + sourceState: source.state, + sourceRepo, + targetRepo, + includeSessions: true + }) + const sourceAfter = removeSourceRepo(source.state, sourceRepo.id) + const targetAfter = applyPayloadToTarget(target.state, payload) + const intent = createProfileProjectDomainMoveIntent({ + sourceProfileId: 'source', + targetProfileId: 'target', + source: prepareProfileProjectDomainChanges(source.revision, source.documents, sourceAfter), + target: prepareProfileProjectDomainChanges(target.revision, target.documents, targetAfter) + }) + return { intent, sourceAfter, targetAfter } +} + +beforeAll(() => { + crashRoot = mkdtempSync(join(tmpdir(), 'orca-domain-move-crash-api-')) + crashBundle = join(crashRoot, 'api.cjs') + crashScript = join(crashRoot, 'crash.cjs') + buildSync({ + stdin: { + contents: + "export { transferOrcaProfileProject } from './src/main/orca-profiles/profile-project-transfer'", + loader: 'ts', + resolveDir: process.cwd() + }, + outfile: crashBundle, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external' + }) + writeFileSync( + crashScript, + ` +const fs = require('node:fs') +const path = require('node:path') +const [bundle, root, stage] = process.argv.slice(2) +const barrier = label => { + if (label !== stage) return + fs.writeSync(1, label + '\\n') + process.kill(process.pid, 'SIGKILL') + throw new Error('SIGKILL returned') +} +const sqlite = require('node:sqlite') +const exec = sqlite.DatabaseSync.prototype.exec +const writers = new WeakSet() +sqlite.DatabaseSync.prototype.exec = function(sql) { + const writing = writers.has(this) + const participant = writing ? this.prepare("SELECT value FROM profile_state_meta WHERE key = 'profile_id'").get().value : '' + if (writing && sql === 'COMMIT') barrier(participant + '-before-commit') + const result = exec.call(this, sql) + if (sql === 'BEGIN IMMEDIATE') writers.add(this) + if (sql === 'COMMIT' || sql === 'ROLLBACK') writers.delete(this) + if (writing && sql === 'COMMIT') barrier(participant + '-committed') + return result +} +let published = false +let removed = false +const rename = fs.renameSync +fs.renameSync = (from, to) => { + const intent = path.dirname(to) === path.join(root, 'profile-move-intents') && to.endsWith('.json') + if (intent) barrier('intent-before-publish') + rename(from, to) + if (intent) { published = true; barrier('intent-published') } +} +const rm = fs.rmSync +fs.rmSync = (target, ...rest) => { + const intent = path.dirname(target) === path.join(root, 'profile-move-intents') && target.endsWith('.json') + if (intent) barrier('cleanup-before-remove') + rm(target, ...rest) + if (intent) { removed = true; barrier('cleanup-removed') } +} +const fsync = fs.fsyncSync +fs.fsyncSync = fd => { + fsync(fd) + if (fs.fstatSync(fd).isDirectory()) { + if (removed) barrier('cleanup-durable') + else if (published) barrier('intent-durable') + } +} +require(bundle).transferOrcaProfileProject({ sourceProfileId: 'source', targetProfileId: 'target', repoId: 'repo-1', mode: 'move' }, root) +throw new Error('Crash boundary not reached: ' + stage) +` + ) +}) + +afterAll(() => rmSync(crashRoot, { recursive: true, force: true })) + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-domain-transfer-')) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: 'source', + profiles: ['source', 'target'].map((id) => ({ + id, + name: id, + avatar: { kind: 'initials', initials: id[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + })) + }) + ) + seed('source', [repo]) + seed('target') +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +describe('profile domain transfers', () => { + it.each(['copy', 'move'] as const)( + '%s preserves the full normalized projection and unchanged physical rows', + (mode) => { + const beforeSource = domainState.readProfileProjectTransferState('source', root).state + const beforeTarget = domainState.readProfileProjectTransferState('target', root).state + const physicalBefore = withDatabase('target', (db) => + db + .prepare( + "SELECT * FROM profile_state_documents WHERE domain IN ('futureOpaque', '', '__proto__', 'futureNull') ORDER BY domain" + ) + .all() + ) + const result = transfer(mode) + expect(result.status).toBe('transferred') + const afterTarget = JSON.parse(raw('target').json) + const targetRepo: Repo = afterTarget.repos[0] + const payload = createTransferPayload({ + sourceState: beforeSource, + sourceRepo: repo, + targetRepo, + includeSessions: mode === 'move' + }) + expect(afterTarget).toEqual( + JSON.parse(JSON.stringify(applyPayloadToTarget(beforeTarget, payload))) + ) + expect(afterTarget.settings.opencodeSessionCookie).toBe('enc:v1:sealed-inactive') + expect( + withDatabase('target', (db) => + db + .prepare( + "SELECT * FROM profile_state_documents WHERE domain IN ('futureOpaque', '', '__proto__', 'futureNull') ORDER BY domain" + ) + .all() + ) + ).toEqual(physicalBefore) + expect(raw('target').revision).toBe(2) + expect(raw('source').revision).toBe(mode === 'move' ? 2 : 1) + if (mode === 'move') { + expect(JSON.parse(raw('source').json)).toEqual( + JSON.parse(JSON.stringify(removeSourceRepo(beforeSource, repo.id))) + ) + } + } + ) + + it.each(['git', 'folder', 'ssh'] as const)( + 'normalization and %s projections do not mutate raw nested values', + (kind) => { + const snapshot = domainState.readProfileProjectTransferState('source', root) + const input = Object.fromEntries( + (snapshot.documents ?? []).map(({ domain, value }) => [domain, value]) + ) + input.repos = [ + { + ...repo, + kind: kind === 'folder' ? 'folder' : 'git', + connectionId: kind === 'ssh' ? 'remote' : null + } + ] + input.projects = [ + { + id: 'old-project', + displayName: 'Previous', + badgeColor: 'neutral', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + input.workspaceSession = { + ...getDefaultPersistedState('/test').workspaceSession, + tabsByWorktree: { + 'repo-1::/project/branch': [ + { + id: 'tab', + ptyId: 'pty', + worktreeId: 'repo-1::/project/branch', + title: 'Shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + if (kind === 'ssh') { + input.workspaceSessionsByHostId = { 'runtime:remote': input.workspaceSession } + } + const before = JSON.stringify(input) + frozen(input) + const source = frozen(normalizeProfileProjectState(input)) + const target = frozen(normalizeProfileProjectState({ repos: [] })) + const sourceRepo = source.repos[0] + if (!sourceRepo) { + throw new Error('Missing source repo') + } + const payload = frozen( + createTransferPayload({ + sourceState: source, + sourceRepo, + targetRepo: createTargetRepo(sourceRepo, target, false), + includeSessions: true + }) + ) + expect(() => applyPayloadToTarget(target, payload)).not.toThrow() + expect(() => removeSourceRepo(source, sourceRepo.id)).not.toThrow() + expect(JSON.stringify(input)).toBe(before) + expect(Object.entries(source).find(([domain]) => domain === 'futureOpaque')?.[1]).toBe( + input.futureOpaque + ) + } + ) + + it('journals only changed domains and replays target-first with exact revisions', () => { + const { intent, sourceAfter, targetAfter } = preparedMove() + expect(intent.source.replacements.map(({ domain }) => domain)).not.toContain('futureOpaque') + expect(JSON.stringify(intent).length).toBeLessThan(40_000) + persistProfileProjectMoveIntent(root, intent) + domainState.writeProfileProjectDomainChanges('target', root, intent.target) + expect(recoverPendingProfileProjectMoves(root)).toBe(1) + expect(JSON.parse(raw('source').json)).toEqual(JSON.parse(JSON.stringify(sourceAfter))) + expect(JSON.parse(raw('target').json)).toEqual(JSON.parse(JSON.stringify(targetAfter))) + expect(raw('source').revision).toBe(2) + expect(raw('target').revision).toBe(2) + expect(recoverPendingProfileProjectMoves(root)).toBe(0) + }) + + it.each(['source', 'target'] as const)( + 'refuses an unrelated %s write and retains the move intent', + (participant) => { + const { intent } = preparedMove() + persistProfileProjectMoveIntent(root, intent) + domainState.writeProfileProjectDomainChanges('target', root, intent.target) + withDatabase(participant, (db) => + writeProfileStateDomain(db, { + expectedRevision: participant === 'source' ? 1 : 2, + domain: 'unrelated', + payload: 'true' + }) + ) + expect(() => recoverPendingProfileProjectMoves(root)).toThrow(/conflicts|unrecognized/) + expect(readdirSync(join(root, 'profile-move-intents'))).toContain(`${intent.id}.json`) + expect(JSON.parse(raw('source').json).repos).toHaveLength(1) + } + ) + + it('refuses independently hashed malformed unrelated data before a copy writes anything', () => { + const before = raw('target').json + withDatabase('source', (db) => + db + .prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ) + .run('null,"extra":true', hashProfileStateJson('null,"extra":true'), 'futureNull') + ) + expect(() => transfer('copy')).toThrow(/JSON/) + expect(raw('target').json).toBe(before) + }) + + it('distinguishes deletions and null while ignoring extra executable mutation options', () => { + const snapshot = domainState.readProfileProjectTransferState('target', root) + if (!snapshot.documents || snapshot.revision === undefined) { + throw new Error('Missing SQL fixture') + } + const after = { ...snapshot.state, futureDelete: undefined, futureNull: null, addedNull: null } + const changes = prepareProfileProjectDomainChanges(snapshot.revision, snapshot.documents, after) + const poisoned = { + ...changes, + automationRunsAfter: [{}], + replacements: changes.replacements.map((replacement) => ({ + ...replacement, + domainVersion: -1 + })) + } + domainState.writeProfileProjectDomainChanges('target', root, poisoned) + const saved = JSON.parse(raw('target').json) + expect(saved.futureNull).toBeNull() + expect(saved.addedNull).toBeNull() + expect(saved).not.toHaveProperty('futureDelete') + expect(saved.automationRuns).toEqual([]) + }) + + it.each(['payload', 'afterHash', 'duplicate', 'revision', 'before'] as const)( + 'rejects %s tampering before recovery modifies either participant', + (kind) => { + const { intent } = preparedMove() + persistProfileProjectMoveIntent(root, intent) + domainState.writeProfileProjectDomainChanges('target', root, intent.target) + const before = raw('source').json + if (kind === 'payload') { + intent.source.replacements[0]!.payload = 'null' + } + if (kind === 'afterHash') { + intent.source.afterHash = 'a'.repeat(64) + } + if (kind === 'duplicate') { + intent.source.replacements.push(intent.source.replacements[0]!) + } + if (kind === 'revision') { + intent.source.expectedRevision = Number.MAX_SAFE_INTEGER + } + if (kind === 'before') { + intent.source.before.push(intent.source.before[0]!) + } + writeFileSync(join(root, 'profile-move-intents', `${intent.id}.json`), JSON.stringify(intent)) + expect(() => recoverPendingProfileProjectMoves(root)).toThrow() + expect(raw('source').json).toBe(before) + expect(readFileSync(join(root, 'profile-move-intents', `${intent.id}.json`), 'utf8')).toBe( + JSON.stringify(intent) + ) + } + ) + + it('canonicalizes only domain ordering in fingerprints', () => { + const a = { domain: 'a', hash: hashProfileStateJson('{"x":1,"y":2}') } + const b = { domain: 'b', hash: hashProfileStateJson('[2,1]') } + expect(profileProjectDomainFingerprint([a, b])).toBe(profileProjectDomainFingerprint([b, a])) + expect(profileProjectDomainFingerprint([a, b])).not.toBe( + profileProjectDomainFingerprint([{ ...a, hash: hashProfileStateJson('{"y":2,"x":1}') }, b]) + ) + const { intent } = preparedMove() + expect(() => validateProfileProjectDomainChanges(intent.source)).not.toThrow() + }) + + const crashStages = [ + 'intent-before-publish', + 'intent-published', + ...(process.platform === 'win32' ? [] : ['intent-durable']), + 'target-before-commit', + 'target-committed', + 'source-before-commit', + 'source-committed', + 'cleanup-before-remove', + 'cleanup-removed', + ...(process.platform === 'win32' ? [] : ['cleanup-durable']) + ] + it.each(crashStages)('recovers exact state after actual SIGKILL at %s', async (stage) => { + const sourceBefore = raw('source').json + const targetBefore = raw('target').json + const { sourceAfter, targetAfter } = preparedMove() + const child = await runProcess({ + program: process.execPath, + args: [crashScript, crashBundle, root, stage], + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + NODE_PATH: join(process.cwd(), 'node_modules') + }, + timeoutMs: 10_000, + maxOutputBytes: 16_384 + }) + expect(child.timedOut, child.stderr).toBe(false) + expect(child.stdout, child.stderr).toBe(`${stage}\n`) + expect(child.code).not.toBe(0) + if (process.platform !== 'win32') { + expect(child.signal).toBe('SIGKILL') + } + recoverPendingProfileProjectMoves(root) + const targetCommitted = ![ + 'intent-before-publish', + 'intent-published', + 'intent-durable', + 'target-before-commit' + ].includes(stage) + expect(JSON.parse(raw('source').json)).toEqual( + targetCommitted ? JSON.parse(JSON.stringify(sourceAfter)) : JSON.parse(sourceBefore) + ) + expect(JSON.parse(raw('target').json)).toEqual( + targetCommitted ? JSON.parse(JSON.stringify(targetAfter)) : JSON.parse(targetBefore) + ) + expect(raw('source').revision).toBe(targetCommitted ? 2 : 1) + expect(raw('target').revision).toBe(targetCommitted ? 2 : 1) + expect(recoverPendingProfileProjectMoves(root)).toBe(0) + }) +}) diff --git a/src/main/orca-profiles/profile-project-move-intent.ts b/src/main/orca-profiles/profile-project-move-intent.ts index 910ac0b97dca..12740645facc 100644 --- a/src/main/orca-profiles/profile-project-move-intent.ts +++ b/src/main/orca-profiles/profile-project-move-intent.ts @@ -17,16 +17,25 @@ import { type ReadProfileStateResult } from './profile-project-state-file' import { getOrcaProfileMoveIntentDirectory } from './profile-storage-paths' +import { + readProfileProjectDomainMoveState, + validateProfileProjectDomainMoveIntent, + type ProfileProjectDomainMoveIntent +} from './profile-project-domain-move-intent' +import { writeProfileProjectDomainChanges } from './profile-project-domain-state' const PROFILE_MOVE_INTENT_VERSION = 1 const INTENT_FILE_PATTERN = /^[0-9a-f-]{36}\.json$/ const PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ -export type ProfileProjectMoveIntent = { - version: typeof PROFILE_MOVE_INTENT_VERSION +export type ProfileProjectMoveIdentity = { id: string sourceProfileId: string targetProfileId: string +} + +type ProfileProjectMoveIntentV1 = ProfileProjectMoveIdentity & { + version: typeof PROFILE_MOVE_INTENT_VERSION expectedSourceRevision: number expectedTargetRevision: number sourceBeforeHash: string @@ -37,33 +46,7 @@ export type ProfileProjectMoveIntent = { targetAfterJson: string } -export function createProfileProjectMoveIntent(args: { - sourceProfileId: string - targetProfileId: string - source: ReadProfileStateResult - target: ReadProfileStateResult - sourceAfterJson: string - targetAfterJson: string -}): ProfileProjectMoveIntent { - const sourceBeforeJson = requireSerializedSnapshot(args.source, 'source') - const targetBeforeJson = requireSerializedSnapshot(args.target, 'target') - const expectedSourceRevision = requireRevision(args.source, 'source') - const expectedTargetRevision = requireRevision(args.target, 'target') - return { - version: PROFILE_MOVE_INTENT_VERSION, - id: randomUUID(), - sourceProfileId: args.sourceProfileId, - targetProfileId: args.targetProfileId, - expectedSourceRevision, - expectedTargetRevision, - sourceBeforeHash: hashProfileStateJson(sourceBeforeJson), - targetBeforeHash: hashProfileStateJson(targetBeforeJson), - sourceAfterHash: hashProfileStateJson(args.sourceAfterJson), - targetAfterHash: hashProfileStateJson(args.targetAfterJson), - sourceAfterJson: args.sourceAfterJson, - targetAfterJson: args.targetAfterJson - } -} +export type ProfileProjectMoveIntent = ProfileProjectMoveIntentV1 | ProfileProjectDomainMoveIntent export function persistProfileProjectMoveIntent( userDataPath: string, @@ -117,16 +100,10 @@ function recoverProfileProjectMoveIntent( userDataPath: string, intent: ProfileProjectMoveIntent ): void { - const source = readProfileStateWithRevision(intent.sourceProfileId, userDataPath) - const target = readProfileStateWithRevision(intent.targetProfileId, userDataPath) - if (source.revision === undefined || target.revision === undefined) { - throw new Error(`Profile move ${intent.id} no longer has two SQLite participants`) - } - - const sourceBefore = matches(source, intent.expectedSourceRevision, intent.sourceBeforeHash) - const targetBefore = matches(target, intent.expectedTargetRevision, intent.targetBeforeHash) - const sourceAfter = matches(source, intent.expectedSourceRevision + 1, intent.sourceAfterHash) - const targetAfter = matches(target, intent.expectedTargetRevision + 1, intent.targetAfterHash) + const { sourceBefore, targetBefore, sourceAfter, targetAfter } = + intent.version === 2 + ? readProfileProjectDomainMoveState(userDataPath, intent) + : readLegacyMoveState(userDataPath, intent) if (sourceAfter && targetAfter) { removeProfileProjectMoveIntent(userDataPath, intent.id) @@ -137,9 +114,13 @@ function recoverProfileProjectMoveIntent( return } if (sourceBefore && targetAfter) { - writeSerializedProfileState(intent.sourceProfileId, userDataPath, intent.sourceAfterJson, { - expectedRevision: intent.expectedSourceRevision - }) + if (intent.version === 2) { + writeProfileProjectDomainChanges(intent.sourceProfileId, userDataPath, intent.source) + } else { + writeSerializedProfileState(intent.sourceProfileId, userDataPath, intent.sourceAfterJson, { + expectedRevision: intent.expectedSourceRevision + }) + } removeProfileProjectMoveIntent(userDataPath, intent.id) return } @@ -147,8 +128,7 @@ function recoverProfileProjectMoveIntent( throw new Error(`Profile move ${intent.id} has an unrecognized target state`) } if (targetAfter && !sourceAfter) { - // A source revision that moved independently means the intent can no longer - // be replayed safely. Leave the journal for an operator or a later repair. + // Preserve the journal when an independent write makes replay unsafe. throw new Error(`Profile move ${intent.id} conflicts with a source profile write`) } if (sourceAfter && targetBefore) { @@ -157,6 +137,21 @@ function recoverProfileProjectMoveIntent( throw new Error(`Profile move ${intent.id} has an unrecognized participant state`) } +function readLegacyMoveState(userDataPath: string, intent: ProfileProjectMoveIntentV1) { + const source = readProfileStateWithRevision(intent.sourceProfileId, userDataPath) + const target = readProfileStateWithRevision(intent.targetProfileId, userDataPath) + if (source.revision === undefined || target.revision === undefined) { + throw new Error(`Profile move ${intent.id} no longer has two SQLite participants`) + } + + return { + sourceBefore: matches(source, intent.expectedSourceRevision, intent.sourceBeforeHash), + targetBefore: matches(target, intent.expectedTargetRevision, intent.targetBeforeHash), + sourceAfter: matches(source, intent.expectedSourceRevision + 1, intent.sourceAfterHash), + targetAfter: matches(target, intent.expectedTargetRevision + 1, intent.targetAfterHash) + } +} + function matches(snapshot: ReadProfileStateResult, revision: number, hash: string): boolean { return ( snapshot.revision === revision && @@ -165,20 +160,6 @@ function matches(snapshot: ReadProfileStateResult, revision: number, hash: strin ) } -function requireSerializedSnapshot(snapshot: ReadProfileStateResult, participant: string): string { - if (snapshot.serialized === undefined) { - throw new Error(`SQLite profile move requires a serialized ${participant} snapshot`) - } - return snapshot.serialized -} - -function requireRevision(snapshot: ReadProfileStateResult, participant: string): number { - if (snapshot.revision === undefined) { - throw new Error(`SQLite profile move requires a ${participant} revision`) - } - return snapshot.revision -} - function profileProjectMoveIntentPath(userDataPath: string, intentId: string): string { if (!/^[0-9a-f-]{36}$/.test(intentId)) { throw new Error('Invalid profile move intent ID') @@ -206,21 +187,16 @@ function readProfileProjectMoveIntent(path: string): ProfileProjectMoveIntent { } function validateIntent(value: unknown): asserts value is ProfileProjectMoveIntent { - if (!isRecord(value)) { - throw new Error('Profile move intent is malformed') + validateMoveIdentity(value) + if (value.version === 2) { + validateProfileProjectDomainMoveIntent(value) + return } const intent = value const expectedSourceRevision = intent.expectedSourceRevision const expectedTargetRevision = intent.expectedTargetRevision if ( intent.version !== PROFILE_MOVE_INTENT_VERSION || - typeof intent.id !== 'string' || - !/^[0-9a-f-]{36}$/.test(intent.id) || - typeof intent.sourceProfileId !== 'string' || - typeof intent.targetProfileId !== 'string' || - !PROFILE_ID_PATTERN.test(intent.sourceProfileId) || - !PROFILE_ID_PATTERN.test(intent.targetProfileId) || - intent.sourceProfileId === intent.targetProfileId || !Number.isSafeInteger(expectedSourceRevision) || !Number.isSafeInteger(expectedTargetRevision) || typeof expectedSourceRevision !== 'number' || @@ -240,6 +216,23 @@ function validateIntent(value: unknown): asserts value is ProfileProjectMoveInte } } +function validateMoveIdentity( + value: unknown +): asserts value is ProfileProjectMoveIdentity & Record { + if ( + !isRecord(value) || + typeof value.id !== 'string' || + !/^[0-9a-f-]{36}$/.test(value.id) || + typeof value.sourceProfileId !== 'string' || + typeof value.targetProfileId !== 'string' || + !PROFILE_ID_PATTERN.test(value.sourceProfileId) || + !PROFILE_ID_PATTERN.test(value.targetProfileId) || + value.sourceProfileId === value.targetProfileId + ) { + throw new Error('Profile move intent is malformed') + } +} + function isHash(value: unknown): value is string { return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) } diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index 366c5bd2ee66..4d164dc3c9f9 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -114,11 +114,16 @@ export function readProfileStateWithRevision( } function parseProfileState(rawJson: string | undefined): TransferProfileState { - const defaults = getDefaultPersistedState(homedir()) if (rawJson === undefined) { - return structuredClone(defaults) + return structuredClone(getDefaultPersistedState(homedir())) } - const parsed: Partial = JSON.parse(rawJson) + return normalizeProfileProjectState(JSON.parse(rawJson)) +} + +export function normalizeProfileProjectState( + parsed: Partial +): TransferProfileState { + const defaults = getDefaultPersistedState(homedir()) return rebuildRepoBackedProjectState({ ...defaults, ...parsed, diff --git a/src/main/orca-profiles/profile-project-transfer-migration.test.ts b/src/main/orca-profiles/profile-project-transfer-migration.test.ts index 9c2e18228e9e..99ed6da11df1 100644 --- a/src/main/orca-profiles/profile-project-transfer-migration.test.ts +++ b/src/main/orca-profiles/profile-project-transfer-migration.test.ts @@ -23,7 +23,7 @@ import { import { transferOrcaProfileProject } from './profile-project-transfer' import { readProfileStateWithRevision } from './profile-project-state-file' import { recoverPendingProfileProjectMoves } from './profile-project-move-intent' -import * as profileProjectStateFile from './profile-project-state-file' +import * as profileProjectDomainState from './profile-project-domain-state' vi.mock('../persistence/loading-store/store', () => { throw new Error('Profile transfers must not load inactive Stores') @@ -256,9 +256,9 @@ describe('profile transfer migration', () => { (sourceJson) => { writeState('source', !sourceJson, [repo]) writeState('target', sourceJson) - const originalWrite = profileProjectStateFile.writeProfileState + const originalWrite = profileProjectDomainState.writeProfileProjectDomainChanges const write = vi - .spyOn(profileProjectStateFile, 'writeProfileState') + .spyOn(profileProjectDomainState, 'writeProfileProjectDomainChanges') .mockImplementation((profileId, ...rest) => { if (profileId === 'source') { throw new Error('source commit interrupted') diff --git a/src/main/orca-profiles/profile-project-transfer-migration.ts b/src/main/orca-profiles/profile-project-transfer-migration.ts index 759940ffe659..1b380030c653 100644 --- a/src/main/orca-profiles/profile-project-transfer-migration.ts +++ b/src/main/orca-profiles/profile-project-transfer-migration.ts @@ -1,16 +1,17 @@ import { migrateProfileStateToSqlite } from '../persistence/profile-state/profile-state-migration' import { getOrcaProfileDataFile, getOrcaProfileStateDatabaseFile } from './profile-storage-paths' +import type { ReadProfileStateResult } from './profile-project-state-file' import { - readProfileStateWithRevision, - type ReadProfileStateResult -} from './profile-project-state-file' + readProfileProjectTransferState, + type ReadProfileProjectTransferResult +} from './profile-project-domain-state' /** Adopt inactive storage without running Store's active-profile listeners or secret transforms. */ export function migrateProfileProjectTransferParticipant( profileId: string, userDataPath: string, snapshot: ReadProfileStateResult -): ReadProfileStateResult { +): ReadProfileProjectTransferResult { const migrated = migrateProfileStateToSqlite({ dataFile: getOrcaProfileDataFile(profileId, userDataPath), databaseFile: getOrcaProfileStateDatabaseFile(profileId, userDataPath), @@ -19,7 +20,7 @@ export function migrateProfileProjectTransferParticipant( serializedState: snapshot.serialized ?? '{}' }) try { - return readProfileStateWithRevision(profileId, userDataPath) + return readProfileProjectTransferState(profileId, userDataPath) } finally { migrated.authority.close() } diff --git a/src/main/orca-profiles/profile-project-transfer.test.ts b/src/main/orca-profiles/profile-project-transfer.test.ts index 4a037d8edff2..67c780f72085 100644 --- a/src/main/orca-profiles/profile-project-transfer.test.ts +++ b/src/main/orca-profiles/profile-project-transfer.test.ts @@ -26,10 +26,43 @@ import { } from '../persistence/profile-state/profile-state-documents' import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' import { - createProfileProjectMoveIntent, persistProfileProjectMoveIntent, - recoverPendingProfileProjectMoves + recoverPendingProfileProjectMoves, + type ProfileProjectMoveIntent } from './profile-project-move-intent' +import type { ReadProfileStateResult } from './profile-project-state-file' + +function createProfileProjectMoveIntent(args: { + sourceProfileId: string + targetProfileId: string + source: ReadProfileStateResult + target: ReadProfileStateResult + sourceAfterJson: string + targetAfterJson: string +}): Extract { + if ( + args.source.revision === undefined || + args.target.revision === undefined || + args.source.serialized === undefined || + args.target.serialized === undefined + ) { + throw new Error('Legacy move fixture requires two serialized SQLite snapshots') + } + return { + version: 1, + id: '11111111-1111-1111-1111-111111111111', + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + expectedSourceRevision: args.source.revision, + expectedTargetRevision: args.target.revision, + sourceBeforeHash: hashProfileStateJson(args.source.serialized), + targetBeforeHash: hashProfileStateJson(args.target.serialized), + sourceAfterHash: hashProfileStateJson(args.sourceAfterJson), + targetAfterHash: hashProfileStateJson(args.targetAfterJson), + sourceAfterJson: args.sourceAfterJson, + targetAfterJson: args.targetAfterJson + } +} const testState = { dir: '' } diff --git a/src/main/orca-profiles/profile-project-transfer.ts b/src/main/orca-profiles/profile-project-transfer.ts index f0cbb085ea0a..18e3a3e90fee 100644 --- a/src/main/orca-profiles/profile-project-transfer.ts +++ b/src/main/orca-profiles/profile-project-transfer.ts @@ -3,7 +3,13 @@ import type { TransferOrcaProfileProjectResult } from '../../shared/orca-profiles' import { getOrcaProfileListState } from './profile-index-store' -import { readProfileStateWithRevision, writeProfileState } from './profile-project-state-file' +import { writeSerializedProfileState } from './profile-project-state-file' +import { + readProfileProjectTransferState, + writeProfileProjectDomainChanges +} from './profile-project-domain-state' +import { prepareProfileProjectDomainChanges } from './profile-project-domain-changes' +import { createProfileProjectDomainMoveIntent } from './profile-project-domain-move-intent' import { removeSourceRepo } from './profile-project-source-removal' import { applyPayloadToTarget, @@ -13,7 +19,6 @@ import { import { repoPhysicalKey } from './profile-project-worktree-identity' import { migrateProfileProjectTransferParticipant } from './profile-project-transfer-migration' import { - createProfileProjectMoveIntent, persistProfileProjectMoveIntent, recoverPendingProfileProjectMoves, removeProfileProjectMoveIntent, @@ -40,15 +45,13 @@ export function transferOrcaProfileProject( ): TransferOrcaProfileProjectResult { recoverPendingProfileProjectMoves(userDataPath) assertKnownProfiles(args, userDataPath) - let sourceSnapshot = readProfileStateWithRevision(args.sourceProfileId, userDataPath) - let targetSnapshot = readProfileStateWithRevision(args.targetProfileId, userDataPath) - const sourceState = sourceSnapshot.state - const targetState = targetSnapshot.state - const sourceRepo = sourceState.repos.find((repo) => repo.id === args.repoId) + let sourceSnapshot = readProfileProjectTransferState(args.sourceProfileId, userDataPath) + let targetSnapshot = readProfileProjectTransferState(args.targetProfileId, userDataPath) + const sourceRepo = sourceSnapshot.state.repos.find((repo) => repo.id === args.repoId) if (!sourceRepo) { throw new Error('unknown_source_repo') } - const duplicate = targetState.repos.find( + const duplicate = targetSnapshot.state.repos.find( (repo) => repoPhysicalKey(repo) === repoPhysicalKey(sourceRepo) ) if (duplicate) { @@ -80,6 +83,8 @@ export function transferOrcaProfileProject( ) } + const sourceState = sourceSnapshot.state + const targetState = targetSnapshot.state const targetRepo = createTargetRepo(sourceRepo, targetState, args.mode === 'copy') const payload = createTransferPayload({ sourceState, @@ -90,30 +95,55 @@ export function transferOrcaProfileProject( const targetAfterState = applyPayloadToTarget(targetState, payload) const sourceAfterState = args.mode === 'move' ? removeSourceRepo(sourceState, sourceRepo.id) : undefined - if (sourceAfterState !== undefined && sourceSnapshot.revision !== undefined) { - moveIntent = createProfileProjectMoveIntent({ + const targetChanges = + targetSnapshot.documents !== undefined && targetSnapshot.revision !== undefined + ? prepareProfileProjectDomainChanges( + targetSnapshot.revision, + targetSnapshot.documents, + targetAfterState + ) + : undefined + const sourceChanges = + sourceAfterState !== undefined && + sourceSnapshot.documents !== undefined && + sourceSnapshot.revision !== undefined + ? prepareProfileProjectDomainChanges( + sourceSnapshot.revision, + sourceSnapshot.documents, + sourceAfterState + ) + : undefined + if (sourceChanges !== undefined) { + if (targetChanges === undefined) { + throw new Error('SQLite profile move requires two SQLite participants') + } + moveIntent = createProfileProjectDomainMoveIntent({ sourceProfileId: args.sourceProfileId, targetProfileId: args.targetProfileId, - source: sourceSnapshot, - target: targetSnapshot, - sourceAfterJson: JSON.stringify(sourceAfterState), - targetAfterJson: JSON.stringify(targetAfterState) + source: sourceChanges, + target: targetChanges }) persistProfileProjectMoveIntent(userDataPath, moveIntent) } - writeProfileState( - args.targetProfileId, - userDataPath, - targetAfterState, - targetSnapshot.revision === undefined ? {} : { expectedRevision: targetSnapshot.revision } - ) - if (sourceAfterState !== undefined) { - writeProfileState( - args.sourceProfileId, + if (targetChanges !== undefined) { + writeProfileProjectDomainChanges(args.targetProfileId, userDataPath, targetChanges) + } else { + writeSerializedProfileState( + args.targetProfileId, userDataPath, - sourceAfterState, - sourceSnapshot.revision === undefined ? {} : { expectedRevision: sourceSnapshot.revision } + JSON.stringify(targetAfterState) ) + } + if (sourceAfterState !== undefined) { + if (sourceChanges !== undefined) { + writeProfileProjectDomainChanges(args.sourceProfileId, userDataPath, sourceChanges) + } else { + writeSerializedProfileState( + args.sourceProfileId, + userDataPath, + JSON.stringify(sourceAfterState) + ) + } if (moveIntent) { removeProfileProjectMoveIntent(userDataPath, moveIntent.id) } diff --git a/src/main/persistence/loading-store/profile-state-authority-writes.ts b/src/main/persistence/loading-store/profile-state-authority-writes.ts index 78949576f5ad..a7f3b69a8e33 100644 --- a/src/main/persistence/loading-store/profile-state-authority-writes.ts +++ b/src/main/persistence/loading-store/profile-state-authority-writes.ts @@ -39,11 +39,11 @@ export function serializeCompleteProfileStateDomains( if (fragment === '{}') { continue } - const serialized = applySecretSentinelSubstitutions(fragment, substitutions, '') + const serialized = applySecretSentinelSubstitutions(fragment, substitutions, '', 'text') hash.update(serialized.stateHash) domains.push({ domain, - payload: serialized.payload.toString('utf8').slice(JSON.stringify(domain).length + 2, -1) + payload: serialized.payload.slice(JSON.stringify(domain).length + 2, -1) }) } return { diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts index b39d31871478..eaae559e77fa 100644 --- a/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts @@ -1,6 +1,6 @@ /** Full serialization retains its bytes/hash; domain serialization preserves bytes and equality. */ import { createHash, randomUUID } from 'node:crypto' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { serializeCompleteProfileStateDomains } from './profile-state-authority-writes' import { applySecretSentinelSubstitutions, @@ -32,8 +32,11 @@ function expectIdenticalToPrevious( ): void { const before = previousImplementation(serialized, subs, degradedPrefix) const after = applySecretSentinelSubstitutions(serialized, subs, degradedPrefix) + const text = applySecretSentinelSubstitutions(serialized, subs, degradedPrefix, 'text') expect(after.payload.equals(before.payload)).toBe(true) expect(after.stateHash).toBe(before.stateHash) + expect(text.payload).toBe(before.payload.toString('utf8')) + expect(text.stateHash).toBe(before.stateHash) } function sentinel(): string { @@ -97,6 +100,31 @@ describe('complete profile domain serialization', () => { .stateHash ).not.toBe(first.stateHash) }) + + it('retains unknown own keys and encodes bytes only when a complete payload is requested', () => { + const state = Object.fromEntries([ + ['9', 9], + ['3', 3], + ['z', null], + ['__proto__', { own: true }], + ['constructor', false], + ['future', { text: '雪😀\ud800', absent: undefined }] + ]) + const expected = JSON.stringify(state) + const encode = vi.spyOn(Buffer, 'from') + try { + const serialized = serializeCompleteProfileStateDomains(state, [], 'safeStorage-degraded\0') + expect(serialized.domains.map(({ domain }) => domain)).toEqual(Object.keys(state)) + expect(serialized.domains.find(({ domain }) => domain === '__proto__')?.payload).toBe( + '{"own":true}' + ) + expect(encode).not.toHaveBeenCalled() + expect(serialized.payload.toString('utf8')).toBe(expected) + expect(encode).toHaveBeenCalledExactlyOnceWith(expected, 'utf8') + } finally { + encode.mockRestore() + } + }) }) describe('applySecretSentinelSubstitutions', () => { @@ -172,6 +200,39 @@ describe('applySecretSentinelSubstitutions', () => { expect(payload.toString('utf8')).not.toContain(subs[0].sentinel) }) + it.each(['', 'safeStorage-degraded\0'])( + 'keeps the first duplicate and handles adjacent escaped sentinels with prefix %j', + (prefix) => { + const slot = 'orca-$a/.*+?^${}()|[]\\"雪' + const subs = [ + { sentinel: slot, blob: 'cipher-other-token-"\\\n雪\ud800', hashValue: 'plain-😀' }, + { sentinel: slot, blob: 'duplicate-must-not-win', hashValue: 'wrong-plain' }, + { sentinel: 'other-token', blob: 'last', hashValue: 'last-plain' } + ] + const serialized = JSON.stringify({ nested: { [slot]: `${slot}${slot}other-token` } }) + const expectedPayload = JSON.stringify({ + nested: { [subs[0].blob]: subs[0].blob + subs[0].blob + subs[2].blob } + }) + const expectedHashInput = JSON.stringify({ + nested: { [subs[0].hashValue]: subs[0].hashValue + subs[0].hashValue + subs[2].hashValue } + }) + const expectedHash = createHash('sha1').update(prefix).update(expectedHashInput).digest('hex') + for (const actual of [ + applySecretSentinelSubstitutions(serialized, subs, prefix), + applySecretSentinelSubstitutions(serialized, subs, prefix, 'text') + ]) { + expect(actual.payload.toString()).toBe(expectedPayload) + expect(actual.stateHash).toBe(expectedHash) + } + } + ) + + it('leaves domains without matching sentinels byte-identical', () => { + const serialized = JSON.stringify({ future: { output: '雪😀\ud800', present: null } }) + const subs = [{ sentinel: 'missing-slot', blob: 'cipher', hashValue: 'plain' }] + expectIdenticalToPrevious(serialized, subs, 'safeStorage-degraded\0') + }) + it('copies and UTF-8 encodes the full state once, not once per sentinel per side', () => { const subs: SecretSentinelSubstitution[] = Array.from({ length: 3 }, () => ({ sentinel: sentinel(), @@ -227,13 +288,17 @@ describe('applySecretSentinelSubstitutions', () => { const before = counted(() => previousImplementation(serialized, subs, '')) const after = counted(() => applySecretSentinelSubstitutions(serialized, subs, '')) + const text = counted(() => applySecretSentinelSubstitutions(serialized, subs, '', 'text')) // Two `String.replace` calls over the whole state per sentinel — payload and hash input. expect(before.fullStateReplaces).toBe(subs.length * 2) expect(after.fullStateReplaces).toBe(0) + expect(text.fullStateReplaces).toBe(0) // The old path encoded the state twice: once for sha1, once for the file write. expect(before.encodedChars).toBeGreaterThan(serialized.length * 1.9) expect(after.encodedChars).toBeLessThan(serialized.length * 1.1) expect(after.encodedChars).toBeGreaterThan(serialized.length * 0.9) + expect(text.encodedChars).toBeLessThan(serialized.length * 1.1) + expect(text.encodedChars).toBeGreaterThan(serialized.length * 0.9) }) }) diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.ts index afcdddafa771..30ec43eaa1d6 100644 --- a/src/main/persistence/loading-store/secret-sentinel-substitution.ts +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.ts @@ -10,69 +10,92 @@ export type SecretSentinelSubstitution = { hashValue: string } -/** - * Replace every secret sentinel in `serialized` in ONE pass, producing the on-disk bytes and the - * guard hash from the same encoded segments. - * - * Why not the obvious `payload.replace(...)` / `hashInput.replace(...)` loop it replaces: each - * `String.replace` returns a rope that the *next* `replace` has to flatten before it can search, so - * N sentinels cost 2N-1 flattened copies of the whole multi-MB state, plus one more per side when - * `hash.update` and the file write finally consume them. Measured on a 4.65 MB store with three - * sentinels: 7 full-state string allocations, 62 MB of V8 heap, 27 MB of it in large_object_space. - * - * Here the state is walked once, each literal run is UTF-8 encoded exactly once, and those same - * buffers feed both the payload and the hash — 1 full-state string, 1 encode. - * - * Byte-for-byte identical output to the loop: both sides read the sentinel in its JSON-escaped - * form, the replacements are the JSON-escaped `blob`/`hashValue`, and the hash sees the same byte - * sequence it saw when it was handed one concatenated string. - */ +type SecretSubstitutionOutput = { + encode: (value: string) => T + concat: (chunks: T[]) => T +} + +const bufferOutput: SecretSubstitutionOutput = { + encode: (value) => Buffer.from(value, 'utf8'), + concat: (chunks) => Buffer.concat(chunks) +} + +const textOutput: SecretSubstitutionOutput = { + encode: (value) => value, + concat: (chunks) => chunks.join('') +} + +/** One traversal keeps ciphertext and guard hashes aligned without copying once per secret. */ +export function applySecretSentinelSubstitutions( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string, + output?: 'buffer' +): { payload: Buffer; stateHash: string } export function applySecretSentinelSubstitutions( serialized: string, substitutions: readonly SecretSentinelSubstitution[], - degradedPrefix: string -): { payload: Buffer; stateHash: string } { + degradedPrefix: string, + output: 'text' +): { payload: string; stateHash: string } +export function applySecretSentinelSubstitutions( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string, + output: 'buffer' | 'text' = 'buffer' +): { payload: Buffer | string; stateHash: string } { + return output === 'text' + ? substituteSentinels(serialized, substitutions, degradedPrefix, textOutput) + : substituteSentinels(serialized, substitutions, degradedPrefix, bufferOutput) +} + +function substituteSentinels( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string, + output: SecretSubstitutionOutput +): { payload: T; stateHash: string } { const hash = createHash('sha1').update(degradedPrefix) if (substitutions.length === 0) { - const payload = Buffer.from(serialized, 'utf8') + const payload = output.encode(serialized) return { payload, stateHash: hash.update(payload).digest('hex') } } - const replacementBySentinel = new Map() + const replacementBySentinel = new Map() const alternatives: string[] = [] for (const { sentinel, blob, hashValue } of substitutions) { - // Preserved from the loop this replaces: both the search key and the replacements are the - // JSON-escaped forms, because that is what `serialized` actually contains. + // Match escaped JSON contents, including quotes and backslashes inside a secret. const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) if (replacementBySentinel.has(escapedSentinel)) { continue } alternatives.push(escapeRegex(escapedSentinel)) replacementBySentinel.set(escapedSentinel, { - blob: Buffer.from(JSON.stringify(blob).slice(1, -1), 'utf8'), - hashValue: Buffer.from(JSON.stringify(hashValue).slice(1, -1), 'utf8') + blob: output.encode(JSON.stringify(blob).slice(1, -1)), + hashValue: output.encode(JSON.stringify(hashValue).slice(1, -1)) }) } - // Global, though a sentinel is a UUID minted after the state was assembled and so occurs exactly - // once: a single pass that substitutes every occurrence cannot leave one behind on disk. + // Substitute every occurrence so a repeated sentinel cannot survive on disk. const pattern = new RegExp(alternatives.join('|'), 'g') - const chunks: Buffer[] = [] + const chunks: T[] = [] let cursor = 0 let match: RegExpExecArray | null while ((match = pattern.exec(serialized)) !== null) { - // Non-null: the alternation is built from exactly the map's keys. - const replacement = replacementBySentinel.get(match[0])! - // A sliced substring, so this does not copy the state; the encode below is its only pass. - const literal = Buffer.from(serialized.slice(cursor, match.index), 'utf8') + const replacement = replacementBySentinel.get(match[0]) + if (replacement === undefined) { + throw new Error('Secret substitution matched an unregistered sentinel') + } + // The Buffer output reuses each literal's UTF-8 bytes for both the payload and hash. + const literal = output.encode(serialized.slice(cursor, match.index)) chunks.push(literal, replacement.blob) hash.update(literal) hash.update(replacement.hashValue) cursor = match.index + match[0].length } - const tail = Buffer.from(serialized.slice(cursor), 'utf8') + const tail = output.encode(serialized.slice(cursor)) chunks.push(tail) hash.update(tail) - return { payload: Buffer.concat(chunks), stateHash: hash.digest('hex') } + return { payload: output.concat(chunks), stateHash: hash.digest('hex') } } diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts b/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts index 6246f71a0c52..c8e4087a1a8c 100644 --- a/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts +++ b/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts @@ -5,7 +5,8 @@ import { hashProfileStatePayload, ProfileStateDocumentCorruptionError, type ProfileStateDocument, - type ProfileStateParsedDocument + type ProfileStateParsedDocument, + type ProfileStateValidatedDocument } from './profile-state-document-validation' import { AUTOMATION_RUNS_ABSENT, @@ -22,6 +23,12 @@ import { import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' +type Representation = 'serialized' | 'parsed' | 'validated' +type ReadDocument = + | ProfileStateDocument + | ProfileStateParsedDocument + | ProfileStateValidatedDocument + /** Undefined means explicit document storage; null means the domain is absent. */ export function readProfileStateAutomationRunsDocument( db: Database.Database, @@ -32,11 +39,16 @@ export function readProfileStateAutomationRunsDocument( profileRevision: number, representation: 'parsed' ): ProfileStateParsedDocument | null | undefined +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision: number, + representation: 'validated' +): ProfileStateValidatedDocument | null | undefined export function readProfileStateAutomationRunsDocument( db: Database.Database, profileRevision = readProfileStateRevision(db), - representation: 'serialized' | 'parsed' = 'serialized' -): ProfileStateDocument | ProfileStateParsedDocument | null | undefined { + representation: Representation = 'serialized' +): ReadDocument | null | undefined { const meta = readCurrentAutomationRunsState(db, profileRevision) if (meta.presence === AUTOMATION_RUNS_DOCUMENT) { return undefined @@ -54,26 +66,33 @@ export function readProfileStateAutomationRunsDocument( } assertNoNormalizedAutomationRuns(db) return makeAutomationRunsDocument( - 'null', meta, - representation === 'parsed' ? { value: null } : undefined + representation === 'validated' + ? {} + : representation === 'parsed' + ? { value: null } + : { payload: 'null' } ) } - // Sort references instead of copying large payloads into SQLite's temporary sort table. - const parsedRows = db - .prepare( - `SELECT run_id, ordinal, payload, content_hash, revision, updated_at FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE}` - ) - .all() - .map((row) => parseNormalizedAutomationRunRow(row, representation === 'parsed')) - .sort((left, right) => left.ordinal - right.ordinal) + // Sort only stable keys; payloads stay outside the sorter and extra columns cannot shadow the key. + const references = db.prepare( + `SELECT run_id FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} ORDER BY ordinal` + ) + const row = db.prepare( + `SELECT run_id, ordinal, payload, content_hash, revision, updated_at FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} WHERE run_id = ?` + ) const payloads: string[] = [] const values: unknown[] = [] - const aggregate = representation === 'parsed' ? createHash('sha256').update('[') : undefined + const aggregate = createHash('sha256').update('[') const ids = new Set() - parsedRows.forEach((parsed, index) => { - if (parsed.ordinal !== index || ids.has(parsed.id)) { + let index = 0 + for (const reference of references.iterate()) { + const parsed = parseNormalizedAutomationRunRow( + row.get(reference.run_id), + representation === 'parsed' + ) + if (parsed.id !== reference.run_id || parsed.ordinal !== index || ids.has(parsed.id)) { throw new ProfileStateDocumentCorruptionError( 'Normalized automationRuns ordering is corrupt', AUTOMATION_RUNS_DOMAIN @@ -89,37 +108,41 @@ export function readProfileStateAutomationRunsDocument( ) } ids.add(parsed.id) - if (aggregate) { - if (index > 0) { - aggregate.update(',') - } - aggregate.update(parsed.payload, 'utf8') + if (index > 0) { + aggregate.update(',') + } + aggregate.update(parsed.payload, 'utf8') + if (representation === 'parsed') { values.push(parsed.value) - } else { + } else if (representation === 'serialized') { payloads.push(parsed.payload) } - }) - const payload = aggregate ? '' : `[${payloads.join(',')}]` - const contentHash = aggregate - ? aggregate.update(']').digest('hex') - : hashProfileStatePayload(payload) + index++ + } + const contentHash = aggregate.update(']').digest('hex') if (contentHash !== meta.contentHash) { throw new ProfileStateDocumentCorruptionError( 'Normalized automationRuns aggregate hash mismatch', AUTOMATION_RUNS_DOMAIN ) } - return makeAutomationRunsDocument(payload, meta, aggregate ? { value: values } : undefined) + return makeAutomationRunsDocument( + meta, + representation === 'validated' + ? {} + : representation === 'parsed' + ? { value: values } + : { payload: `[${payloads.join(',')}]` } + ) } function makeAutomationRunsDocument( - payload: string, meta: AutomationRunsMeta, - parsed?: { value: unknown } -): ProfileStateDocument | ProfileStateParsedDocument { + content: { payload: string } | { value: unknown } | Record +): ReadDocument { return { domain: AUTOMATION_RUNS_DOMAIN, - ...(parsed ?? { payload }), + ...content, domainVersion: meta.domainVersion, revision: meta.revision, updatedAt: meta.updatedAt, diff --git a/src/main/persistence/profile-state/profile-state-backup-job.ts b/src/main/persistence/profile-state/profile-state-backup-job.ts index 5adb541ee52c..ef6e068aa7db 100644 --- a/src/main/persistence/profile-state/profile-state-backup-job.ts +++ b/src/main/persistence/profile-state/profile-state-backup-job.ts @@ -1,6 +1,6 @@ import { openProfileStateDatabaseReadOnly } from './profile-state-database' import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' -import { readProfileStateParsedSnapshot } from './profile-state-documents' +import { validateProfileStateSnapshot } from './profile-state-documents' export type ProfileStateBackupJob = { databasePath: string @@ -24,7 +24,7 @@ export async function writeProfileStateBackup(job: ProfileStateBackupJob): Promi function validateProfileStateBackup(path: string, profileId: string): void { const snapshot = openProfileStateDatabaseReadOnly(path, profileId) try { - readProfileStateParsedSnapshot(snapshot.db) + validateProfileStateSnapshot(snapshot.db) } finally { snapshot.db.close() } diff --git a/src/main/persistence/profile-state/profile-state-database-recovery.ts b/src/main/persistence/profile-state/profile-state-database-recovery.ts index d2beb1370434..aaacb912a9c1 100644 --- a/src/main/persistence/profile-state/profile-state-database-recovery.ts +++ b/src/main/persistence/profile-state/profile-state-database-recovery.ts @@ -5,7 +5,7 @@ import { durableWriteTempPath, renameDurableSync } from '../../durable-file-writ import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' import { openProfileStateDatabaseReadOnly } from './profile-state-database' -import { readProfileStateSnapshot } from './profile-state-documents' +import { validateProfileStateSnapshot } from './profile-state-documents' import { profileStateDatabaseBackups, profileStateDatabaseBackupFiles @@ -101,11 +101,11 @@ function validateRecoverySnapshot(path: string, profileId: string): number { if (opened.db.pragma('journal_mode', { simple: true }) !== 'delete') { throw new Error('Profile state database backup must use a self-contained journal mode') } - const snapshot = readProfileStateSnapshot(opened.db) - if (snapshot.revision === 0) { + const revision = validateProfileStateSnapshot(opened.db) + if (revision === 0) { throw new Error('Profile state database backup contains no committed profile state') } - return snapshot.revision + return revision } finally { opened.db.close() } diff --git a/src/main/persistence/profile-state/profile-state-document-reader.ts b/src/main/persistence/profile-state/profile-state-document-reader.ts index b28c0eba245c..075e4f6dc060 100644 --- a/src/main/persistence/profile-state/profile-state-document-reader.ts +++ b/src/main/persistence/profile-state/profile-state-document-reader.ts @@ -8,7 +8,8 @@ import { ProfileStateDocumentCorruptionError, validateProfileStateDocumentRow, type ProfileStateDocument, - type ProfileStateParsedDocument + type ProfileStateParsedDocument, + type ProfileStateValidatedDocument } from './profile-state-document-validation' export type ReadProfileStateDocumentsOptions = { @@ -27,20 +28,33 @@ export function readProfileStateDocuments( ): readonly ProfileStateDocument[] export function readProfileStateDocuments( db: Database.Database, - options: ReadProfileStateDocumentsOptions & { representation?: 'parsed' } = {} -): readonly (ProfileStateDocument | ProfileStateParsedDocument)[] { + options: ReadProfileStateDocumentsOptions & { representation: 'validated' } +): void +export function readProfileStateDocuments( + db: Database.Database, + options: ReadProfileStateDocumentsOptions & { representation?: 'parsed' | 'validated' } = {} +): + | readonly (ProfileStateDocument | ProfileStateParsedDocument | ProfileStateValidatedDocument)[] + | void { const profileRevision = options.profileRevision ?? readProfileStateRevision(db) const normalized = - options.representation === 'parsed' - ? readProfileStateAutomationRunsDocument(db, profileRevision, 'parsed') - : readProfileStateAutomationRunsDocument(db, profileRevision) + options.representation === 'validated' + ? readProfileStateAutomationRunsDocument(db, profileRevision, 'validated') + : options.representation === 'parsed' + ? readProfileStateAutomationRunsDocument(db, profileRevision, 'parsed') + : readProfileStateAutomationRunsDocument(db, profileRevision) const rows = db .prepare( `SELECT domain, payload, domain_version, revision, updated_at, content_hash FROM profile_state_documents ORDER BY rowid` ) - .all() - const documents = rows.map((row): ProfileStateDocument | ProfileStateParsedDocument => { + .iterate() + const documents: ( + | ProfileStateDocument + | ProfileStateParsedDocument + | ProfileStateValidatedDocument + )[] = [] + for (const row of rows) { const document = validateProfileStateDocumentRow(row, { retainParsedValue: options.representation === 'parsed' }) @@ -55,12 +69,19 @@ export function readProfileStateDocuments( document.domain ) } + if (options.representation === 'validated') { + continue + } if (options.representation === 'parsed') { const { payload: _payload, ...parsedDocument } = document - return { ...parsedDocument, value: document.value } + documents.push({ ...parsedDocument, value: document.value }) + } else { + documents.push(document) } - return document - }) + } + if (options.representation === 'validated') { + return + } if (normalized === undefined) { return documents } diff --git a/src/main/persistence/profile-state/profile-state-document-validation.ts b/src/main/persistence/profile-state/profile-state-document-validation.ts index 5ead20cd8d09..526383539219 100644 --- a/src/main/persistence/profile-state/profile-state-document-validation.ts +++ b/src/main/persistence/profile-state/profile-state-document-validation.ts @@ -9,7 +9,8 @@ export type ProfileStateDocument = { contentHash: string } -export type ProfileStateParsedDocument = Omit & { value: unknown } +export type ProfileStateValidatedDocument = Omit +export type ProfileStateParsedDocument = ProfileStateValidatedDocument & { value: unknown } export class ProfileStateDocumentCorruptionError extends Error { readonly code = 'corrupt-document' as const diff --git a/src/main/persistence/profile-state/profile-state-documents.ts b/src/main/persistence/profile-state/profile-state-documents.ts index 4ecf701f3c8c..f20b0d0ccdca 100644 --- a/src/main/persistence/profile-state/profile-state-documents.ts +++ b/src/main/persistence/profile-state/profile-state-documents.ts @@ -221,3 +221,12 @@ export function readProfileStateSnapshot(db: Database.Database): ProfileStateSna } }) } + +/** Validate the complete snapshot without retaining state that recovery callers discard. */ +export function validateProfileStateSnapshot(db: Database.Database): number { + return withProfileStateReadSnapshot(db, () => { + const revision = readProfileStateRevision(db) + readProfileStateDocuments(db, { profileRevision: revision, representation: 'validated' }) + return revision + }) +} diff --git a/src/main/persistence/profile-state/profile-state-domain-reader.test.ts b/src/main/persistence/profile-state/profile-state-domain-reader.test.ts index fc34c14fe490..c6c9d89c6db4 100644 --- a/src/main/persistence/profile-state/profile-state-domain-reader.test.ts +++ b/src/main/persistence/profile-state/profile-state-domain-reader.test.ts @@ -1,10 +1,13 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { importProfileStateJson } from './profile-state-documents' import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' -import { readProfileStateDomains } from './profile-state-domain-reader' +import { + readProfileStateDomains, + readProfileStateDomainsWithRevisionFromDatabase +} from './profile-state-domain-reader' import { writeProfileStateDomains } from './profile-state-domain-writes' const temporaryDirectories: string[] = [] @@ -120,3 +123,30 @@ describe('profile state domain reader', () => { expect(result.kind === 'values' ? result.values.has('automationRuns') : true).toBe(false) }) }) + +it('reuses independently parsed values for selected domains and history rows', () => { + const { databasePath } = createDatabase() + const { db } = openProfileStateDatabase(databasePath, 'profile-a') + const settings = '{"theme":"dark"}' + const row = '{"id":"run-a","output":"retained"}' + importProfileStateJson(db, `{"settings":${settings},"automationRuns":[${row}]}`) + const parse = vi.spyOn(JSON, 'parse') + try { + expect( + readProfileStateDomainsWithRevisionFromDatabase(db, ['settings', 'automationRuns']) + ).toEqual({ + kind: 'values', + revision: 1, + values: new Map([ + ['settings', { theme: 'dark' }], + ['automationRuns', [{ id: 'run-a', output: 'retained' }]] + ]) + }) + expect(parse.mock.calls.filter(([input]) => input === settings)).toHaveLength(1) + expect(parse.mock.calls.filter(([input]) => input === row)).toHaveLength(1) + expect(parse.mock.calls.some(([input]) => input === `[${row}]`)).toBe(false) + } finally { + parse.mockRestore() + db.close() + } +}) diff --git a/src/main/persistence/profile-state/profile-state-domain-reader.ts b/src/main/persistence/profile-state/profile-state-domain-reader.ts index bd194e1edca9..2bd3a642e8ee 100644 --- a/src/main/persistence/profile-state/profile-state-domain-reader.ts +++ b/src/main/persistence/profile-state/profile-state-domain-reader.ts @@ -7,7 +7,7 @@ import { readProfileStateAutomationRunsDocument } from './profile-state-automati import { openProfileStateDatabaseReadOnly } from './profile-state-database' import { validateProfileStateDocumentRow, - type ProfileStateDocument + type ProfileStateParsedDocument } from './profile-state-document-validation' export type ProfileStateDomainReadResult = @@ -71,17 +71,17 @@ export function readProfileStateDomainsWithRevisionFromDatabase( } const normalized = wanted.has('automationRuns') - ? readProfileStateAutomationRunsDocument(db, revision) + ? readProfileStateAutomationRunsDocument(db, revision, 'parsed') : undefined const legacyDomains = [...wanted].filter( (domain) => domain !== 'automationRuns' || normalized === undefined ) for (const document of readSelectedDocuments(db, legacyDomains)) { assertProfileStateDocumentRevision(document.revision, revision, document.domain) - values.set(document.domain, JSON.parse(document.payload)) + values.set(document.domain, document.value) } if (normalized !== undefined && normalized !== null) { - values.set(normalized.domain, JSON.parse(normalized.payload)) + values.set(normalized.domain, normalized.value) } return { kind: 'values', revision, values } }) @@ -93,7 +93,7 @@ export function readProfileStateDomainsWithRevisionFromDatabase( function readSelectedDocuments( db: Parameters[0], domains: readonly string[] -): readonly ProfileStateDocument[] { +): readonly ProfileStateParsedDocument[] { if (domains.length === 0) { return [] } @@ -105,6 +105,11 @@ function readSelectedDocuments( WHERE domain IN (${placeholders}) ORDER BY rowid` ) - .all(...domains) - return rows.map((row) => validateProfileStateDocumentRow(row)) + .iterate(...domains) + return Array.from(rows, (row) => { + const { payload: _payload, ...document } = validateProfileStateDocumentRow(row, { + retainParsedValue: true + }) + return { ...document, value: document.value } + }) } diff --git a/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts b/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts index 5b4a1215da5e..ffdfccc79040 100644 --- a/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts +++ b/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts @@ -8,7 +8,8 @@ import { hashProfileStateJson, importProfileStateJson, readProfileStateSnapshot, - readProfileStateParsedSnapshot + readProfileStateParsedSnapshot, + validateProfileStateSnapshot } from './profile-state-documents' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' import { createProfileStateStore } from './profile-state-store-factory' @@ -61,6 +62,7 @@ describe('independent profile state JSON fragments', () => { 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' ).run(payload, hashProfileStateJson(payload), 'futureDomain') expect(() => readProfileStateParsedSnapshot(db)).toThrow(/invalid JSON: futureDomain/) + expect(() => validateProfileStateSnapshot(db)).toThrow(/invalid JSON: futureDomain/) db.close() expect(() => { @@ -89,7 +91,7 @@ describe('independent profile state JSON fragments', () => { } }) - it.each(['snapshot', 'parsed', 'authority', 'store'] as const)( + it.each(['snapshot', 'parsed', 'validated', 'authority', 'store'] as const)( 'rejects history rows that form a valid array only when spliced together (%s)', (boundary) => { const { paths, db } = fixture() @@ -112,6 +114,8 @@ describe('independent profile state JSON fragments', () => { readProfileStateSnapshot(db) } else if (boundary === 'parsed') { readProfileStateParsedSnapshot(db) + } else if (boundary === 'validated') { + validateProfileStateSnapshot(db) } else if (boundary === 'authority') { authority.readSerializedState() } else { diff --git a/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts index 8afac7160706..428622f8c6d5 100644 --- a/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts +++ b/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts @@ -9,7 +9,8 @@ import { readAcceptedProfileStateParsedSnapshot, readProfileStateDocuments, readProfileStateParsedSnapshot, - readProfileStateSnapshot + readProfileStateSnapshot, + validateProfileStateSnapshot } from './profile-state-documents' const directories: string[] = [] @@ -54,6 +55,7 @@ describe('checked profile state values', () => { db.exec(sql) expect(() => readProfileStateParsedSnapshot(db)).toThrow(message) expect(() => readProfileStateSnapshot(db)).toThrow(message) + expect(() => validateProfileStateSnapshot(db)).toThrow(message) } finally { db.close() } @@ -70,6 +72,7 @@ describe('checked profile state values', () => { ) expect(() => readProfileStateParsedSnapshot(db)).toThrow('identity is corrupt') expect(() => readProfileStateSnapshot(db)).toThrow('identity is corrupt') + expect(() => validateProfileStateSnapshot(db)).toThrow('identity is corrupt') } finally { db.close() } diff --git a/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts b/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts index aba9c44396be..0f6307e47d37 100644 --- a/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts +++ b/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts @@ -10,7 +10,8 @@ import { verifyProfileStateSchema } from './profile-state-database-validation' import { importProfileStateJson, readProfileStateSnapshot, - readProfileStateParsedSnapshot + readProfileStateParsedSnapshot, + validateProfileStateSnapshot } from './profile-state-documents' import { readProfileStateDomainsWithRevisionFromDatabase } from './profile-state-domain-reader' import { writeProfileStateDomains } from './profile-state-domain-writes' @@ -35,7 +36,7 @@ function fixture() { } describe('profile state reads during concurrent commits', () => { - it.each([readProfileStateSnapshot, readProfileStateParsedSnapshot])( + it.each([readProfileStateSnapshot, readProfileStateParsedSnapshot, validateProfileStateSnapshot])( 'keeps revision and documents together when a writer commits during %s', (read) => { const { path, db: writer } = fixture() @@ -56,14 +57,19 @@ describe('profile state reads during concurrent commits', () => { try { const snapshot = read(reader) expect(committed).toBe(true) - expect(snapshot).toMatchObject({ revision: 1 }) - expect(snapshot).toMatchObject( - read === readProfileStateSnapshot - ? { json: '{"automationRuns":[{"id":"run-1","status":"pending"}]}' } - : { state: { automationRuns: [{ id: 'run-1', status: 'pending' }] } } - ) + if (typeof snapshot === 'number') { + expect(snapshot).toBe(1) + } else { + expect(snapshot).toMatchObject({ revision: 1 }) + expect(snapshot).toMatchObject( + read === readProfileStateSnapshot + ? { json: '{"automationRuns":[{"id":"run-1","status":"pending"}]}' } + : { state: { automationRuns: [{ id: 'run-1', status: 'pending' }] } } + ) + } expect(reader.isTransaction).toBe(false) - expect(read(reader).revision).toBe(2) + const next = read(reader) + expect(typeof next === 'number' ? next : next.revision).toBe(2) } finally { reader.close() writer.close() diff --git a/src/main/persistence/profile-state/profile-state-streaming-validation.test.ts b/src/main/persistence/profile-state/profile-state-streaming-validation.test.ts new file mode 100644 index 000000000000..537023ac45a5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-streaming-validation.test.ts @@ -0,0 +1,145 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { openProfileStateDatabase } from './profile-state-database' +import { verifyProfileStateSchema } from './profile-state-database-validation' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateParsedSnapshot, + readProfileStateSnapshot, + validateProfileStateSnapshot +} from './profile-state-documents' + +const fixtures: { directory: string; db: ReturnType['db'] }[] = [] +afterEach(() => { + for (const { directory, db } of fixtures.splice(0)) { + db.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(source = '{"automationRuns":[{"id":"a"},{"id":"b"}],"last":null}') { + const directory = mkdtempSync(join(tmpdir(), 'orca-streamed-profile-')) + const { db } = openProfileStateDatabase(join(directory, 'profile-state.db'), 'stream-test') + fixtures.push({ directory, db }) + importProfileStateJson(db, source) + return db +} + +const readers = [ + readProfileStateSnapshot, + readProfileStateParsedSnapshot, + validateProfileStateSnapshot +] + +describe('complete streaming profile validation', () => { + it('preserves history order and bytes with rowids at both SQLite integer limits', () => { + const source = + '{"before":null,"automationRuns":[{"id":"a","text":"雪\\ud800"},{"id":"b"}],"after":true}' + const db = fixture(source) + const before = readProfileStateSnapshot(db) + const update = db.prepare('UPDATE profile_state_automation_runs SET rowid = ? WHERE run_id = ?') + update.run(9223372036854775807n, 'a') + update.run(-9223372036854775808n, 'b') + expect(readProfileStateSnapshot(db)).toEqual(before) + expect(readProfileStateParsedSnapshot(db)).toEqual({ + revision: before.revision, + state: JSON.parse(source) + }) + expect(validateProfileStateSnapshot(db)).toBe(before.revision) + expect(db.isTransaction).toBe(false) + }) + + it('accepts extra columns that shadow every SQLite rowid alias', () => { + const db = fixture() + const before = readProfileStateSnapshot(db) + db.exec("ALTER TABLE profile_state_automation_runs ADD COLUMN rowid TEXT DEFAULT 'shadow'") + db.exec("ALTER TABLE profile_state_automation_runs ADD COLUMN _rowid_ TEXT DEFAULT 'shadow'") + db.exec("ALTER TABLE profile_state_automation_runs ADD COLUMN oid TEXT DEFAULT 'shadow'") + verifyProfileStateSchema(db, 'stream-test') + expect(readProfileStateSnapshot(db)).toEqual(before) + expect(readProfileStateParsedSnapshot(db).state).toEqual(JSON.parse(before.json)) + expect(validateProfileStateSnapshot(db)).toBe(before.revision) + }) + + it.each([ + '{}', + '{"automationRuns":null}', + '{"automationRuns":[]}', + '{"automationRuns":{"future":true}}' + ])('validates history presence without constructing a returned state: %s', (source) => { + const db = fixture(source) + expect(validateProfileStateSnapshot(db)).toBe(readProfileStateSnapshot(db).revision) + expect(readProfileStateParsedSnapshot(db).state).toEqual(JSON.parse(source)) + }) + + it.each([ + [ + 'row hash', + "UPDATE profile_state_automation_runs SET content_hash = printf('%064d', 0) WHERE ordinal = 1" + ], + ['ordering', 'UPDATE profile_state_automation_runs SET ordinal = 0 WHERE ordinal = 1'], + ['row revision', 'UPDATE profile_state_automation_runs SET revision = 99 WHERE ordinal = 1'], + [ + 'row timestamp', + 'UPDATE profile_state_automation_runs SET updated_at = updated_at + 1 WHERE ordinal = 1' + ], + [ + 'aggregate hash', + "UPDATE profile_state_automation_runs_meta SET content_hash = printf('%064d', 0)" + ], + ['domain hash', "UPDATE profile_state_documents SET payload = 'true' WHERE domain = 'last'"], + ['domain revision', "UPDATE profile_state_documents SET revision = 99 WHERE domain = 'last'"] + ])('rejects late %s corruption in every representation', (_, sql) => { + const db = fixture() + db.exec(sql) + for (const read of readers) { + expect(() => read(db)).toThrow() + expect(db.isTransaction).toBe(false) + } + }) + + it.each([false, true])( + 'closes failed iterators while preserving caller transaction ownership (%s)', + (ownsTransaction) => { + const db = fixture() + if (ownsTransaction) { + db.exec('BEGIN') + } + const payload = 'null,"injected":true' + const update = db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ) + update.run(payload, hashProfileStateJson(payload), 'last') + expect(() => validateProfileStateSnapshot(db)).toThrow('invalid JSON') + expect(db.isTransaction).toBe(ownsTransaction) + update.run('null', hashProfileStateJson('null'), 'last') + expect(validateProfileStateSnapshot(db)).toBe(1) + expect(db.isTransaction).toBe(ownsTransaction) + if (ownsTransaction) { + db.exec('ROLLBACK') + } + expect(readProfileStateSnapshot(db).json).toBe( + '{"automationRuns":[{"id":"a"},{"id":"b"}],"last":null}' + ) + } + ) + + it('closes the ordered-history iterator when the inner lookup fails validation', () => { + const db = fixture() + const update = db.prepare( + 'UPDATE profile_state_automation_runs SET payload = ?, content_hash = ? WHERE ordinal = 1' + ) + const invalid = '{"id":"wrong"}' + update.run(invalid, hashProfileStateJson(invalid)) + for (const read of readers) { + expect(() => read(db)).toThrow('identity is corrupt') + expect(db.isTransaction).toBe(false) + } + const valid = '{"id":"b"}' + update.run(valid, hashProfileStateJson(valid)) + expect(validateProfileStateSnapshot(db)).toBe(1) + }) +}) From a29e17bd65523e1e1390c769f1e9a642d7615e07 Mon Sep 17 00:00:00 2001 From: m4air Date: Wed, 23 Sep 2026 15:13:02 -0700 Subject: [PATCH 03/43] Accelerate large profile recovery copies on macOS --- config/tsconfig.cli.json | 1 + .../profile-state-database-quarantine.ts | 7 +- .../profile-state-database-recovery.test.ts | 25 ++- .../profile-state-database-recovery.ts | 5 +- ...profile-state-large-recovery-crash.test.ts | 142 +++++++++++++ .../profile-state-recovery-copy.test.ts | 193 ++++++++++++++++++ .../profile-state-recovery-copy.ts | 48 +++++ .../profile-state-recovery-crash-process.ts | 8 + ...le-state-automatic-backup-recovery.spec.ts | 25 ++- 9 files changed, 441 insertions(+), 13 deletions(-) create mode 100644 src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-copy.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-copy.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 7e6d67e3e1b0..8308e0c72681 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -57,6 +57,7 @@ "../src/main/persistence/profile-state/profile-state-database-recovery.ts", "../src/main/persistence/profile-state/profile-state-recovery-required.ts", "../src/main/persistence/profile-state/profile-state-recovery.ts", + "../src/main/persistence/profile-state/profile-state-recovery-copy.ts", "../src/main/persistence/profile-state/profile-state-recovery-command.ts", "../src/main/persistence/profile-state/profile-state-active-location.ts", "../src/main/persistence/profile-state/profile-state-access.ts", diff --git a/src/main/persistence/profile-state/profile-state-database-quarantine.ts b/src/main/persistence/profile-state/profile-state-database-quarantine.ts index b59c7a1c37e5..98edbbc9fc8b 100644 --- a/src/main/persistence/profile-state/profile-state-database-quarantine.ts +++ b/src/main/persistence/profile-state/profile-state-database-quarantine.ts @@ -1,10 +1,11 @@ import { profileStateDatabaseFiles } from './profile-state-storage-classification' import { randomUUID } from 'node:crypto' -import { copyFileSync, existsSync, mkdirSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync } from 'node:fs' import { basename, dirname, join } from 'node:path' import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import { copyProfileStateRecoveryFile } from './profile-state-recovery-copy' export type ProfileStateDatabaseQuarantine = { directory: string @@ -41,7 +42,7 @@ export function quarantineProfileStateDatabase( ? 'profile-state.db' : `profile-state.db${sourcePath.slice(databasePath.length)}` const targetPath = join(directory, targetName) - copyFileSync(sourcePath, targetPath) + copyProfileStateRecoveryFile(sourcePath, targetPath) hardenSqliteDatabaseFiles(targetPath) fsyncFileSync(targetPath) copiedFiles.push(targetPath) @@ -51,7 +52,7 @@ export function quarantineProfileStateDatabase( if (existsSync(targetPath) || basename(sourcePath) === 'manifest.json') { throw new Error('Profile recovery artifact name conflicts with the quarantine manifest') } - copyFileSync(sourcePath, targetPath) + copyProfileStateRecoveryFile(sourcePath, targetPath) hardenSqliteDatabaseFiles(targetPath) fsyncFileSync(targetPath) copiedFiles.push(targetPath) diff --git a/src/main/persistence/profile-state/profile-state-database-recovery.test.ts b/src/main/persistence/profile-state/profile-state-database-recovery.test.ts index 188442525c5a..5a0fb0c868fc 100644 --- a/src/main/persistence/profile-state/profile-state-database-recovery.test.ts +++ b/src/main/persistence/profile-state/profile-state-database-recovery.test.ts @@ -43,7 +43,7 @@ afterEach(() => { } }) -async function fixture(options: { profileId?: string; empty?: boolean } = {}) { +async function fixture(options: { profileId?: string; empty?: boolean; json?: string } = {}) { const root = mkdtempSync(join(tmpdir(), 'orca-database-recovery-')) directories.push(root) const directory = join(root, 'profiles', profileId) @@ -62,7 +62,7 @@ async function fixture(options: { profileId?: string; empty?: boolean } = {}) { ) try { if (!options.empty) { - importProfileStateJson(source.db, savedJson) + importProfileStateJson(source.db, options.json ?? savedJson) } await writeProfileStateDatabaseSnapshotAsync(source.db, backupPath) } finally { @@ -95,6 +95,27 @@ function expectOriginals(options: Awaited>): void { } describe('profile state database backup recovery', () => { + it('rejects corruption in a large cloned staging file before changing recovery state', async () => { + const options = await fixture({ + json: JSON.stringify({ opaqueExtension: 'x'.repeat(8 * 1024 * 1024) }) + }) + const backup = new Database(options.backupPath) + try { + backup.exec("UPDATE profile_state_documents SET content_hash = printf('%064d', 0)") + } finally { + backup.close() + } + const originalBackup = readFileSync(options.backupPath) + expect(() => restoreProfileStateDatabaseBackup(options)).toThrow() + expectOriginals(options) + expect(readFileSync(options.backupPath).equals(originalBackup)).toBe(true) + expect( + readdirSync(dirname(options.databasePath)).some((name) => + name.startsWith('.orca-recovery-clone-') + ) + ).toBe(false) + }) + it.each([true, false])( 'restores SQLite authority with the old database present=%s', async (hasDatabase) => { diff --git a/src/main/persistence/profile-state/profile-state-database-recovery.ts b/src/main/persistence/profile-state/profile-state-database-recovery.ts index aaacb912a9c1..106e771978f5 100644 --- a/src/main/persistence/profile-state/profile-state-database-recovery.ts +++ b/src/main/persistence/profile-state/profile-state-database-recovery.ts @@ -1,11 +1,12 @@ import { profileStateDatabaseFiles } from './profile-state-storage-classification' -import { constants, copyFileSync, existsSync, lstatSync, mkdirSync, rmSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, rmSync } from 'node:fs' import { dirname } from 'node:path' import { durableWriteTempPath, renameDurableSync } from '../../durable-file-write' import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' import { openProfileStateDatabaseReadOnly } from './profile-state-database' import { validateProfileStateSnapshot } from './profile-state-documents' +import { copyProfileStateRecoveryFile } from './profile-state-recovery-copy' import { profileStateDatabaseBackups, profileStateDatabaseBackupFiles @@ -53,7 +54,7 @@ export function restoreProfileStateDatabaseBackup( mkdirSync(dirname(options.databasePath), { recursive: true }) const stagingPath = durableWriteTempPath(options.databasePath) try { - copyFileSync(options.backupPath, stagingPath, constants.COPYFILE_EXCL) + copyProfileStateRecoveryFile(options.backupPath, stagingPath) hardenSqliteDatabaseFiles(stagingPath) const revision = validateRecoverySnapshot(stagingPath, options.profileId) fsyncFileSync(stagingPath) diff --git a/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts b/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts new file mode 100644 index 000000000000..5a207373e5ed --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts @@ -0,0 +1,142 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { importProfileStateJson, readProfileStateSnapshot } from './profile-state-documents' +import { profileStateJsonExportPath } from './profile-state-export-path' +import { buildRecoveryCrashProcess, killRecoveryAt } from './profile-state-recovery-crash-process' + +const suite = mkdtempSync(join(tmpdir(), 'orca-large-recovery-crash-')) +const roots: string[] = [] +const profileId = 'large-recovery' +const oldJson = JSON.stringify({ opaque: 'x'.repeat(8 * 1024 * 1024), revision: 'old' }) +const selectedJson = JSON.stringify({ opaque: 'y'.repeat(8 * 1024 * 1024), revision: 'selected' }) +let bundle: string +beforeAll(() => { + bundle = buildRecoveryCrashProcess(suite) +}) +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +afterAll(() => rmSync(suite, { recursive: true, force: true })) + +async function fixture() { + const root = mkdtempSync(join(suite, 'profile-')) + roots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const backupPath = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + const db = openProfileStateDatabase(databasePath, profileId).db + try { + importProfileStateJson(db, oldJson) + importProfileStateJson(db, oldJson) + importProfileStateJson(db, selectedJson) + await writeProfileStateDatabaseSnapshotAsync(db, backupPath) + } finally { + db.close() + } + const options = { + root, + directory, + profileId, + databasePath, + dataFile, + backupPath, + exportPath: profileStateJsonExportPath(dataFile, 3), + markerPath: join(root, 'http1-compatibility.json'), + kind: 'sqlite' as const + } + await killRecoveryAt(bundle, options, 'seed', oldJson) + expect(existsSync(`${databasePath}-wal`)).toBe(true) + const originalFamily = new Map( + ['', '-wal', '-shm'].map((suffix) => [suffix, readFileSync(databasePath + suffix)]) + ) + return { ...options, originalFamily, backupBytes: readFileSync(backupPath) } +} + +function snapshot(path: string) { + const db = openProfileStateDatabaseReadOnly(path, profileId).db + try { + const { json, revision } = readProfileStateSnapshot(db) + return { json, revision } + } finally { + db.close() + } +} + +const boundaries = [ + ...(process.platform === 'darwin' + ? ['clone:1:before', 'clone:1:after', 'clone:2:after', 'clone:3:after'] + : []), + 'primary', + 'sqlite-publish:before', + 'sqlite-publish:after' +] + +describe('large independent recovery copies under process death', () => { + it.each(boundaries)( + 'preserves exact backup and retry state after %s', + async (boundary) => { + const profile = await fixture() + const stage = boundary === 'primary' ? `removed:${profile.databasePath}` : boundary + await killRecoveryAt(bundle, profile, stage) + expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true) + if (boundary.startsWith('clone:')) { + for (const [suffix, bytes] of profile.originalFamily) { + expect(readFileSync(profile.databasePath + suffix).equals(bytes)).toBe(true) + } + } else { + const directory = readdirSync(profile.directory).find((name) => + name.startsWith('profile-state-corrupt-') + ) + if (directory === undefined) { + throw new Error('Recovery did not preserve a quarantine') + } + for (const [suffix, bytes] of profile.originalFamily) { + expect( + readFileSync(join(profile.directory, directory, `profile-state.db${suffix}`)).equals( + bytes + ) + ).toBe(true) + } + expect( + readFileSync(join(profile.directory, directory, basename(profile.backupPath))).equals( + profile.backupBytes + ) + ).toBe(true) + if (boundary === 'sqlite-publish:after') { + expect(snapshot(profile.databasePath)).toEqual({ json: selectedJson, revision: 3 }) + } else { + expect(existsSync(profile.databasePath)).toBe(false) + } + } + const maintenance = acquireProfileStateMaintenance(profile.root) + try { + restoreProfileStateDatabaseBackup({ ...profile, maintenance }) + } finally { + maintenance.release() + } + expect(snapshot(profile.databasePath)).toEqual({ json: selectedJson, revision: 3 }) + expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true) + }, + 30_000 + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts b/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts new file mode 100644 index 000000000000..d7bc8796be2b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts @@ -0,0 +1,193 @@ +import { + closeSync, + copyFileSync, + existsSync, + ftruncateSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, + writeSync +} from 'node:fs' +import type * as FileSystem from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, relative } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as processes from '../../../shared/child-process/run-process' +import { copyProfileStateRecoveryFile } from './profile-state-recovery-copy' + +const cloneLink = vi.hoisted(() => ({ unsupported: false })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { + ...actual, + linkSync: (...args: Parameters) => { + if (cloneLink.unsupported) { + throw Object.assign(new Error('hardlinks unavailable'), { code: 'ENOTSUP' }) + } + return actual.linkSync(...args) + } + } +}) + +const directories: string[] = [] +afterEach(() => { + cloneLink.unsupported = false + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(large = true) { + const directory = mkdtempSync(join(tmpdir(), 'orca-recovery-copy-')) + directories.push(directory) + const source = join(directory, '- source with spaces') + const target = join(directory, 'target') + const descriptor = openSync(source, 'wx', 0o600) + try { + ftruncateSync(descriptor, large ? 8 * 1024 * 1024 + 1 : 100) + writeSync(descriptor, Buffer.from('retained source')) + } finally { + closeSync(descriptor) + } + return { directory, source, target } +} + +function expectNoTemporary(directory: string): void { + expect(readdirSync(directory).some((name) => name.startsWith('.orca-recovery-clone-'))).toBe( + false + ) +} + +describe('independent profile recovery copies', () => { + it.each([false, true])('preserves independent bytes with a large file=%s', (large) => { + const { directory, source, target } = fixture(large) + const before = readFileSync(source) + copyProfileStateRecoveryFile(source, target) + expect(readFileSync(target).equals(before)).toBe(true) + expect(statSync(target, { bigint: true }).ino).not.toBe(statSync(source, { bigint: true }).ino) + writeFileSync(source, 'changed source') + expect(readFileSync(target).equals(before)).toBe(true) + writeFileSync(target, 'changed target') + expect(readFileSync(source, 'utf8')).toBe('changed source') + expectNoTemporary(directory) + }) + + it.each([false, true])('never replaces an existing destination with a large file=%s', (large) => { + const { directory, source, target } = fixture(large) + writeFileSync(target, 'do not replace') + expect(() => copyProfileStateRecoveryFile(source, target)).toThrow() + expect(readFileSync(target, 'utf8')).toBe('do not replace') + expectNoTemporary(directory) + }) + + it('does not start a copy process for small files', () => { + const { source, target } = fixture(false) + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFile(source, target) + expect(run).not.toHaveBeenCalled() + }) + + describe.skipIf(process.platform !== 'darwin')('Darwin clone failure boundaries', () => { + it('resolves both process arguments while preserving relative path behavior', () => { + const { directory, source, target } = fixture() + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFile(relative(process.cwd(), source), relative(process.cwd(), target)) + const args = run.mock.calls[0]?.[0].args + expect(args?.[0]).toBe('-c') + expect(isAbsolute(args?.[1] ?? '')).toBe(true) + expect(isAbsolute(args?.[2] ?? '')).toBe(true) + expect(readFileSync(target).equals(readFileSync(source))).toBe(true) + expectNoTemporary(directory) + }) + + it('keeps ordinary-copy semantics for symlinks to large recovery artifacts', () => { + const { directory, source, target } = fixture() + const alias = join(directory, 'alias') + symlinkSync(source, alias) + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFile(alias, target) + expect(run).not.toHaveBeenCalled() + expect(readFileSync(target).equals(readFileSync(source))).toBe(true) + expect(statSync(target, { bigint: true }).ino).not.toBe( + statSync(source, { bigint: true }).ino + ) + expectNoTemporary(directory) + }) + + it('cleans a failed partial clone and falls back to an independent ordinary copy', () => { + const { directory, source, target } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation((spec) => { + const temporary = spec.args?.[2] + if (typeof temporary !== 'string') { + throw new Error('Missing clone destination') + } + expect(statSync(dirname(temporary)).mode & 0o777).toBe(0o700) + writeFileSync(temporary, 'incomplete') + return { code: 1, signal: null, timedOut: false, stdout: '', stderr: 'clone unavailable' } + }) + copyProfileStateRecoveryFile(source, target) + expect(readFileSync(target).equals(readFileSync(source))).toBe(true) + expect(statSync(target, { bigint: true }).ino).not.toBe( + statSync(source, { bigint: true }).ino + ) + expectNoTemporary(directory) + }) + + it.each(['timeout', 'signal', 'spawn'] as const)( + 'preserves originals and removes temporary copies after %s failure', + (failure) => { + const { directory, source, target } = fixture() + const before = readFileSync(source) + vi.spyOn(processes, 'runProcessSync').mockImplementation(() => { + if (failure === 'spawn') { + throw new Error('copy process could not start') + } + return { + code: null, + signal: 'SIGTERM', + timedOut: failure === 'timeout', + stdout: '', + stderr: '' + } + }) + expect(() => copyProfileStateRecoveryFile(source, target)).toThrow() + expect(readFileSync(source).equals(before)).toBe(true) + expect(existsSync(target)).toBe(false) + expectNoTemporary(directory) + } + ) + + it('preserves a destination created while the clone process runs', () => { + const { directory, source, target } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation((spec) => { + const temporary = spec.args?.[2] + if (typeof temporary !== 'string') { + throw new Error('Missing clone destination') + } + copyFileSync(source, temporary) + writeFileSync(target, 'concurrent destination') + return { code: 0, signal: null, timedOut: false, stdout: '', stderr: '' } + }) + expect(() => copyProfileStateRecoveryFile(source, target)).toThrow() + expect(readFileSync(target, 'utf8')).toBe('concurrent destination') + expectNoTemporary(directory) + }) + + it('falls back when the destination filesystem does not support hardlinks', () => { + const { directory, source, target } = fixture() + cloneLink.unsupported = true + copyProfileStateRecoveryFile(source, target) + expect(readFileSync(target).equals(readFileSync(source))).toBe(true) + expect(statSync(target, { bigint: true }).ino).not.toBe( + statSync(source, { bigint: true }).ino + ) + expectNoTemporary(directory) + }) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-recovery-copy.ts b/src/main/persistence/profile-state/profile-state-recovery-copy.ts new file mode 100644 index 000000000000..b9722d547b29 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-copy.ts @@ -0,0 +1,48 @@ +import { constants, copyFileSync, linkSync, lstatSync, mkdtempSync, rmSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { runProcessSync } from '../../../shared/child-process/run-process' + +// Keep process startup overhead off small recovery copies. +const MINIMUM_CLONE_BYTES = 8 * 1024 * 1024 + +/** Copy quiescent recovery artifacts independently; never copy an active WAL database this way. */ +export function copyProfileStateRecoveryFile(source: string, target: string): void { + const sourceInfo = process.platform === 'darwin' ? lstatSync(source) : undefined + if (!sourceInfo?.isFile() || sourceInfo.size < MINIMUM_CLONE_BYTES) { + copyFileSync(source, target, constants.COPYFILE_EXCL) + return + } + + // Node's clone flag is unsupported on Darwin; cp -c uses APFS clones with ordinary-copy fallback. + const directory = mkdtempSync(join(dirname(target), '.orca-recovery-clone-')) + const temporary = resolve(directory, 'copy') + let copied = false + try { + const result = runProcessSync({ + program: '/bin/cp', + args: ['-c', resolve(source), temporary], + timeoutMs: 30_000, + maxOutputBytes: 16_384 + }) + if (result.timedOut || result.signal !== null) { + throw new Error('Profile recovery file copy was interrupted') + } + if (result.code === 0) { + // Publish the independent clone without replacing a concurrently created destination. + try { + linkSync(temporary, target) + copied = true + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'EEXIST') { + throw error + } + // Some destination filesystems support copying but not hardlink publication. + } + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } + if (!copied) { + copyFileSync(source, target, constants.COPYFILE_EXCL) + } +} diff --git a/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts b/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts index dd3c7ac2bc80..d51ae465dae2 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts @@ -72,6 +72,14 @@ fs.rmSync = (target, ...rest) => { rm(target, ...rest) barrier('removed:' + target) } +let clone = 0 +const link = fs.linkSync +fs.linkSync = (from, to) => { + const isClone = from.includes('.orca-recovery-clone-') + if (isClone) barrier('clone:' + (++clone) + ':before') + link(from, to) + if (isClone) barrier('clone:' + clone + ':after') +} const fsync = fs.fsyncSync fs.fsyncSync = descriptor => { fsync(descriptor) diff --git a/tests/e2e/profile-state-automatic-backup-recovery.spec.ts b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts index ef50e173ca65..0f2cde124cb7 100644 --- a/tests/e2e/profile-state-automatic-backup-recovery.spec.ts +++ b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs' import path from 'node:path' import type { ElectronApplication } from '@stablyai/playwright-test' import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' @@ -62,13 +62,18 @@ for (const recoveryRuntime of ['node', 'electron'] as const) { const databasePath = path.join(profileDirectory, 'profile-state.db') const dataFile = path.join(profileDirectory, 'orca-data.json') const marker = `automatic-backup-${Date.now()}` + const recoveryMarker = { + marker, + // Exercise native cloning in the Electron recovery process on macOS. + payload: recoveryRuntime === 'electron' ? 'retained recovery data'.repeat(450_000) : '' + } const seed = getE2ECompletedOnboardingProfile() writeFileSync( rootJson, JSON.stringify({ ...seed, settings: { ...seed.settings, terminalFontSize: 19, theme: 'light' }, - backupRecoveryMarker: { marker } + backupRecoveryMarker: recoveryMarker }) ) let firstApp: ElectronApplication | null = null @@ -98,8 +103,11 @@ for (const recoveryRuntime of ['node', 'electron'] as const) { const chosen = readSnapshot(backup.path) expect(JSON.parse(chosen.json)).toMatchObject({ settings: { terminalFontSize: 19 }, - backupRecoveryMarker: { marker } + backupRecoveryMarker: recoveryMarker }) + if (recoveryRuntime === 'electron') { + expect(statSync(backup.path).size).toBeGreaterThan(8 * 1024 * 1024) + } const backupBytes = readFileSync(backup.path) await first.page.evaluate(async () => { const update = window.__store?.getState().updateSettingsOrThrow @@ -111,7 +119,7 @@ for (const recoveryRuntime of ['node', 'electron'] as const) { await expect .poll(() => JSON.parse(readSnapshot(databasePath).json).settings.terminalFontSize) .toBe(23) - expect(readFileSync(backup.path)).toEqual(backupBytes) + expect(readFileSync(backup.path).equals(backupBytes)).toBe(true) const beforeRefusal = readSnapshot(databasePath) const recoveryExecutable = recoveryRuntime === 'electron' @@ -127,7 +135,7 @@ for (const recoveryRuntime of ['node', 'electron'] as const) { recoveryRuntime === 'electron' ? 'Stop Orca' : 'in use' ) expect(readSnapshot(databasePath)).toEqual(beforeRefusal) - expect(readFileSync(backup.path)).toEqual(backupBytes) + expect(readFileSync(backup.path).equals(backupBytes)).toBe(true) await session.close(firstApp) firstApp = null await cleanupE2EDaemons(session.userDataDir) @@ -164,7 +172,12 @@ for (const recoveryRuntime of ['node', 'electron'] as const) { relaunched.page.evaluate(() => window.__store?.getState().settings?.terminalFontSize) ) .toBe(19) - expect(JSON.parse(readSnapshot(databasePath).json).backupRecoveryMarker).toEqual({ marker }) + await expect(relaunched.page.locator('html')).toHaveClass( + JSON.parse(chosen.json).settings.theme === 'dark' ? /\bdark\b/ : /\blight\b/ + ) + expect(JSON.parse(readSnapshot(databasePath).json).backupRecoveryMarker).toEqual( + recoveryMarker + ) expect( await relaunched.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().every((window) => !window.isVisible()) From dbb154e675e40760f20afb766349309dcbddb871 Mon Sep 17 00:00:00 2001 From: m4air Date: Wed, 23 Sep 2026 17:02:19 -0700 Subject: [PATCH 04/43] Remove duplicate profile checkpoint work and batch recovery copies --- ...ile-state-automation-runs-equality.test.ts | 51 ++++ .../profile-state-automation-runs-model.ts | 12 +- .../profile-state-automation-runs-payload.ts | 46 ++-- .../profile-state-automation-runs-writer.ts | 29 +-- .../profile-state-database-quarantine.ts | 35 ++- .../profile-state-domain-equality.test.ts | 16 ++ .../profile-state-domain-write-validation.ts | 12 +- ...profile-state-large-recovery-crash.test.ts | 9 +- .../profile-state-recovery-batch.test.ts | 227 ++++++++++++++++++ .../profile-state-recovery-copy.ts | 74 ++++-- ...le-state-automatic-backup-recovery.spec.ts | 22 +- 11 files changed, 443 insertions(+), 90 deletions(-) create mode 100644 src/main/persistence/profile-state/profile-state-recovery-batch.test.ts diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts b/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts index 0fec8e1626be..8ac387db9aaf 100644 --- a/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts +++ b/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts @@ -57,6 +57,57 @@ describe('automation history replacement equality', () => { } ) + it.each([ + { + payload: '[{"id":"ignored","id":"run","extension":{"value":1,"value":2}}, {"id":"next"}]', + presence: 'array' + }, + { payload: '[{"id":"same"},{"id":"same","extension":true}]', presence: 'document' }, + { payload: '[{"extension":true}]', presence: 'document' }, + { payload: '[]', presence: 'array' }, + { payload: 'null', presence: 'null' }, + { payload: null, presence: 'absent' } + ])('preserves canonical JSON and storage transitions for $payload', ({ payload, presence }) => { + const { db } = fixture() + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload }] + }) + ).toEqual({ changed: true, revision: 2, changedDomains: ['automationRuns'] }) + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ + settings: {}, + ...(payload === null ? {} : { automationRuns: JSON.parse(payload) }) + }) + expect(db.prepare('SELECT presence FROM profile_state_automation_runs_meta').get()).toEqual({ + presence + }) + if (presence === 'array' || presence === 'null') { + expect( + db.prepare('SELECT content_hash FROM profile_state_automation_runs_meta').get() + ).toEqual({ + content_hash: hashProfileStateJson(JSON.stringify(JSON.parse(payload ?? 'null'))) + }) + } + }) + + it('derives prepared history from the checked payload instead of caller metadata', () => { + const { db } = fixture() + const replacement = { + domain: 'automationRuns', + payload: '[{"id":"actual"}]', + automationRunsValue: [{ id: 'forged' }] + } + + writeProfileStateDomains(db, { expectedRevision: 1, replacements: [replacement] }) + + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ + settings: {}, + automationRuns: [{ id: 'actual' }] + }) + }) + it('fences a stale caller even when its history still matches', () => { const { db, payload } = fixture() writeProfileStateDomains(db, { diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-model.ts b/src/main/persistence/profile-state/profile-state-automation-runs-model.ts index 1fc5a70b9ffa..b02418cf8ea6 100644 --- a/src/main/persistence/profile-state/profile-state-automation-runs-model.ts +++ b/src/main/persistence/profile-state/profile-state-automation-runs-model.ts @@ -13,12 +13,6 @@ export type AutomationRunsPresence = | typeof AUTOMATION_RUNS_NULL | typeof AUTOMATION_RUNS_ARRAY -export type AutomationRunsReplacement = { - payload: string | null - domainVersion: number - now?: () => number -} - export type AutomationRunsMeta = { presence: AutomationRunsPresence domainVersion: number @@ -46,10 +40,10 @@ export type AutomationRunIdentity = { } export type ParsedAutomationRunsReplacement = - | { presence: typeof AUTOMATION_RUNS_ABSENT; payload: null; runs?: undefined } - | { presence: typeof AUTOMATION_RUNS_NULL; payload: 'null'; runs?: undefined } + | { presence: typeof AUTOMATION_RUNS_ABSENT; contentHash: ''; runs?: undefined } + | { presence: typeof AUTOMATION_RUNS_NULL; contentHash: string; runs?: undefined } | { presence: typeof AUTOMATION_RUNS_ARRAY - payload: string + contentHash: string runs: readonly AutomationRunPayload[] } diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts b/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts index 6a9f756c00e4..32da30b87848 100644 --- a/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts +++ b/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { hashProfileStatePayload, isRecord } from './profile-state-document-validation' import { AUTOMATION_RUNS_ABSENT, @@ -11,7 +12,7 @@ export function parseAutomationRunsReplacement( payload: string | null ): ParsedAutomationRunsReplacement | undefined { if (payload === null) { - return { presence: AUTOMATION_RUNS_ABSENT, payload: null } + return parseAutomationRunsValue(undefined) } let parsed: unknown try { @@ -19,28 +20,30 @@ export function parseAutomationRunsReplacement( } catch { return undefined } - if (parsed === null) { - return { presence: AUTOMATION_RUNS_NULL, payload: 'null' } + return parseAutomationRunsValue(parsed) +} + +export function parseAutomationRunsValue( + value: unknown +): ParsedAutomationRunsReplacement | undefined { + if (value === undefined) { + return { presence: AUTOMATION_RUNS_ABSENT, contentHash: '' } } - if (!Array.isArray(parsed)) { - return undefined + if (value === null) { + return { presence: AUTOMATION_RUNS_NULL, contentHash: hashProfileStatePayload('null') } } - const runs = parseAutomationRunValues(parsed) - if (runs === undefined) { + if (!Array.isArray(value)) { return undefined } - return { - presence: AUTOMATION_RUNS_ARRAY, - payload: `[${runs.map((run) => run.payload).join(',')}]`, - runs - } + return parseAutomationRunValues(value) } export function parseAutomationRunValues( values: readonly unknown[] -): AutomationRunPayload[] | undefined { +): ParsedAutomationRunsReplacement | undefined { const ids = new Set() const runs: AutomationRunPayload[] = [] + const aggregate = createHash('sha256').update('[') for (const [ordinal, value] of values.entries()) { if (!isRecord(value) || typeof value.id !== 'string' || ids.has(value.id)) { return undefined @@ -49,6 +52,10 @@ export function parseAutomationRunValues( if (runPayload === undefined) { return undefined } + if (ordinal > 0) { + aggregate.update(',') + } + aggregate.update(runPayload, 'utf8') ids.add(value.id) runs.push({ id: value.id, @@ -57,14 +64,9 @@ export function parseAutomationRunValues( contentHash: hashProfileStatePayload(runPayload) }) } - return runs -} - -export function hashAutomationRunsReplacement( - replacement: ParsedAutomationRunsReplacement -): string { - if (replacement.presence === AUTOMATION_RUNS_ABSENT) { - return '' + return { + presence: AUTOMATION_RUNS_ARRAY, + contentHash: aggregate.update(']').digest('hex'), + runs } - return hashProfileStatePayload(replacement.payload) } diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts b/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts index 4627e915966a..c84598d7add9 100644 --- a/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts +++ b/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts @@ -1,17 +1,17 @@ import type Database from '../../sqlite/sync-database' +import type { PreparedProfileStateMutation } from './profile-state-domain-write-validation' import { AUTOMATION_RUNS_ARRAY, AUTOMATION_RUNS_DOMAIN, PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, PROFILE_STATE_AUTOMATION_RUNS_TABLE, - type AutomationRunsReplacement, type AutomationRunIdentity, type ParsedAutomationRunsReplacement } from './profile-state-automation-runs-model' import { - hashAutomationRunsReplacement, parseAutomationRunValues, - parseAutomationRunsReplacement + parseAutomationRunsReplacement, + parseAutomationRunsValue } from './profile-state-automation-runs-payload' import { parseAutomationRunIdentity } from './profile-state-automation-runs-validation' import { @@ -44,18 +44,16 @@ export function rebuildProfileStateAutomationRunsProjection( export function prepareProfileStateAutomationRunsReplacement( db: Database.Database, - replacement: AutomationRunsReplacement, + replacement: PreparedProfileStateMutation, actualRevision: number ): AutomationRunsWritePreparation | undefined { const current = readCurrentAutomationRunsState(db, actualRevision) - const incoming = parseAutomationRunsReplacement(replacement.payload) + const incoming = parseAutomationRunsValue(replacement.automationRunsValue) if (incoming === undefined) { return undefined } return { - changed: - current.presence !== incoming.presence || - current.contentHash !== hashAutomationRunsReplacement(incoming), + changed: current.presence !== incoming.presence || current.contentHash !== incoming.contentHash, incoming, domainVersion: replacement.domainVersion, now: replacement.now @@ -69,20 +67,13 @@ export function prepareProfileStateAutomationRunsDelta( now: () => number, actualRevision: number ): AutomationRunsWritePreparation | undefined { - const runs = parseAutomationRunValues(after) - if (runs === undefined) { + const incoming = parseAutomationRunValues(after) + if (incoming === undefined) { return undefined } - const incoming: ParsedAutomationRunsReplacement = { - presence: AUTOMATION_RUNS_ARRAY, - payload: `[${runs.map((run) => run.payload).join(',')}]`, - runs - } const current = readCurrentAutomationRunsState(db, actualRevision) return { - changed: - current.presence !== incoming.presence || - current.contentHash !== hashAutomationRunsReplacement(incoming), + changed: current.presence !== incoming.presence || current.contentHash !== incoming.contentHash, incoming, domainVersion, now @@ -156,7 +147,7 @@ function applyIncomingAutomationRuns( domainVersion, nextRevision, now, - hashAutomationRunsReplacement(incoming) + incoming.contentHash ) compactAutomationRunsDocument(db) } diff --git a/src/main/persistence/profile-state/profile-state-database-quarantine.ts b/src/main/persistence/profile-state/profile-state-database-quarantine.ts index 98edbbc9fc8b..8e15f9fcf0d3 100644 --- a/src/main/persistence/profile-state/profile-state-database-quarantine.ts +++ b/src/main/persistence/profile-state/profile-state-database-quarantine.ts @@ -5,7 +5,7 @@ import { basename, dirname, join } from 'node:path' import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' -import { copyProfileStateRecoveryFile } from './profile-state-recovery-copy' +import { copyProfileStateRecoveryFiles } from './profile-state-recovery-copy' export type ProfileStateDatabaseQuarantine = { directory: string @@ -33,32 +33,41 @@ export function quarantineProfileStateDatabase( } const directory = join(quarantineRoot, `profile-state-corrupt-${Date.now()}-${randomUUID()}`) - const copiedFiles: string[] = [] + const targets = new Set() mkdirSync(directory, { recursive: true, mode: 0o700 }) try { + const copies: { source: string; target: string }[] = [] for (const sourcePath of sourceFiles) { const targetName = sourcePath === databasePath ? 'profile-state.db' : `profile-state.db${sourcePath.slice(databasePath.length)}` const targetPath = join(directory, targetName) - copyProfileStateRecoveryFile(sourcePath, targetPath) - hardenSqliteDatabaseFiles(targetPath) - fsyncFileSync(targetPath) - copiedFiles.push(targetPath) + copies.push({ source: sourcePath, target: targetPath }) + targets.add(targetPath) } for (const sourcePath of new Set(recoveryFiles)) { const targetPath = join(directory, basename(sourcePath)) - if (existsSync(targetPath) || basename(sourcePath) === 'manifest.json') { + if ( + targets.has(targetPath) || + existsSync(targetPath) || + basename(sourcePath) === 'manifest.json' + ) { throw new Error('Profile recovery artifact name conflicts with the quarantine manifest') } - copyProfileStateRecoveryFile(sourcePath, targetPath) - hardenSqliteDatabaseFiles(targetPath) - fsyncFileSync(targetPath) - copiedFiles.push(targetPath) + copies.push({ source: sourcePath, target: targetPath }) + targets.add(targetPath) } - hardenSqliteDatabaseFiles(join(directory, 'profile-state.db')) + copyProfileStateRecoveryFiles(copies) const manifestPath = join(directory, 'manifest.json') + // Let the destination filesystem detect case or Unicode aliases of the manifest name. + if (existsSync(manifestPath)) { + throw new Error('Profile recovery artifact name conflicts with the quarantine manifest') + } + for (const { target } of copies) { + hardenSqliteDatabaseFiles(target) + fsyncFileSync(target) + } writeFileDurableSync( durableWriteTempPath(manifestPath), manifestPath, @@ -72,7 +81,7 @@ export function quarantineProfileStateDatabase( }) ) bestEffortFsyncDirectorySync(directory) - return { directory, manifestPath, copiedFiles } + return { directory, manifestPath, copiedFiles: [...targets] } } catch (error) { rmSync(directory, { recursive: true, force: true }) throw error diff --git a/src/main/persistence/profile-state/profile-state-domain-equality.test.ts b/src/main/persistence/profile-state/profile-state-domain-equality.test.ts index b58c1d555c5b..df9dfe3efb93 100644 --- a/src/main/persistence/profile-state/profile-state-domain-equality.test.ts +++ b/src/main/persistence/profile-state/profile-state-domain-equality.test.ts @@ -109,6 +109,22 @@ describe('validated domain replacement equality', () => { } ) + it('ignores caller-supplied prepared history when writing another domain', () => { + const { db } = fixture() + const replacement = { + domain: 'settings', + payload: '{"changed":true}', + automationRuns: { incoming: { presence: 'absent', contentHash: '' }, domainVersion: 1 } + } + + writeProfileStateDomains(db, { expectedRevision: 1, replacements: [replacement] }) + + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ + settings: { changed: true }, + extension: { future: { content: '雪 🐋', nullable: null } } + }) + }) + it('fences a stale writer even when its payload remains equal', () => { const { db, payload } = fixture() writeProfileStateDomains(db, { diff --git a/src/main/persistence/profile-state/profile-state-domain-write-validation.ts b/src/main/persistence/profile-state/profile-state-domain-write-validation.ts index 44d982161cf7..bac86a1610aa 100644 --- a/src/main/persistence/profile-state/profile-state-domain-write-validation.ts +++ b/src/main/persistence/profile-state/profile-state-domain-write-validation.ts @@ -9,6 +9,8 @@ import type { export type PreparedProfileStateMutation = ProfileStateDomainMutation & { domainVersion: number payloadHash: string | null + // Keep the checked history value only for this write so normalization does not parse it again. + automationRunsValue?: unknown automationRuns?: AutomationRunsWritePreparation } @@ -42,17 +44,21 @@ export function prepareProfileStateDomainMutation( ): PreparedProfileStateMutation { const payloadHash = replacement.payload === null ? null : hashProfileStateJson(replacement.payload) + let parsed: unknown if (replacement.payload !== null) { try { - JSON.parse(replacement.payload) + parsed = JSON.parse(replacement.payload) } catch { throw new Error(`Profile state domain payload is invalid JSON: ${replacement.domain}`) } } return { - ...replacement, + domain: replacement.domain, + payload: replacement.payload, + now: replacement.now, domainVersion: replacement.domainVersion ?? PROFILE_STATE_DOCUMENT_VERSION, - payloadHash + payloadHash, + automationRunsValue: replacement.domain === 'automationRuns' ? parsed : undefined } } diff --git a/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts b/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts index 5a207373e5ed..a1b8216c49f4 100644 --- a/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts +++ b/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts @@ -84,7 +84,14 @@ function snapshot(path: string) { const boundaries = [ ...(process.platform === 'darwin' - ? ['clone:1:before', 'clone:1:after', 'clone:2:after', 'clone:3:after'] + ? [ + 'clone:1:before', + 'clone:1:after', + 'clone:2:before', + 'clone:2:after', + 'clone:3:after', + 'clone:4:after' + ] : []), 'primary', 'sqlite-publish:before', diff --git a/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts b/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts new file mode 100644 index 000000000000..82e4ccf708ee --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts @@ -0,0 +1,227 @@ +import { + closeSync, + copyFileSync, + existsSync, + ftruncateSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, + writeSync +} from 'node:fs' +import type * as FileSystem from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, isAbsolute, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as processes from '../../../shared/child-process/run-process' +import { copyProfileStateRecoveryFiles } from './profile-state-recovery-copy' +import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' + +const publication = vi.hoisted(() => ({ unsupportedTarget: '' })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { + ...actual, + linkSync: (...args: Parameters) => { + if (args[1] === publication.unsupportedTarget) { + throw Object.assign(new Error('hardlinks unavailable'), { code: 'ENOTSUP' }) + } + return actual.linkSync(...args) + } + } +}) + +const directories: string[] = [] +afterEach(() => { + publication.unsupportedTarget = '' + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(names = ['primary.db', 'backup.db'], large = true) { + const directory = mkdtempSync(join(tmpdir(), 'orca-recovery-batch-')) + directories.push(directory) + const files = names.map((name, index) => { + const sourceDirectory = join(directory, `source-${index}`) + mkdirSync(sourceDirectory) + const source = join(sourceDirectory, name) + const descriptor = openSync(source, 'wx', 0o600) + try { + ftruncateSync(descriptor, large ? 8 * 1024 * 1024 + 1 : 128) + writeSync(descriptor, Buffer.from(`retained-${index}`)) + } finally { + closeSync(descriptor) + } + return { source, target: join(directory, `restored-${index}.db`) } + }) + return { directory, files } +} + +function expectIndependent(files: ReturnType['files']): void { + for (const { source, target } of files) { + const original = readFileSync(source) + expect(readFileSync(target).equals(original)).toBe(true) + expect(statSync(source, { bigint: true }).ino).not.toBe(statSync(target, { bigint: true }).ino) + writeFileSync(source, 'changed source') + expect(readFileSync(target).equals(original)).toBe(true) + } +} + +function expectNoTemporary(directory: string): void { + expect(readdirSync(directory).some((name) => name.startsWith('.orca-recovery-clone-'))).toBe( + false + ) +} + +describe('batched profile recovery copies', () => { + it.each(['manifest.json', 'MANIFEST.JSON'])( + 'preserves a recovery artifact named %s without overwriting it with a manifest', + (name) => { + const { directory, files } = fixture(['primary.db', name], false) + const artifact = files[1].source + const original = readFileSync(artifact) + const quarantine = () => + quarantineProfileStateDatabase(files[0].source, 'profile', directory, 'test', [artifact]) + if (existsSync(join(dirname(artifact), 'manifest.json'))) { + expect(quarantine).toThrow('conflicts with the quarantine manifest') + expect( + readdirSync(directory).some((entry) => entry.startsWith('profile-state-corrupt-')) + ).toBe(false) + } else { + const result = quarantine() + expect(readFileSync(join(result.directory, name)).equals(original)).toBe(true) + } + expect(readFileSync(artifact).equals(original)).toBe(true) + expect(existsSync(files[0].source)).toBe(true) + } + ) + + it.each([false, true])('preserves every independent file with large copies=%s', (large) => { + const { directory, files } = fixture(undefined, large) + copyProfileStateRecoveryFiles(files) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it('does nothing for an empty batch', () => { + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFiles([]) + expect(run).not.toHaveBeenCalled() + }) + + describe.skipIf(process.platform !== 'darwin')('Darwin batch boundaries', () => { + it('shares one process with absolute arguments and the per-file timeout budget', () => { + const { directory, files } = fixture() + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFiles(files) + expect(run).toHaveBeenCalledOnce() + const spec = run.mock.calls[0]?.[0] + expect(spec?.args?.slice(1).every(isAbsolute)).toBe(true) + expect(spec?.args).toHaveLength(4) + expect(spec?.timeoutMs).toBe(60_000) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it('bounds a shared process when many large artifacts are retained', () => { + const { directory, files } = fixture( + Array.from({ length: 17 }, (_, index) => `backup-${index}.db`) + ) + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFiles(files) + expect(run).toHaveBeenCalledTimes(2) + expect(run.mock.calls.every(([spec]) => (spec.args?.length ?? 0) <= 18)).toBe(true) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it.each([ + ['same.db', 'same.db'], + ['CASE.db', 'case.db'], + ['é.db', 'e\u0301.db'], + ['suffix.db.', 'suffix.db'], + ['- leading space.db', 'plain.db'] + ])('isolates ambiguous source names %s and %s', (...names) => { + const { directory, files } = fixture(names) + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFiles(files) + expect(run).toHaveBeenCalledTimes(2) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it.each(['timeout', 'signal', 'spawn'] as const)( + 'publishes no cloned files after a batch %s failure', + (failure) => { + const { directory, files } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation(() => { + if (failure === 'spawn') { + throw new Error('copy process could not start') + } + return { + code: null, + signal: 'SIGTERM', + timedOut: failure === 'timeout', + stdout: '', + stderr: '' + } + }) + expect(() => copyProfileStateRecoveryFiles(files)).toThrow() + for (const { source, target } of files) { + expect(existsSync(source)).toBe(true) + expect(existsSync(target)).toBe(false) + } + expectNoTemporary(directory) + } + ) + + it('discards partial process output before falling back for every source', () => { + const { directory, files } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation((spec) => { + const temporaryDirectory = spec.args?.at(-1) + if (!temporaryDirectory) { + throw new Error('Missing clone directory') + } + expect(statSync(temporaryDirectory).mode & 0o777).toBe(0o700) + writeFileSync(join(temporaryDirectory, basename(files[0].source)), 'incomplete') + return { code: 1, signal: null, timedOut: false, stdout: '', stderr: 'clone unavailable' } + }) + copyProfileStateRecoveryFiles(files) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it('preserves a destination created during the batch process', () => { + const { directory, files } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation((spec) => { + const temporaryDirectory = spec.args?.at(-1) + if (!temporaryDirectory) { + throw new Error('Missing clone directory') + } + for (const { source } of files) { + copyFileSync(source, join(temporaryDirectory, basename(source))) + } + writeFileSync(files[1].target, 'concurrent destination') + return { code: 0, signal: null, timedOut: false, stdout: '', stderr: '' } + }) + expect(() => copyProfileStateRecoveryFiles(files)).toThrow() + expect(readFileSync(files[1].target, 'utf8')).toBe('concurrent destination') + expect(readFileSync(files[0].target).equals(readFileSync(files[0].source))).toBe(true) + expectNoTemporary(directory) + }) + + it('falls back independently when one destination cannot publish hardlinks', () => { + const { directory, files } = fixture() + publication.unsupportedTarget = files[1].target + copyProfileStateRecoveryFiles(files) + expectIndependent(files) + expectNoTemporary(directory) + }) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-recovery-copy.ts b/src/main/persistence/profile-state/profile-state-recovery-copy.ts index b9722d547b29..1c97e9ebd446 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-copy.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-copy.ts @@ -1,48 +1,80 @@ import { constants, copyFileSync, linkSync, lstatSync, mkdtempSync, rmSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { runProcessSync } from '../../../shared/child-process/run-process' // Keep process startup overhead off small recovery copies. const MINIMUM_CLONE_BYTES = 8 * 1024 * 1024 +const MAXIMUM_CLONE_FILES = 16 + +type RecoveryCopy = { source: string; target: string } /** Copy quiescent recovery artifacts independently; never copy an active WAL database this way. */ export function copyProfileStateRecoveryFile(source: string, target: string): void { - const sourceInfo = process.platform === 'darwin' ? lstatSync(source) : undefined - if (!sourceInfo?.isFile() || sourceInfo.size < MINIMUM_CLONE_BYTES) { - copyFileSync(source, target, constants.COPYFILE_EXCL) + copyProfileStateRecoveryFiles([{ source, target }]) +} + +export function copyProfileStateRecoveryFiles(files: readonly RecoveryCopy[]): void { + const clones: RecoveryCopy[] = [] + const names = new Set() + for (const file of files) { + const info = process.platform === 'darwin' ? lstatSync(file.source) : undefined + const name = basename(file.source) + if (!info?.isFile() || info.size < MINIMUM_CLONE_BYTES) { + copyFileSync(file.source, file.target, constants.COPYFILE_EXCL) + } else if ( + files.length > 1 && + (clones.length === MAXIMUM_CLONE_FILES || + !/^[a-z0-9][a-z0-9._-]*[a-z0-9]$/i.test(name) || + names.has(name.toLowerCase())) + ) { + // cp derives temporary basenames; ambiguous names need their own private directory. + copyProfileStateRecoveryFile(file.source, file.target) + } else { + clones.push(file) + names.add(name.toLowerCase()) + } + } + if (clones.length === 0) { return } - - // Node's clone flag is unsupported on Darwin; cp -c uses APFS clones with ordinary-copy fallback. - const directory = mkdtempSync(join(dirname(target), '.orca-recovery-clone-')) - const temporary = resolve(directory, 'copy') - let copied = false + const directory = mkdtempSync(join(dirname(clones[0].target), '.orca-recovery-clone-')) + const temporary = (source: string) => + join(directory, clones.length === 1 ? 'copy' : basename(source)) + let cloned = false try { + // Node's clone flag is unsupported on Darwin; cp -c falls back to ordinary copying. const result = runProcessSync({ program: '/bin/cp', - args: ['-c', resolve(source), temporary], - timeoutMs: 30_000, + args: [ + '-c', + ...clones.map(({ source }) => resolve(source)), + resolve(clones.length === 1 ? temporary(clones[0].source) : directory) + ], + timeoutMs: 30_000 * clones.length, maxOutputBytes: 16_384 }) if (result.timedOut || result.signal !== null) { throw new Error('Profile recovery file copy was interrupted') } if (result.code === 0) { - // Publish the independent clone without replacing a concurrently created destination. - try { - linkSync(temporary, target) - copied = true - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'EEXIST') { - throw error + for (const { source, target } of clones) { + try { + linkSync(temporary(source), target) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'EEXIST') { + throw error + } + copyFileSync(source, target, constants.COPYFILE_EXCL) } - // Some destination filesystems support copying but not hardlink publication. } + cloned = true } } finally { rmSync(directory, { recursive: true, force: true }) } - if (!copied) { - copyFileSync(source, target, constants.COPYFILE_EXCL) + if (!cloned) { + for (const { source, target } of clones) { + copyFileSync(source, target, constants.COPYFILE_EXCL) + } } } diff --git a/tests/e2e/profile-state-automatic-backup-recovery.spec.ts b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts index 0f2cde124cb7..d98a8575ac4e 100644 --- a/tests/e2e/profile-state-automatic-backup-recovery.spec.ts +++ b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts @@ -143,7 +143,13 @@ for (const recoveryRuntime of ['node', 'electron'] as const) { for (const file of [dataFile, rootJson, `${databasePath}-wal`, `${databasePath}-shm`]) { rmSync(file, { force: true }) } - writeFileSync(databasePath, 'deliberately corrupt SQLite primary') + // Preserve both large artifacts so quarantine exercises batched recovery copies. + const corruptPrimary = readFileSync(databasePath) + corruptPrimary.write('deliberately corrupt SQLite primary') + writeFileSync(databasePath, corruptPrimary) + if (recoveryRuntime === 'electron') { + expect(corruptPrimary.length).toBeGreaterThan(8 * 1024 * 1024) + } expect(() => readSnapshot(databasePath)).toThrow() const restored = await rollbackProfileBackup( session.userDataDir, @@ -151,7 +157,8 @@ for (const recoveryRuntime of ['node', 'electron'] as const) { recoveryExecutable ) expect(restored.code, restored.stderr || restored.stdout).toBe(0) - expect(JSON.parse(restored.stdout)).toMatchObject({ + const recovered = JSON.parse(restored.stdout) + expect(recovered).toMatchObject({ ok: true, result: { storage: 'sqlite', @@ -162,6 +169,17 @@ for (const recoveryRuntime of ['node', 'electron'] as const) { } }) expect(readSnapshot(databasePath)).toEqual(chosen) + const quarantineDirectory: unknown = recovered.result.quarantineDirectory + if (typeof quarantineDirectory !== 'string') { + throw new Error('Recovery did not report its quarantine directory') + } + expect( + readFileSync(path.join(quarantineDirectory, 'profile-state.db')).equals(corruptPrimary) + ).toBe(true) + expect( + readFileSync(path.join(quarantineDirectory, path.basename(backup.path))).equals(backupBytes) + ).toBe(true) + expect(readFileSync(backup.path).equals(backupBytes)).toBe(true) expect(existsSync(dataFile)).toBe(false) const relaunched = await session.launch() From 96a793cf23922781376a3b0bfe7e456ae08ba893 Mon Sep 17 00:00:00 2001 From: m4air Date: Wed, 23 Sep 2026 21:42:26 -0700 Subject: [PATCH 05/43] Persist live profile SQLite state in a dedicated worker --- .../build-plugins/plain-node-entry-guard.ts | 3 +- config/scripts/build-orcad.mjs | 31 +- config/scripts/profile-state-worker-smoke.mjs | 134 +++++++ .../profile-state-worker-smoke.test.mjs | 91 +++++ config/tsconfig.cli.json | 4 + electron.vite.config.ts | 3 + src/main/active-view-preference.ts | 21 ++ .../codex-accounts/codex-account-selection.ts | 4 +- .../codex-reset-credit-coordinator.ts | 11 +- .../codex-reset-credit-ledger.test.ts | 131 +++++++ .../codex-reset-credit-ledger.ts | 89 +++-- .../service-reset-credit-durability.test.ts | 45 ++- ...ervice-reset-credit-home-ownership.test.ts | 2 +- ...ervice-reset-credit-target-routing.test.ts | 12 +- .../codex-accounts/service-test-harness.ts | 8 +- src/main/ipc/orca-profiles.test.ts | 67 ++-- src/main/ipc/orca-profiles.ts | 39 +- .../pty-pane-reservation-settlement.test.ts | 5 +- .../pty-persisted-incarnation-repair.test.ts | 9 +- src/main/ipc/pty/ipc/spawn-commit-persist.ts | 13 +- ...spawn-commit-ssh-lease-cardinality.test.ts | 4 +- src/main/ipc/pty/pane/stable-owner.ts | 76 ++-- ...ble-pane-absence-death-certificate.test.ts | 6 +- .../stable-pane-relay-absence-respawn.test.ts | 6 +- src/main/ipc/pty/runtime/spawn-commit.ts | 6 +- src/main/ipc/session.ts | 21 +- .../profile-active-transfer-worker.test.ts | 127 +++++++ .../profile-active-transfer.test.ts | 8 +- .../orca-profiles/profile-active-transfer.ts | 23 +- .../profile-persistence-deadline.ts | 12 +- src/main/orcad/orcad-entry.test.ts | 16 +- src/main/orcad/orcad-entry.ts | 4 +- src/main/orcad/orcad-lifecycle.ts | 8 +- .../orcad/orcad-profile-state-startup.test.ts | 56 ++- src/main/orcad/orcad-profile-state-startup.ts | 24 +- ...sistence-flush-and-save-scheduling.test.ts | 163 +++++---- ...-host-admitted-terminal-membership.test.ts | 10 +- ...-host-partitioned-ssh-pty-bindings.test.ts | 12 +- src/main/persistence-initial-load.test.ts | 50 +-- ...ce-pty-binding-leaf-tab-resolution.test.ts | 4 +- ...istence-pty-binding-reconciliation.test.ts | 12 +- ...persistence-split-pane-incarnation.test.ts | 22 +- src/main/persistence-ui-state.test.ts | 35 ++ ...sistence-worktree-deletion-fencing.test.ts | 12 +- .../ssh-pty-consumer-recovery.ts | 53 +-- .../ssh-pty-lease-operations.ts | 30 +- .../primary-state-write-runtime.ts | 5 + .../loading-store/primary-state-write-sync.ts | 22 +- .../primary-state-write-worker.ts | 99 +++++ .../loading-store/primary-state-writes.ts | 144 ++++++-- .../loading-store/profile-state-authority.ts | 61 +++- ...profile-state-delayed-authority-fixture.ts | 130 +++++++ .../profile-state-direct-flush.test.ts | 4 +- .../profile-state-flush-lifetime.ts | 34 ++ .../profile-state-maintenance-fixture.ts | 103 ++++++ .../profile-state-maintenance.test.ts | 287 +++++++++++++++ .../profile-state-maintenance.ts | 161 +++++++++ .../profile-state-selective-write.ts | 86 +++-- .../profile-state-sqlite-authority.test.ts | 6 +- .../profile-state-worker-coordination.test.ts | 205 +++++++++++ ...file-state-worker-secret-retention.test.ts | 90 +++++ .../pty-binding-async-durability.test.ts | 154 ++++++++ .../loading-store/pty-binding-persistence.ts | 277 +++++--------- .../pty-binding-session-update.ts | 136 +++++++ .../session-snapshot-operations.ts | 16 +- .../ssh-lease-async-durability.test.ts | 128 +++++++ .../ssh-lease-durable-mutation.ts | 42 +++ .../ssh-lease-recovery-operations.ts | 44 ++- .../loading-store/store-runtime-state.ts | 16 +- src/main/persistence/loading-store/store.ts | 139 ++++--- .../loading-store/write-flush-barriers.ts | 222 +++++++----- .../loading-store/write-scheduling.ts | 6 +- .../profile-state-authority-bootstrap.ts | 10 +- ...ile-state-authority-export-fencing.test.ts | 43 +++ .../profile-state-authority-exports.ts | 75 ++++ .../profile-state-backup-rotation.ts | 5 +- .../profile-state-complete-replacements.ts | 36 ++ .../profile-state/profile-state-database.ts | 16 +- .../profile-state-domain-writes.ts | 3 +- .../profile-state-live-store-factory.test.ts | 152 ++++++++ .../profile-state-live-store-factory.ts | 51 +++ .../profile-state/profile-state-migration.ts | 2 +- .../profile-state-revision-readmission.ts | 20 + .../profile-state-sqlite-authority.ts | 232 ++++++------ .../profile-state-startup-authority.test.ts | 109 ++++-- .../profile-state-startup-authority.ts | 16 +- .../profile-state-startup-failure.test.ts | 21 ++ .../profile-state-startup-failure.ts | 22 +- .../profile-state-store-factory.ts | 21 +- .../profile-state-versioned-export.ts | 41 +++ .../profile-state-worker-authority.ts | 158 ++++++++ .../profile-state-write-transaction.test.ts | 42 ++- .../profile-state-write-transaction.ts | 16 +- .../profile-state-writer-connection.ts | 287 +++++++++++++++ .../profile-state-writer-errors.ts | 89 +++++ ...ofile-state-writer-protocol-faults.test.ts | 95 +++++ .../profile-state-writer-protocol.ts | 115 ++++++ .../profile-state-writer-request.ts | 60 +++ .../profile-state-writer-worker-client.ts | 75 ++++ .../profile-state-writer-worker-entry.ts | 153 ++++++++ .../profile-state-writer-worker-path.ts | 17 + .../profile-state-writer-worker.test.ts | 341 ++++++++++++++++++ .../workspace-session-write-rollback.ts | 132 +++++++ ...ted-secret-persistence-concurrency.test.ts | 192 ++++++++++ src/main/protected-secret-persistence.ts | 27 +- ...time-terminal-close-continuity-fixtures.ts | 9 +- ...wledged-terminal-tab-retirement-fixture.ts | 18 +- ...knowledged-terminal-tab-retirement.test.ts | 6 +- ...ude-structured-session-integration.test.ts | 1 + .../folder-workspace-pty-identity.test.ts | 6 +- ...c-live-daemon-pty-tab-preservation.test.ts | 5 +- ...less-close-keeps-publication-epoch.test.ts | 17 +- ...minal-close-persistence-durability.test.ts | 16 +- ...ld-headless-mobile-session-browser-tabs.ts | 92 +++-- ...time-close-headless-mobile-terminal-tab.ts | 12 +- .../orca-runtime-close-mobile-session-tab.ts | 26 +- ...obile-close-preserved-resurrection.test.ts | 9 +- src/main/runtime/orca-runtime-on-pty-exit.ts | 12 +- ...me-persist-terminal-surface-retirements.ts | 145 ++++---- ...rca-runtime-stop-terminals-for-worktree.ts | 93 ++--- ...terminal-retirement-host-partition.test.ts | 25 +- .../orca-runtime-terminal-retirement.test.ts | 107 +++--- .../orca-runtime-test-fixtures.spec.ts | 64 ++-- ...rca-runtime-test-scenario-builders.spec.ts | 3 +- .../mobile-session-tabs-part-04.spec.ts | 37 +- .../mobile-session-tabs-part-05.spec.ts | 11 +- .../mobile-session-tabs-part-08.spec.ts | 5 +- .../mobile-session-tabs-part-11.spec.ts | 2 +- ...nal-creation-and-readiness-part-05.spec.ts | 2 +- .../terminal-handles-and-agent-status.spec.ts | 13 +- ...output-and-worker-recovery-part-02.spec.ts | 47 ++- ...output-and-worker-recovery-part-03.spec.ts | 5 +- ...output-and-worker-recovery-part-04.spec.ts | 9 +- ...output-and-worker-recovery-part-05.spec.ts | 11 +- ...output-and-worker-recovery-part-06.spec.ts | 21 +- ...output-and-worker-recovery-part-07.spec.ts | 55 +-- ...retirement-proof-publication-order.test.ts | 28 +- ...y-inventory-partial-relay-liveness.test.ts | 6 +- .../runtime/runtime-durable-store-fixture.ts | 40 ++ ...cy-worker-terminal-recovery-persistence.ts | 119 +++--- src/main/runtime/runtime-store-contract.ts | 1 + .../runtime-terminal-orphan-adoption.ts | 2 +- ...rminal-retirement-async-durability.test.ts | 70 ++++ ...workspace-session-failed-write-rollback.ts | 74 ---- .../ssh-reattach-pane-cardinality.test.ts | 46 +-- .../ssh/ssh-orphan-relay-pty-sweep.test.ts | 2 +- src/main/ssh/ssh-pty-consumer-recovery.ts | 5 +- .../ssh-relay-session-data-delivery.test.ts | 3 +- ...elay-session-reconnect-incarnation.test.ts | 12 +- .../ssh/ssh-relay-session-test-fixtures.ts | 19 +- src/main/ssh/ssh-relay-session.ts | 89 +++-- .../startup/main-process-ready-foundation.ts | 2 +- ...-process-ready-persistence-cleanup.test.ts | 81 +++++ src/main/startup/main-process-ready.ts | 35 +- src/main/startup/main-window-core-services.ts | 4 +- src/preload/api/orca-profiles-bridge.ts | 35 +- .../app-restart-checkpoint-routing.test.ts | 95 +++++ src/preload/renderer-restart-wiring.test.ts | 7 +- src/preload/renderer-restart-wiring.ts | 31 +- src/shared/orcad-artifacts.ts | 2 + src/shared/updater-renderer-events.ts | 1 + 161 files changed, 6900 insertions(+), 1606 deletions(-) create mode 100644 config/scripts/profile-state-worker-smoke.mjs create mode 100644 config/scripts/profile-state-worker-smoke.test.mjs create mode 100644 src/main/codex-accounts/codex-reset-credit-ledger.test.ts create mode 100644 src/main/orca-profiles/profile-active-transfer-worker.test.ts create mode 100644 src/main/persistence/loading-store/primary-state-write-worker.ts create mode 100644 src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts create mode 100644 src/main/persistence/loading-store/profile-state-flush-lifetime.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance-fixture.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance.ts create mode 100644 src/main/persistence/loading-store/profile-state-worker-coordination.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts create mode 100644 src/main/persistence/loading-store/pty-binding-async-durability.test.ts create mode 100644 src/main/persistence/loading-store/pty-binding-session-update.ts create mode 100644 src/main/persistence/loading-store/ssh-lease-async-durability.test.ts create mode 100644 src/main/persistence/loading-store/ssh-lease-durable-mutation.ts create mode 100644 src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-authority-exports.ts create mode 100644 src/main/persistence/profile-state/profile-state-complete-replacements.ts create mode 100644 src/main/persistence/profile-state/profile-state-live-store-factory.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-live-store-factory.ts create mode 100644 src/main/persistence/profile-state/profile-state-revision-readmission.ts create mode 100644 src/main/persistence/profile-state/profile-state-versioned-export.ts create mode 100644 src/main/persistence/profile-state/profile-state-worker-authority.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-connection.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-errors.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-protocol.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-request.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-worker-client.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-worker-entry.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-worker-path.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-worker.test.ts create mode 100644 src/main/persistence/restoring-sessions/workspace-session-write-rollback.ts create mode 100644 src/main/protected-secret-persistence-concurrency.test.ts create mode 100644 src/main/runtime/runtime-durable-store-fixture.ts create mode 100644 src/main/runtime/terminal-retirement-async-durability.test.ts delete mode 100644 src/main/runtime/workspace-session-failed-write-rollback.ts create mode 100644 src/main/startup/main-process-ready-persistence-cleanup.test.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 234fc9877ad6..75d386d0c401 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -42,7 +42,8 @@ const WORKER_THREAD_ENTRY_NAMES = [ 'main-thread-hang-watchdog-entry', 'port-scan-command-worker-entry', 'usage-scan-worker-entry', - 'profile-state-backup-worker-entry' + 'profile-state-backup-worker-entry', + 'profile-state-writer-worker-entry' ] as const export const GUARDED_ENTRY_NAMES = [ diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index a0489ba21ff9..0b882689f947 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -21,6 +21,7 @@ import { import { arch, platform, tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' +import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' import { ORCAD_VERSION_FILENAME, ORCAD_RIPGREP_ARTIFACTS @@ -99,10 +100,8 @@ cpSync(join(ROOT, 'resources', 'licenses', 'ripgrep'), join(OUT_DIR, 'ripgrep', recursive: true }) -/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source - * directory under `outdir`, and both children must land flat beside orcad.js — that is where - * their runtime resolvers look for them. */ -function buildForkedChild(entryPoint, outfile) { +// Child and worker resolvers require flat entries beside orcad.js. +function buildIsolatedEntry(entryPoint, outfile) { return build({ entryPoints: [entryPoint], bundle: true, @@ -120,9 +119,15 @@ function buildForkedChild(entryPoint, outfile) { }) } -const childResults = await Promise.all([ - buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), - buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE) +const isolatedResults = await Promise.all([ + buildIsolatedEntry(WATCHER_ENTRY, WATCHER_OUT_FILE), + buildIsolatedEntry(DAEMON_ENTRY, DAEMON_OUT_FILE), + ...['writer', 'backup'].map((role) => + buildIsolatedEntry( + join(ROOT, `src/main/persistence/profile-state/profile-state-${role}-worker-entry.ts`), + join(OUT_DIR, `profile-state-${role}-worker-entry.js`) + ) + ) ]) const result = await build({ @@ -147,9 +152,7 @@ const output = Object.values(result.metafile.outputs).find( // Why check `original` and not just `path`: when electron is bundleable, esbuild // rewrites `path` to the resolved file under node_modules and the naive check passes // while the package is very much in the bundle. -// Why both metafiles: the forked children ship in the same deployment and run under the -// same plain Node. A daemon-entry that reached electron would fail at fork time, on the -// path whose whole point is that terminals survive. +// Every isolated entry ships under the same plain-Node compatibility contract. function collectImporters(metafiles, matches) { const importers = new Set() for (const metafile of metafiles) { @@ -164,7 +167,7 @@ function collectImporters(metafiles, matches) { return importers } -const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)] +const metafiles = [result.metafile, ...isolatedResults.map((entry) => entry.metafile)] const electronImporters = collectImporters( metafiles, (specifier) => specifier === 'electron' || specifier.startsWith('electron/') @@ -254,6 +257,12 @@ if (graphErrors.length > 0) { ) process.exitCode = 1 } + try { + await smokeProfileStateWorkers(OUT_DIR) + } catch (error) { + console.error('[build-orcad] profile state worker check failed:', error) + process.exitCode = 1 + } } // Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on diff --git a/config/scripts/profile-state-worker-smoke.mjs b/config/scripts/profile-state-worker-smoke.mjs new file mode 100644 index 000000000000..a866c9da5540 --- /dev/null +++ b/config/scripts/profile-state-worker-smoke.mjs @@ -0,0 +1,134 @@ +import { deepStrictEqual } from 'node:assert' +import { build } from 'esbuild' +import { mkdtempSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { Worker } from 'node:worker_threads' + +async function initializeFixture(directory, databasePath, profileId) { + const fixture = join(directory, 'initialize.cjs') + await build({ + stdin: { + contents: `import { openProfileStateDatabase } from './src/main/persistence/profile-state/profile-state-database'; + export function initialize(path, profileId) { openProfileStateDatabase(path, profileId).db.close() }`, + resolveDir: resolve(import.meta.dirname, '../..'), + sourcefile: 'profile-state-build-fixture.ts' + }, + outfile: fixture, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) + createRequire(import.meta.url)(fixture).initialize(databasePath, profileId) +} + +function runWorker(entry, workerData, steps, timeoutMs) { + return new Promise((resolve, reject) => { + const worker = new Worker(entry, { workerData, execArgv: [] }) + let received = 0 + let failure + const stop = (error) => { + failure ??= error + void worker.terminate().catch((terminationError) => { + failure ??= terminationError + }) + } + const timer = setTimeout(() => stop(new Error(`${entry} timed out`)), timeoutMs) + worker.on('message', (response) => { + if (failure) { + return + } + const step = steps[received] + if (!step) { + stop(new Error(`${entry} sent an unexpected response`)) + return + } + try { + if (response?.ok === false) { + throw new Error(`${entry}: ${response.error?.message ?? response.error}`) + } + for (const [key, expected] of Object.entries(step.reply)) { + deepStrictEqual(response?.[key], expected, `${entry}: unexpected ${key}`) + } + received++ + const next = steps[received] + if (next) { + worker.postMessage(next.request) + } + } catch (error) { + stop(error) + } + }) + worker.on('error', (error) => { + failure ??= error + }) + worker.once('exit', (code) => { + clearTimeout(timer) + if (failure || code !== 0 || received !== steps.length) { + reject(failure ?? new Error(`${entry} exited before completing its protocol (${code})`)) + } else { + resolve() + } + }) + }) +} + +/** Exercise the shipped entries and copied state before publishing their content version. */ +export async function smokeProfileStateWorkers(outDir, { timeoutMs = 30_000 } = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-worker-smoke-')) + const databasePath = join(directory, 'profile.db') + const targetPath = join(directory, 'backup.db') + const profileId = 'build-smoke' + const payload = JSON.stringify({ theme: 'dark', witness: 'saved \ud800 \u{1f419}' }) + try { + await initializeFixture(directory, databasePath, profileId) + await runWorker( + join(outDir, 'profile-state-writer-worker-entry.js'), + { databasePath, profileId, revision: 0 }, + [ + { reply: { id: 0, ok: true, revision: 0 } }, + { + request: { + id: 1, + command: 'write-complete', + replacements: [{ domain: 'settings', payload }] + }, + reply: { id: 1, ok: true, revision: 1 } + }, + { + request: { id: 2, command: 'close' }, + reply: { id: 2, ok: true, revision: 1 } + } + ], + timeoutMs + ) + await runWorker( + join(outDir, 'profile-state-backup-worker-entry.js'), + { databasePath, profileId, targetPath }, + [{ reply: { ok: true } }], + timeoutMs + ) + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const database = new DatabaseSync(targetPath, { readOnly: true }) + try { + deepStrictEqual(database.prepare('PRAGMA quick_check').get()['quick_check'], 'ok') + deepStrictEqual( + database + .prepare("SELECT payload FROM profile_state_documents WHERE domain = 'settings'") + .get()?.payload, + payload + ) + deepStrictEqual( + database.prepare("SELECT value FROM profile_state_meta WHERE key = 'revision'").get() + ?.value, + '1' + ) + } finally { + database.close() + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/profile-state-worker-smoke.test.mjs b/config/scripts/profile-state-worker-smoke.test.mjs new file mode 100644 index 000000000000..06f45e9fd6d7 --- /dev/null +++ b/config/scripts/profile-state-worker-smoke.test.mjs @@ -0,0 +1,91 @@ +import { build } from 'esbuild' +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' + +const directories = [] +const writerFilename = 'profile-state-writer-worker-entry.js' +const backupFilename = 'profile-state-backup-worker-entry.js' +let builtDirectory + +function fixtureDirectory() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-worker-build-test-')) + directories.push(directory) + return directory +} + +beforeAll(async () => { + builtDirectory = fixtureDirectory() + await Promise.all( + ['writer', 'backup'].map((role) => + build({ + entryPoints: [ + resolve(`src/main/persistence/profile-state/profile-state-${role}-worker-entry.ts`) + ], + outfile: join(builtDirectory, `profile-state-${role}-worker-entry.js`), + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + logLevel: 'silent' + }) + ) + ) +}) + +afterAll(() => { + for (const directory of directories) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('profile state build smoke', () => { + it('writes through the built writer and verifies the built backup after handle release', async () => { + await expect(smokeProfileStateWorkers(builtDirectory)).resolves.toBeUndefined() + }) + + it('rejects a worker that exits without completing its protocol', async () => { + const directory = fixtureDirectory() + writeFileSync(join(directory, writerFilename), 'process.exit(0)\n') + await expect(smokeProfileStateWorkers(directory)).rejects.toThrow('before completing') + }) + + it('rejects a mismatched startup revision', async () => { + const directory = fixtureDirectory() + writeFileSync( + join(directory, writerFilename), + `const { parentPort } = require('node:worker_threads') + parentPort.postMessage({ id: 0, ok: true, revision: 7 }) + parentPort.close()` + ) + await expect(smokeProfileStateWorkers(directory)).rejects.toThrow('unexpected revision') + }) + + it('does not accept a close acknowledgement from a worker that remains alive', async () => { + const directory = fixtureDirectory() + writeFileSync( + join(directory, writerFilename), + `const { parentPort } = require('node:worker_threads') + parentPort.postMessage({ id: 0, ok: true, revision: 0 }) + parentPort.on('message', ({ id }) => parentPort.postMessage({ id, ok: true, revision: 1 })) + setInterval(() => {}, 1000)` + ) + await expect(smokeProfileStateWorkers(directory, { timeoutMs: 2_000 })).rejects.toThrow( + 'timed out' + ) + }) + + it('does not accept a successful backup response without the copied database', async () => { + const directory = fixtureDirectory() + copyFileSync(join(builtDirectory, writerFilename), join(directory, writerFilename)) + writeFileSync( + join(directory, backupFilename), + `const { parentPort } = require('node:worker_threads') + parentPort.postMessage({ ok: true }) + parentPort.close()` + ) + await expect(smokeProfileStateWorkers(directory)).rejects.toThrow() + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 8308e0c72681..182d82056974 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -30,6 +30,10 @@ "../src/main/persistence/profile-state/profile-state-revision.ts", "../src/main/persistence/profile-state/profile-state-read-snapshot.ts", "../src/main/persistence/profile-state/profile-state-sqlite-authority.ts", + "../src/main/persistence/profile-state/profile-state-authority-exports.ts", + "../src/main/persistence/profile-state/profile-state-complete-replacements.ts", + "../src/main/persistence/profile-state/profile-state-revision-readmission.ts", + "../src/main/persistence/profile-state/profile-state-writer-protocol.ts", "../src/main/persistence/loading-store/profile-state-authority.ts", "../src/main/persistence/profile-state/profile-state-automation-runs-migration.ts", "../src/main/persistence/profile-state/profile-state-document-reader.ts", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index db2a9530562d..789360c6c4d3 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -249,6 +249,9 @@ export const electronViteConfig: UserConfig = { 'profile-state-backup-worker-entry': resolve( 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts' ), + 'profile-state-writer-worker-entry': resolve( + 'src/main/persistence/profile-state/profile-state-writer-worker-entry.ts' + ), // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults // can't take down the main process (issue #7547). 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), diff --git a/src/main/active-view-preference.ts b/src/main/active-view-preference.ts index eb6098f8c1d5..41115306e95c 100644 --- a/src/main/active-view-preference.ts +++ b/src/main/active-view-preference.ts @@ -43,6 +43,7 @@ export class ActiveViewPreference { /** Set by flushAsync so the quit flush is the final write; see scheduleSave. */ private quitFlushStarted = false private quitFlushPromise: Promise | null = null + private maintenancePaused = false constructor(dataFile: string, legacyActiveView: unknown) { this.file = getActiveViewPreferenceFile(dataFile) @@ -78,6 +79,9 @@ export class ActiveViewPreference { return } this.writeGeneration += 1 + if (this.maintenancePaused) { + return + } if (this.writeTimer) { clearTimeout(this.writeTimer) } @@ -246,4 +250,21 @@ export class ActiveViewPreference { await this.pendingWrite } } + + pauseForMaintenance(): () => void { + if (this.maintenancePaused || this.quitFlushStarted) { + throw new Error('Active-view persistence is already paused') + } + this.maintenancePaused = true + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + return () => { + this.maintenancePaused = false + if (this.activeView !== this.persistedActiveView) { + this.scheduleSave() + } + } + } } diff --git a/src/main/codex-accounts/codex-account-selection.ts b/src/main/codex-accounts/codex-account-selection.ts index ad97d80696ba..a95c1da9f34e 100644 --- a/src/main/codex-accounts/codex-account-selection.ts +++ b/src/main/codex-accounts/codex-account-selection.ts @@ -28,7 +28,7 @@ type CodexAccountSelectionDependencies = { lifecycle: CodexAccountServiceLifecycle resolveSystemDefault: () => CodexSystemDefaultIdentity removeManagedHome: (candidatePath: string, expectedAccountId: string) => void - discardResetAttempts: (accountId: string) => void + discardResetAttempts: (accountId: string) => Promise } export class CodexAccountSelection { @@ -86,7 +86,7 @@ export class CodexAccountSelection { // Why: a removed account can no longer appear in the switcher dropdown, // so purge its cached usage to avoid stale entries. this.dependencies.rateLimits.evictInactiveCodexCache(accountId) - this.dependencies.discardResetAttempts(accountId) + await this.dependencies.discardResetAttempts(accountId) const accountTarget = getCodexSelectionTargetForAccount(account) this.startQuotaRefresh( getSelectedCodexAccountIdForTarget(settings, accountTarget) === accountId diff --git a/src/main/codex-accounts/codex-reset-credit-coordinator.ts b/src/main/codex-accounts/codex-reset-credit-coordinator.ts index 5d6233d46eb1..cd0c8ad8569f 100644 --- a/src/main/codex-accounts/codex-reset-credit-coordinator.ts +++ b/src/main/codex-accounts/codex-reset-credit-coordinator.ts @@ -171,8 +171,8 @@ export class CodexResetCreditCoordinator { }) } - discardForRemovedAccount(accountId: string): void { - this.ledger.discardForRemovedAccount(accountId) + discardForRemovedAccount(accountId: string): Promise { + return this.ledger.discardForRemovedAccount(accountId) } private startAttempt( @@ -182,6 +182,9 @@ export class CodexResetCreditCoordinator { ): Promise { const promise = this.dependencies.serializeMutation( async (): Promise => { + if (this.ledger.error) { + throw this.ledger.error + } const isFresh = attempt.state === 'fresh' let validation: { managedHomePath: string; rateLimits: RateLimitState } try { @@ -204,7 +207,7 @@ export class CodexResetCreditCoordinator { throw error } if (isFresh) { - this.ledger.markProviderPending(idempotencyKey, attempt) + await this.ledger.markProviderPending(idempotencyKey, attempt) } const { outcome, state } = await this.dependencies.rateLimits.consumeCodexRateLimitResetCredit({ @@ -220,7 +223,7 @@ export class CodexResetCreditCoordinator { codex: this.dependencies.getSnapshot(), rateLimits: state } - this.ledger.markSettled(idempotencyKey, attempt, outcome) + await this.ledger.markSettled(idempotencyKey, attempt, outcome) return result } ) diff --git a/src/main/codex-accounts/codex-reset-credit-ledger.test.ts b/src/main/codex-accounts/codex-reset-credit-ledger.test.ts new file mode 100644 index 000000000000..32f13e33d786 --- /dev/null +++ b/src/main/codex-accounts/codex-reset-credit-ledger.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger' +import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' +import { ProfileStateWriterError } from '../persistence/profile-state/profile-state-writer-errors' +import { CodexResetCreditLedger } from './codex-reset-credit-ledger' + +function scope(accountId: string): CodexResetCreditExpectedScope { + return { + target: { runtime: 'host', wslDistro: null }, + accountId, + accountRevision: 1, + offerRevision: 'offer-1' + } +} + +function setup() { + let durable: CodexResetCreditAttemptLedger = { version: 1, attempts: [] } + const barrier = vi.fn(async () => {}) + const store = { + getCodexResetCreditAttemptLedger: () => structuredClone(durable), + replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn( + async (next: CodexResetCreditAttemptLedger) => { + await barrier() + durable = structuredClone(next) + } + ) + } + return { ledger: new CodexResetCreditLedger(store), store, barrier } +} + +describe('async reset-credit ledger', () => { + it('serializes replacement construction so concurrent account writes survive', async () => { + const { ledger, store, barrier } = setup() + const gate = Promise.withResolvers() + barrier.mockImplementationOnce(() => gate.promise) + const first = ledger.createFresh('first', scope('account-1')) + const second = ledger.createFresh('second', scope('account-2')) + const pendingFirst = ledger.markProviderPending('first', first) + const pendingSecond = ledger.markProviderPending('second', second) + await vi.waitFor(() => expect(barrier).toHaveBeenCalledOnce()) + expect(first.state).toBe('fresh') + expect(second.state).toBe('fresh') + expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) + + gate.resolve() + await Promise.all([pendingFirst, pendingSecond]) + expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([ + { idempotencyKey: 'first', state: 'providerPending' }, + { idempotencyKey: 'second', state: 'providerPending' } + ]) + expect(ledger.getUnresolvedKey(first.accountScopeKey)).toBe('first') + expect(ledger.getUnresolvedKey(second.accountScopeKey)).toBe('second') + }) + + it('keeps pending guards until settlement commits and can retry a known failure', async () => { + const { ledger, store, barrier } = setup() + const attempt = ledger.createFresh('first', scope('account-1')) + await ledger.markProviderPending('first', attempt) + const gate = Promise.withResolvers() + barrier.mockImplementationOnce(() => gate.promise) + const settled = ledger.markSettled('first', attempt, 'reset') + const rejected = expect(settled).rejects.toThrow('disk full') + await vi.waitFor(() => expect(barrier).toHaveBeenCalledTimes(2)) + expect(attempt.state).toBe('providerPending') + expect(ledger.getUnresolvedKey(attempt.accountScopeKey)).toBe('first') + + gate.reject(new Error('disk full')) + await rejected + expect(attempt.state).toBe('providerPending') + expect(store.getCodexResetCreditAttemptLedger().attempts[0]?.state).toBe('providerPending') + expect(ledger.error).toBeNull() + await ledger.markSettled('first', attempt, 'alreadyRedeemed') + expect(attempt.settledOutcome).toBe('alreadyRedeemed') + expect(ledger.getUnresolvedKey(attempt.accountScopeKey)).toBeUndefined() + }) + + it('waits for queued writes before removing an account and retains other accounts', async () => { + const { ledger, store, barrier } = setup() + const first = ledger.createFresh('first', scope('account-1')) + const second = ledger.createFresh('second', scope('account-2')) + await ledger.markProviderPending('first', first) + const gate = Promise.withResolvers() + barrier.mockImplementationOnce(() => gate.promise) + const pendingSecond = ledger.markProviderPending('second', second) + const removed = ledger.discardForRemovedAccount('account-1') + await vi.waitFor(() => expect(barrier).toHaveBeenCalledTimes(2)) + expect(ledger.get('first')).toBe(first) + + gate.resolve() + await Promise.all([pendingSecond, removed]) + expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([ + { idempotencyKey: 'second', state: 'providerPending' } + ]) + expect(ledger.get('first')).toBeUndefined() + expect(ledger.getUnresolvedKey(first.accountScopeKey)).toBeUndefined() + expect(ledger.get('second')).toBe(second) + }) + + it('retains the removed account guard when its async durability barrier fails', async () => { + const { ledger, barrier } = setup() + const attempt = ledger.createFresh('first', scope('account-1')) + await ledger.markProviderPending('first', attempt) + barrier.mockRejectedValueOnce(new Error('disk full')) + await expect(ledger.discardForRemovedAccount('account-1')).rejects.toThrow('disk full') + expect(ledger.get('first')).toBe(attempt) + expect(ledger.getUnresolvedKey(attempt.accountScopeKey)).toBe('first') + }) + + it('fails queued and future mutations closed when a commit outcome is unknown', async () => { + const { ledger, store, barrier } = setup() + const attempt = ledger.createFresh('first', scope('account-1')) + const second = ledger.createFresh('second', scope('account-2')) + const gate = Promise.withResolvers() + barrier.mockImplementationOnce(() => gate.promise) + const pending = ledger.markProviderPending('first', attempt) + const queued = ledger.markProviderPending('second', second) + const rejected = expect(pending).rejects.toThrow('worker stopped') + const blocked = expect(queued).rejects.toThrow('durability is unknown') + await vi.waitFor(() => expect(barrier).toHaveBeenCalledOnce()) + gate.reject(new ProfileStateWriterError('worker-exit', 'worker stopped', 'indeterminate')) + await Promise.all([rejected, blocked]) + + ledger.releaseFresh('first', attempt) + expect(ledger.get('first')).toBe(attempt) + expect(ledger.getClaimedKey(attempt.scopeKey)).toBe('first') + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledOnce() + await expect(ledger.discardForRemovedAccount('account-1')).rejects.toThrow( + 'durability is unknown' + ) + }) +}) diff --git a/src/main/codex-accounts/codex-reset-credit-ledger.ts b/src/main/codex-accounts/codex-reset-credit-ledger.ts index 625731f7d385..77590bcfe362 100644 --- a/src/main/codex-accounts/codex-reset-credit-ledger.ts +++ b/src/main/codex-accounts/codex-reset-credit-ledger.ts @@ -9,6 +9,7 @@ import type { RateLimitRuntimeTarget } from '../../shared/rate-limit-types' import type { Store } from '../persistence' +import { profileStateWriterFailureOutcome } from '../persistence/profile-state/profile-state-writer-errors' export type CodexResetCreditAttempt = { expectedScope: CodexResetCreditExpectedScope @@ -45,14 +46,20 @@ export class CodexResetCreditLedger { private readonly attemptKeyByOffer = new Map() private readonly unresolvedKeyByAccountScope = new Map() private durableLedger: CodexResetCreditAttemptLedger | null = null - private loadError: Error | null = null + private stateError: Error | null = null + private mutationQueue: Promise = Promise.resolve() - constructor(private readonly store: Store) { + constructor( + private readonly store: Pick< + Store, + 'getCodexResetCreditAttemptLedger' | 'replaceCodexResetCreditAttemptLedgerAndFlush' + > + ) { this.hydrate() } get error(): Error | null { - return this.loadError + return this.stateError } get(idempotencyKey: string): CodexResetCreditAttempt | undefined { @@ -114,32 +121,40 @@ export class CodexResetCreditLedger { ) } - markProviderPending(idempotencyKey: string, attempt: CodexResetCreditAttempt): void { - this.persist({ idempotencyKey, expectedScope: attempt.expectedScope, state: 'providerPending' }) - attempt.state = 'providerPending' - this.unresolvedKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey) + markProviderPending(idempotencyKey: string, attempt: CodexResetCreditAttempt): Promise { + return this.serializeMutation(async () => { + await this.persist({ + idempotencyKey, + expectedScope: attempt.expectedScope, + state: 'providerPending' + }) + attempt.state = 'providerPending' + this.unresolvedKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey) + }) } markSettled( idempotencyKey: string, attempt: CodexResetCreditAttempt, outcome: CodexRateLimitResetOutcome - ): void { - this.persist({ - idempotencyKey, - expectedScope: attempt.expectedScope, - state: 'settled', - outcome + ): Promise { + return this.serializeMutation(async () => { + await this.persist({ + idempotencyKey, + expectedScope: attempt.expectedScope, + state: 'settled', + outcome + }) + attempt.state = 'settled' + attempt.settledOutcome = outcome + if (this.unresolvedKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) { + this.unresolvedKeyByAccountScope.delete(attempt.accountScopeKey) + } }) - attempt.state = 'settled' - attempt.settledOutcome = outcome - if (this.unresolvedKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) { - this.unresolvedKeyByAccountScope.delete(attempt.accountScopeKey) - } } releaseFresh(idempotencyKey: string, attempt: CodexResetCreditAttempt): void { - if (attempt.state !== 'fresh') { + if (attempt.state !== 'fresh' || this.stateError) { return } this.attemptsByKey.delete(idempotencyKey) @@ -151,7 +166,11 @@ export class CodexResetCreditLedger { // Why: a removed account's managed home is gone, so its unresolved providerPending // attempt can never validate or be replayed; drop it so a target-scoped default reset // is not wedged forever by hasPendingResetForTarget matching the orphan. - discardForRemovedAccount(accountId: string): void { + discardForRemovedAccount(accountId: string): Promise { + return this.serializeMutation(() => this.discardAccountAttempts(accountId)) + } + + private async discardAccountAttempts(accountId: string): Promise { const staleAttempts = [...this.attemptsByKey].filter( ([, attempt]) => attempt.expectedScope.accountId === accountId ) @@ -167,7 +186,7 @@ export class CodexResetCreditLedger { const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts } // Persist first so a failed durability barrier leaves the in-memory // fail-closed guards aligned with the ledger that will reload. - this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) + await this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) this.durableLedger = structuredClone(nextLedger) } } @@ -202,14 +221,34 @@ export class CodexResetCreditLedger { } } } catch (error) { - this.loadError = + this.stateError = error instanceof Error ? error : new Error('Codex reset-credit attempt ledger is corrupt') } } - private persist(nextAttempt: DurableCodexResetCreditAttempt): void { + private serializeMutation(operation: () => Promise): Promise { + const next = this.mutationQueue.then(async () => { + if (this.stateError) { + throw this.stateError + } + try { + await operation() + } catch (error) { + if (profileStateWriterFailureOutcome(error) === 'indeterminate') { + this.stateError = new Error('Codex reset-credit attempt durability is unknown', { + cause: error + }) + } + throw error + } + }) + this.mutationQueue = next.catch(() => {}) + return next + } + + private async persist(nextAttempt: DurableCodexResetCreditAttempt): Promise { if (!this.durableLedger) { - throw this.loadError ?? new Error('Codex reset-credit attempt ledger is unavailable') + throw this.stateError ?? new Error('Codex reset-credit attempt ledger is unavailable') } const index = this.durableLedger.attempts.findIndex( (attempt) => attempt.idempotencyKey === nextAttempt.idempotencyKey @@ -221,7 +260,7 @@ export class CodexResetCreditLedger { attempts[index] = nextAttempt } const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts } - this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) + await this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) this.durableLedger = structuredClone(nextLedger) } } diff --git a/src/main/codex-accounts/service-reset-credit-durability.test.ts b/src/main/codex-accounts/service-reset-credit-durability.test.ts index ac6cd8f2ef73..e524bda2aeb7 100644 --- a/src/main/codex-accounts/service-reset-credit-durability.test.ts +++ b/src/main/codex-accounts/service-reset-credit-durability.test.ts @@ -146,9 +146,22 @@ describe('CodexAccountService config sync', () => { account, limits })! + const store = createStore(settings) + const persist = store.replaceCodexResetCreditAttemptLedgerAndFlush.getMockImplementation()! + const pendingCommit = Promise.withResolvers() + const settledCommit = Promise.withResolvers() + store.replaceCodexResetCreditAttemptLedgerAndFlush + .mockImplementationOnce(async (ledger) => { + await pendingCommit.promise + await persist(ledger) + }) + .mockImplementationOnce(async (ledger) => { + await settledCommit.promise + await persist(ledger) + }) const { CodexAccountService } = await import('./service') const service = new CodexAccountService( - createStore(settings) as never, + store as never, rateLimits as never, createRuntimeHome() as never ) @@ -157,9 +170,20 @@ describe('CodexAccountService config sync', () => { const first = service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) const second = service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) expect(second).toBe(first) + await vi.waitFor(() => + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledOnce() + ) + expect(consume).not.toHaveBeenCalled() + pendingCommit.resolve() await vi.waitFor(() => expect(consume).toHaveBeenCalledOnce()) const selectingNextAccount = service.selectAccount(nextAccount.id) finishConsume?.({ outcome: 'reset', state }) + await vi.waitFor(() => + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledTimes(2) + ) + expect(service.listAccounts().activeAccountId).toBe(account.id) + expect(store.getCodexResetCreditAttemptLedger().attempts[0]?.state).toBe('providerPending') + settledCommit.resolve() const resetResults = await Promise.all([first, second]) expect(resetResults).toMatchObject([ @@ -406,9 +430,10 @@ describe('CodexAccountService config sync', () => { const limits = createResetCreditLimits() const state = createResetRateLimitState(limits) const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementationOnce(() => { - throw new Error('disk full') - }) + const pendingCommit = Promise.withResolvers() + store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementationOnce( + () => pendingCommit.promise + ) const consume = vi.fn() const expectedScope = buildCodexResetCreditExpectedScope({ target: state.codexTarget, @@ -426,9 +451,15 @@ describe('CodexAccountService config sync', () => { createRuntimeHome() as never ) - await expect( + const rejected = expect( service.consumeRateLimitResetCredit('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', expectedScope) ).rejects.toThrow('disk full') + await vi.waitFor(() => + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledOnce() + ) + expect(consume).not.toHaveBeenCalled() + pendingCommit.reject(new Error('disk full')) + await rejected expect(consume).not.toHaveBeenCalled() expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) }) @@ -453,11 +484,11 @@ describe('CodexAccountService config sync', () => { const state = createResetRateLimitState(limits) const store = createStore(settings) const persist = store.replaceCodexResetCreditAttemptLedgerAndFlush.getMockImplementation()! - store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementation((ledger) => { + store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementation(async (ledger) => { if (ledger.attempts[0]?.state === 'settled') { throw new Error('settle disk full') } - persist(ledger) + return persist(ledger) }) const expectedScope = buildCodexResetCreditExpectedScope({ target: state.codexTarget, diff --git a/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts b/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts index b5c534be4171..eebc256a8c66 100644 --- a/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts +++ b/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts @@ -145,7 +145,7 @@ describe('Codex reset-credit managed-home ownership', () => { } ] } - fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush(pendingLedger) + await fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush(pendingLedger) makeHomeUnsafe(fixture.managedHomePath) const settingsBefore = structuredClone(fixture.store.getSettings()) diff --git a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts index ebfe4e3ac1c5..f5b130ac8a6c 100644 --- a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts +++ b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts @@ -329,7 +329,7 @@ describe('CodexAccountService config sync', () => { limits })! const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ + await store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { @@ -379,7 +379,7 @@ describe('CodexAccountService config sync', () => { limits })! const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ + await store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { @@ -438,7 +438,7 @@ describe('CodexAccountService config sync', () => { limits })! const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ + await store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { @@ -459,9 +459,9 @@ describe('CodexAccountService config sync', () => { } as never, createRuntimeHome() as never ) - vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockImplementationOnce(() => { - throw new Error('disk full') - }) + vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockRejectedValueOnce( + new Error('disk full') + ) await expect(service.removeAccount('account-1')).rejects.toThrow('disk full') await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow('unknown outcome') diff --git a/src/main/codex-accounts/service-test-harness.ts b/src/main/codex-accounts/service-test-harness.ts index 4587788ec67b..8b81cb2125f6 100644 --- a/src/main/codex-accounts/service-test-harness.ts +++ b/src/main/codex-accounts/service-test-harness.ts @@ -56,9 +56,11 @@ export function createStore(settings: GlobalSettings) { return settings }), getCodexResetCreditAttemptLedger: vi.fn(() => structuredClone(resetLedger)), - replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn((next: CodexResetCreditAttemptLedger) => { - resetLedger = structuredClone(next) - }) + replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn( + async (next: CodexResetCreditAttemptLedger) => { + resetLedger = structuredClone(next) + } + ) } } diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index 8d4166add326..e7bd01cfbcb8 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -57,12 +57,20 @@ vi.mock('../orca-profiles/profile-index-store', () => ({ setActiveOrcaProfile: setActiveOrcaProfileMock })) -function makeStoreMock(flushPendingOrThrowAsync = vi.fn()): { - flushPendingOrThrowAsync: typeof flushPendingOrThrowAsync - freezeWrites: ReturnType - getSettings: () => Record -} { - return { flushPendingOrThrowAsync, freezeWrites: vi.fn(), getSettings: () => ({}) } +function makeStoreMock(flushPendingOrThrowAsync = vi.fn()) { + const freezeWrites = vi.fn() + const resumeMaintenance = vi.fn(async () => {}) + return { + flushPendingOrThrowAsync, + freezeWrites, + resumeMaintenance, + beginProfileMaintenance: vi.fn(async (options: unknown) => { + await flushPendingOrThrowAsync(options) + freezeWrites() + return { resume: resumeMaintenance } + }), + getSettings: () => ({}) + } } vi.mock('../orca-profiles/profile-project-transfer', () => ({ @@ -77,6 +85,8 @@ vi.mock('../orca-profiles/profile-storage-paths', async (importOriginal) => ({ import { registerOrcaProfileHandlers } from './orca-profiles' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +const ipcEvent = { sender: { isDestroyed: () => false, send: vi.fn() } } + describe('registerOrcaProfileHandlers', () => { beforeEach(() => { // Why the port and per-test: userData resolves through AppEnvironment now, and @@ -84,6 +94,7 @@ describe('registerOrcaProfileHandlers', () => { installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' }) vi.useFakeTimers() handlers.clear() + ipcEvent.sender.send.mockClear() appExitMock.mockReset() appQuitMock.mockReset() appRelaunchMock.mockReset() @@ -116,12 +127,12 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock() as never) - await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({ + await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(ipcEvent))).resolves.toEqual({ ...listState, multiProfileUi: false }) await expect( - Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(null, { name: 'Work' })) + Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(ipcEvent, { name: 'Work' })) ).resolves.toBe(createState) expect(createLocalOrcaProfileMock).toHaveBeenCalledWith({ name: 'Work' }) }) @@ -136,11 +147,13 @@ describe('registerOrcaProfileHandlers', () => { }) registerOrcaProfileHandlers(makeStoreMock() as never) - await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({ - activeProfileId: 'local-default', - profiles: [], - multiProfileUi: true - }) + await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(ipcEvent))).resolves.toEqual( + { + activeProfileId: 'local-default', + profiles: [], + multiProfileUi: true + } + ) } finally { if (previous === undefined) { delete process.env.ORCA_MULTI_PROFILE_UI @@ -164,7 +177,7 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) const resultPromise = Promise.resolve( - handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }) + handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' }) ) await expect(resultPromise).resolves.toEqual({ status: 'relaunching' }) @@ -198,7 +211,7 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock(flush) as never) await expect( - Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' })) + Promise.resolve(handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' })) ).rejects.toThrow('flush_failed') expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() @@ -215,7 +228,7 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) const switchProfile = Promise.resolve( - handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }) + handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' }) ) const rejection = expect(switchProfile).rejects.toThrow('orca_profile_persistence_timeout') await vi.advanceTimersByTimeAsync(20_000) @@ -234,7 +247,9 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock() as never) await expect( - Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-default' })) + Promise.resolve( + handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-default' }) + ) ).resolves.toEqual({ status: 'already-active' }) expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() @@ -245,7 +260,7 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock() as never) await expect( - Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: ' ' })) + Promise.resolve(handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: ' ' })) ).rejects.toThrow('invalid_orca_profile_id') }) @@ -269,7 +284,7 @@ describe('registerOrcaProfileHandlers', () => { await expect( Promise.resolve( - handlers.get('orcaProfiles:transferProject')?.(null, { + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { sourceProfileId: ' personal ', targetProfileId: ' work ', repoId: ' repo-1 ', @@ -311,7 +326,7 @@ describe('registerOrcaProfileHandlers', () => { await expect( Promise.resolve( - handlers.get('orcaProfiles:transferProject')?.(null, { + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { sourceProfileId: 'personal', targetProfileId: 'work', repoId: 'repo-1', @@ -337,6 +352,7 @@ describe('registerOrcaProfileHandlers', () => { await vi.advanceTimersByTimeAsync(150) expect(appRelaunchMock).toHaveBeenCalledOnce() + expect(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed') expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer') expect(appQuitMock).toHaveBeenCalledOnce() expect(appExitMock).not.toHaveBeenCalled() @@ -351,7 +367,7 @@ describe('registerOrcaProfileHandlers', () => { await expect( Promise.resolve( - handlers.get('orcaProfiles:transferProject')?.(null, { + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { sourceProfileId: 'personal', targetProfileId: 'work', repoId: 'repo-1', @@ -376,7 +392,7 @@ describe('registerOrcaProfileHandlers', () => { await expect( Promise.resolve( - handlers.get('orcaProfiles:transferProject')?.(null, { + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { sourceProfileId: 'personal', targetProfileId: 'work', repoId: 'repo-1', @@ -390,6 +406,7 @@ describe('registerOrcaProfileHandlers', () => { expect(store.freezeWrites).toHaveBeenCalledBefore(onBeforeRelaunch) expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(150) + expect(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed') expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer') expect(appQuitMock).toHaveBeenCalledOnce() }) @@ -406,7 +423,7 @@ describe('registerOrcaProfileHandlers', () => { await expect( Promise.resolve( - handlers.get('orcaProfiles:transferProject')?.(null, { + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { sourceProfileId: 'personal', targetProfileId: 'work', repoId: 'repo-1', @@ -415,7 +432,7 @@ describe('registerOrcaProfileHandlers', () => { ) ).rejects.toThrow('unknown_source_repo') - expect(store.freezeWrites).not.toHaveBeenCalled() + expect(store.resumeMaintenance).toHaveBeenCalledOnce() expect(onBeforeRelaunch).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(150) expect(relaunchAppMock).not.toHaveBeenCalled() @@ -436,7 +453,7 @@ describe('registerOrcaProfileHandlers', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. registerOrcaProfileHandlers(store as never) await expect( - Promise.resolve(handlers.get('orcaProfiles:transferProject')?.(null, args)) + Promise.resolve(handlers.get('orcaProfiles:transferProject')?.(ipcEvent, args)) ).rejects.toThrow('invalid_orca_profile_project_transfer') expect(store.flushPendingOrThrowAsync).not.toHaveBeenCalled() expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled() diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index 3014b8221a2e..83e4e88a8296 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -1,4 +1,4 @@ -import { app, ipcMain } from 'electron' +import { app, ipcMain, type WebContents } from 'electron' import type { Store } from '../persistence' import { relaunchApp, type AppRelaunchReason } from '../app-relaunch' import type { @@ -139,7 +139,13 @@ async function runBeforeProfileRelaunch( } } -function scheduleProfileRelaunch(reason: Extract): void { +function scheduleProfileRelaunch( + reason: Extract, + sender: WebContents +): void { + if (!sender.isDestroyed()) { + sender.send('app:restart-committed') + } setTimeout(() => { relaunchApp(reason) // Why: app.quit() (not app.exit) so before-quit/will-quit still run — @@ -179,7 +185,7 @@ export function registerOrcaProfileHandlers( ipcMain.handle( 'orcaProfiles:switch', - async (_event, args: SwitchOrcaProfileArgs): Promise => { + async (event, args: SwitchOrcaProfileArgs): Promise => { const profileId = profileIdFromArgs(args) const current = getOrcaProfileListState() if (profileId === current.activeProfileId) { @@ -199,11 +205,16 @@ export function registerOrcaProfileHandlers( } // Why: the current profile must be persisted before the global index // points startup at the target profile. - await flushActiveProfileBeforeFileMutation(store) + const maintenance = await flushActiveProfileBeforeFileMutation(store) + try { + setActiveOrcaProfile(profileId) + } catch (error) { + await maintenance.resume() + throw error + } await runBeforeProfileRelaunch(options.onBeforeRelaunch) - setActiveOrcaProfile(profileId) - scheduleProfileRelaunch('profile-switch') + scheduleProfileRelaunch('profile-switch', event.sender) return { status: 'relaunching' } } @@ -212,7 +223,7 @@ export function registerOrcaProfileHandlers( ipcMain.handle( 'orcaProfiles:transferProject', async ( - _event, + event, rawArgs: TransferOrcaProfileProjectArgs ): Promise => { const args = transferProjectArgsFromUnknown(rawArgs) @@ -223,27 +234,29 @@ export function registerOrcaProfileHandlers( if (args.mode === 'move' && args.sourceProfileId === current.activeProfileId) { // Why: transfer before any relaunch side effect so a duplicate-target // or validation failure cannot strand the app in a quitting state. - await flushActiveProfileBeforeFileMutation(store) const result = await transferActiveProfileProject( args, getProfileUserDataPath(), store, async () => { await runBeforeProfileRelaunch(options.onBeforeRelaunch) - scheduleProfileRelaunch('profile-transfer') + scheduleProfileRelaunch('profile-transfer', event.sender) } ) if (result.status === 'transferred') { - store.freezeWrites() await runBeforeProfileRelaunch(options.onBeforeRelaunch) setActiveOrcaProfile(args.targetProfileId) - scheduleProfileRelaunch('profile-transfer') + scheduleProfileRelaunch('profile-transfer', event.sender) return { ...result, willRelaunch: true } } return result } - await flushActiveProfileBeforeFileMutation(store) - return transferOrcaProfileProject(args, getProfileUserDataPath()) + const maintenance = await flushActiveProfileBeforeFileMutation(store) + try { + return transferOrcaProfileProject(args, getProfileUserDataPath()) + } finally { + await maintenance.resume() + } } ) diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index b6aba0855e6d..30ef457a81d3 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { spawnMock, registerPtyMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' @@ -112,7 +113,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-dead-ssh-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn((requestedHostId?: string) => { expect(requestedHostId).toBe(hostId) return session @@ -128,7 +129,7 @@ describe('registerPtyHandlers', () => { removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { diff --git a/src/main/ipc/pty-persisted-incarnation-repair.test.ts b/src/main/ipc/pty-persisted-incarnation-repair.test.ts index 743963d01897..c057d6ec5d21 100644 --- a/src/main/ipc/pty-persisted-incarnation-repair.test.ts +++ b/src/main/ipc/pty-persisted-incarnation-repair.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { statSyncMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' @@ -272,7 +273,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-dead-persisted-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -293,7 +294,7 @@ describe('registerPtyHandlers', () => { ]), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { @@ -434,7 +435,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-unproven-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -445,7 +446,7 @@ describe('registerPtyHandlers', () => { getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 7aaa90bf9b82..0164b3d4701d 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -23,7 +23,7 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ }> { const args = ctx.args try { - ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + ctx.stablePaneBindingPersisted = await persistAdmittedStablePaneBinding({ store: ctx.deps.store, owner: ctx.stablePaneOwner, result: ctx.result, @@ -101,7 +101,7 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { ptySizes.delete(ctx.effectiveSessionAppId) } - // Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217). + // Persist the binding before acknowledging spawn so the renderer debounce cannot orphan history. if ( ctx.deps.store && typeof args.worktreeId === 'string' && @@ -119,10 +119,11 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), origin: spawnCommitBindingOrigin(ctx.result) } - if (args.connectionId) { - ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) - } else { - ctx.deps.store.persistPtyBinding(binding) + const persisted = args.connectionId + ? await ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) + : await ctx.deps.store.persistPtyBinding(binding) + if (persisted === false) { + throw new Error('terminal_pane_owner_changed') } } catch (err) { console.error('[pty] failed to persist PTY binding after spawn:', err) diff --git a/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts b/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts index 6266faf0c02b..a64192be3835 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts @@ -150,7 +150,7 @@ describe('the IPC spawn commit keeps one reattachable lease per SSH pane', () => state: 'attached' }) expect(bulkReattachPtyIds(store)).toEqual(['pty2:aaa:1', 'pty2:bbb:1']) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1, @@ -226,7 +226,7 @@ describe('the IPC spawn commit keeps one reattachable lease per SSH pane', () => }) // Production's writer for an SSH pane binding, and the whole point: it updates ONLY the host // partition, so `local` is left naming the predecessor until the renderer republishes. - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1, ptyId: successor }, hostId ) diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 5d25e11f57cb..40fcabde70c8 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -1,3 +1,5 @@ +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../../../persistence/restoring-sessions/workspace-session-write-rollback' +import { cloneWorkspaceSessionState } from '../../../persistence/restoring-sessions/session-owner-fields' import { toSshExecutionHostId } from '../../../../shared/execution-host' import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause' @@ -98,8 +100,7 @@ export function resolveStablePaneOwner( } const registeredConnectionId = ptyOwnership.get(ptyId) const parsedSshId = registeredConnectionId === undefined ? parseAppSshPtyId(ptyId) : null - const ownerConnectionId = registeredConnectionId ?? parsedSshId?.connectionId ?? null - if (ownerConnectionId !== (connectionId ?? null)) { + if ((registeredConnectionId ?? parsedSshId?.connectionId ?? null) !== (connectionId ?? null)) { throw new Error('terminal_pane_owner_host_mismatch') } const runtimeIncarnationId = ptyIncarnationById.get(ptyId) @@ -121,41 +122,50 @@ export function resolveStablePaneOwner( } } -export function retirePersistedStablePaneOwner( +export async function retirePersistedStablePaneOwner( store: Store | undefined, owner: StablePaneOwner, worktreeId: string, connectionId: string | null | undefined -): boolean { +): Promise { if (!store) { return false } - const paneKey = makePaneKey(owner.tabId, owner.leafId) - const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined - const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) - if (!current) { - // Why: persistence already dropped this pane binding (an earlier stop retired it while the - // runtime kept history), so there is nothing left to clear — that is a completed retirement, - // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. - return true - } - if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { - return false - } - const session = store.getWorkspaceSession(hostId) - const retired = retireTerminalSurfaceFromPersistence(session, { - worktreeId, - parentTabId: owner.tabId, - leafId: owner.leafId, - ptyId: owner.ptyId, - ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) + return store.runDurableMutation(() => { + const paneKey = makePaneKey(owner.tabId, owner.leafId) + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) + if (!current) { + // A prior stop may already have retired this pane while runtime retained its history. + return { value: true, persist: false } + } + if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { + return { value: false, persist: false } + } + const session = cloneWorkspaceSessionState(store.getWorkspaceSession(hostId)) + const retired = retireTerminalSurfaceFromPersistence(session, { + worktreeId, + parentTabId: owner.tabId, + leafId: owner.leafId, + ptyId: owner.ptyId, + ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) + }) + if (retired === session) { + return { value: false, persist: false } + } + store.setWorkspaceSession(retired, hostId) + const staged = cloneWorkspaceSessionState(store.getWorkspaceSession(hostId)) + return { + value: true, + rollback: () => { + const current = store.getWorkspaceSession(hostId) + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) + if (rolledBack !== current) { + store.setWorkspaceSession(rolledBack, hostId) + } + } + } }) - if (retired === session) { - return false - } - store.setWorkspaceSession(retired, hostId) - store.flushOrThrow() - return true } export type StablePaneSpawnContext = { @@ -181,19 +191,19 @@ export function stablePanePersistenceFence( : undefined } -export function persistAdmittedStablePaneBinding(args: { +export async function persistAdmittedStablePaneBinding(args: { store: Store | undefined owner: StablePaneOwner | null result: PtySpawnResult worktreeId: string | undefined startupCwd: string | undefined connectionId: string | null | undefined -}): boolean { +}): Promise { const expectedBinding = stablePanePersistenceFence(args.owner) if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { return false } - const persisted = args.store.persistPtyBinding( + const persisted = await args.store.persistPtyBinding( { worktreeId: args.worktreeId, tabId: args.owner.tabId, @@ -270,7 +280,7 @@ export async function attachStablePaneOwner( ptyOwnership.delete(owner.ptyId) if ( args.worktreeId && - !retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId) + !(await retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId)) ) { throw new Error('terminal_pane_owner_changed') } diff --git a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts index 972f479b4927..390bcdd451a2 100644 --- a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts @@ -9,6 +9,7 @@ // same rule here, and pin that the marked half — the one refusal the relay backed with a pid probe // — still earns the certificate, so a genuinely dead PTY is not left `unverifiable` forever. import { describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' import { getDefaultWorkspaceSession } from '../../../../shared/constants' import { makePaneKey } from '../../../../shared/stable-pane-id' import { SSH_EXIT_UNCONFIRMED_REASON } from '../../../../shared/pty-liveness-verdict' @@ -54,7 +55,8 @@ function paneStore(): { store: Store; read: () => WorkspaceSessionState } { } as unknown as WorkspaceSessionState return { read: () => session, - store: { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the persistence methods used by stable-pane retirement and exit bookkeeping. + store: withDurableRuntimeStore({ getWorkspaceSession: () => session, setWorkspaceSession: (next: WorkspaceSessionState) => { session = next @@ -75,7 +77,7 @@ function paneStore(): { store: Store; read: () => WorkspaceSessionState } { removeWorktreeMeta: () => {}, getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), getProjects: () => [] - } as unknown as Store + }) as unknown as Store } } diff --git a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts index e68bcf6ec997..ce3c578cfa03 100644 --- a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { TerminalSessionOwnerUnverifiedError } from '../../../daemon/daemon-errors' import { @@ -70,13 +71,14 @@ function sessionStore(leaves: string[]): { store: Store; read: () => WorkspaceSe } as unknown as WorkspaceSessionState return { read: () => session, - store: { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every persistence method used by stable-pane retirement. + store: withDurableRuntimeStore({ getWorkspaceSession: () => session, setWorkspaceSession: (next: WorkspaceSessionState) => { session = next }, flushOrThrow: () => {} - } as unknown as Store + }) as unknown as Store } } diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 09d414602636..dcfce3665b05 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -41,7 +41,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) try { - ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + ctx.stablePaneBindingPersisted = await persistAdmittedStablePaneBinding({ store: ctx.hostSessionBinding?.store, owner: ctx.stablePaneOwner, result: ctx.result, @@ -164,11 +164,11 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { origin: spawnCommitBindingOrigin(ctx.result, ctx.hostSessionBinding.expectedSourceBinding) } const persisted = args.connectionId - ? ctx.hostSessionBinding.store.persistPtyBinding( + ? await ctx.hostSessionBinding.store.persistPtyBinding( binding, toSshExecutionHostId(args.connectionId) ) - : ctx.hostSessionBinding.store.persistPtyBinding(binding) + : await ctx.hostSessionBinding.store.persistPtyBinding(binding) if (persisted === false) { throw new Error('terminal_split_source_not_found') } diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 9fdfd8214b27..855401f96c1f 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -31,17 +31,22 @@ export function registerSessionHandlers(store: Store): void { ipcMain.handle('session:flush', () => { // Why: durable lifecycle RPCs must propagate disk failures instead of // returning success through Store.flush(), which intentionally only logs. - store.flushOrThrow() + return store.flushPendingOrThrowAsync() }) - // Synchronous variant for the renderer's beforeunload handler. - // sendSync blocks the renderer until this returns, guaranteeing the - // data (including terminal scrollback buffers) is persisted to disk - // before the window closes — regardless of before-quit ordering. + // Older renderers block on the reply; main remains free to await the writer. ipcMain.on('session:set-sync', (event, args: WorkspaceSessionState, hostId?: string | null) => { - store.setWorkspaceSession(args, hostId) - store.flush() - event.returnValue = true + void (async () => { + try { + store.setWorkspaceSession(args, hostId) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + } catch (error) { + console.error('[persistence] Failed to flush legacy session checkpoint:', error) + } finally { + // This legacy response has always been best effort, including on disk errors. + event.returnValue = true + } + })() }) ipcMain.on( diff --git a/src/main/orca-profiles/profile-active-transfer-worker.test.ts b/src/main/orca-profiles/profile-active-transfer-worker.test.ts new file mode 100644 index 000000000000..2e19eaf73f0b --- /dev/null +++ b/src/main/orca-profiles/profile-active-transfer-worker.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' +import { createWorkerMaintenanceFixture } from '../persistence/loading-store/profile-state-maintenance-fixture' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { transferActiveProfileProject } from './profile-active-transfer' +import * as domainState from './profile-project-domain-state' +import { + profileHasPendingProjectMove, + recoverPendingProfileProjectMoves +} from './profile-project-move-intent' +import { readProfileStateWithRevision } from './profile-project-state-file' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +async function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-worker-profile-transfer-')) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: 'source', + profiles: ['source', 'target'].map((id) => ({ + id, + name: id, + avatar: { kind: 'initials', initials: id[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + })) + }) + ) + const current = await createWorkerMaintenanceFixture({ + directory: join(root, 'profiles', 'source'), + profileId: 'source', + cleanupRoot: root + }) + const target = new ProfileStateSqliteAuthority( + join(root, 'profiles', 'target', 'profile-state.db'), + 'target' + ) + try { + target.writeSerializedState(Buffer.from(JSON.stringify(getDefaultPersistedState(root)))) + } finally { + target.close() + } + const args = { + sourceProfileId: 'source', + targetProfileId: 'target', + repoId: 'repo-remote', + mode: 'move' + } as const + const read = (id: string) => readProfileStateWithRevision(id, root) + return { ...current, root, args, read } +} + +describe('active profile transfers with the live writer', () => { + it('resumes the exact source revision after a validation failure', async () => { + const { store, root, args, read } = await fixture() + const reopen = vi.fn(async () => {}) + await expect( + transferActiveProfileProject({ ...args, repoId: 'missing' }, root, store, reopen) + ).rejects.toThrow('unknown_source_repo') + expect(reopen).not.toHaveBeenCalled() + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(read('source').state.settings.theme).toBe('dark') + }) + + it('keeps the source frozen after moving a remote project and its persisted state', async () => { + const { store, root, args, read } = await fixture() + const result = await transferActiveProfileProject(args, root, store, async () => {}) + expect(result.status).toBe('transferred') + expect(read('source').state.repos.some((repo) => repo.id === args.repoId)).toBe(false) + expect(read('target').state.repos.some((repo) => repo.id === args.repoId)).toBe(true) + const source = read('source') + store.updateSettings({ theme: 'dark' }) + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + expect(read('source')).toEqual(source) + }) + + it('resumes when a copy makes a later move a duplicate', async () => { + const { store, root, args, read } = await fixture() + await transferActiveProfileProject({ ...args, mode: 'copy' }, root, store, async () => {}) + const result = await transferActiveProfileProject(args, root, store, async () => {}) + expect(result.status).toBe('duplicate-target') + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(read('source').state.settings.theme).toBe('dark') + expect(read('source').state.repos.some((repo) => repo.id === args.repoId)).toBe(true) + }) + + it('leaves an interrupted move frozen until journal recovery runs with the writer closed', async () => { + const { store, root, args, read } = await fixture() + const original = domainState.writeProfileProjectDomainChanges + const fault = vi + .spyOn(domainState, 'writeProfileProjectDomainChanges') + .mockImplementation((id, ...rest) => { + if (id === args.sourceProfileId) { + throw new Error('source commit interrupted') + } + return original(id, ...rest) + }) + const reopen = vi.fn(async () => {}) + await expect(transferActiveProfileProject(args, root, store, reopen)).rejects.toThrow( + 'source commit interrupted' + ) + expect(reopen).toHaveBeenCalledOnce() + expect(profileHasPendingProjectMove('source', root)).toBe(true) + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + fault.mockRestore() + expect(recoverPendingProfileProjectMoves(root)).toBe(1) + expect(read('source').state.repos.some((repo) => repo.id === args.repoId)).toBe(false) + expect(read('target').state.repos.some((repo) => repo.id === args.repoId)).toBe(true) + }) +}) diff --git a/src/main/orca-profiles/profile-active-transfer.test.ts b/src/main/orca-profiles/profile-active-transfer.test.ts index f6966ca487cf..0cfd80d7eefb 100644 --- a/src/main/orca-profiles/profile-active-transfer.test.ts +++ b/src/main/orca-profiles/profile-active-transfer.test.ts @@ -138,7 +138,7 @@ describe('active profile transfer recovery', () => { const reopen = vi.fn(async () => { const retained = snapshot('source') store.updateSettings({ theme: 'light' }) - store.flushOrThrow() + expect(() => store.flushOrThrow()).toThrow('final persistence') expect(snapshot('source')).toEqual(retained) }) @@ -188,7 +188,7 @@ describe('active profile transfer recovery', () => { }) ).rejects.toThrow('reopen failed') store.updateSettings({ theme: 'light' }) - store.flushOrThrow() + expect(() => store.flushOrThrow()).toThrow('final persistence') expect(snapshot('source')).toEqual(before) interrupted.mockRestore() expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1) @@ -207,7 +207,7 @@ describe('active profile transfer recovery', () => { ) const recovered = snapshot('source') store.updateSettings({ theme: 'light' }) - store.flushOrThrow() + expect(() => store.flushOrThrow()).toThrow('final persistence') expect(snapshot('source')).toEqual(recovered) expect(reopen).toHaveBeenCalledOnce() }) @@ -225,7 +225,7 @@ describe('active profile transfer recovery', () => { 'Profile move intent is unreadable' ) store.updateSettings({ theme: 'light' }) - store.flushOrThrow() + expect(() => store.flushOrThrow()).toThrow('final persistence') expect(snapshot('source')).toEqual(before) expect(reopen).toHaveBeenCalledOnce() }) diff --git a/src/main/orca-profiles/profile-active-transfer.ts b/src/main/orca-profiles/profile-active-transfer.ts index 87496fca10cb..53ba8481da5b 100644 --- a/src/main/orca-profiles/profile-active-transfer.ts +++ b/src/main/orca-profiles/profile-active-transfer.ts @@ -6,29 +6,34 @@ import type { Store } from '../persistence/loading-store/store' import { transferOrcaProfileProject } from './profile-project-transfer' import { hasOrcaProfileStateDatabase } from './profile-storage-paths' import { profileHasPendingProjectMove } from './profile-project-move-intent' +import { flushActiveProfileBeforeFileMutation } from './profile-persistence-deadline' -/** The caller flushes its active Store before this synchronous disk mutation begins. */ +/** Keep the active Store stopped until file mutation either succeeds or proves unchanged. */ export async function transferActiveProfileProject( args: TransferOrcaProfileProjectArgs, userDataPath: string, - store: Pick, + store: Pick, reopenSource: () => Promise ): Promise { const hadDatabase = hasOrcaProfileStateDatabase(args.sourceProfileId, userDataPath) + const pendingMove = profileHasPendingProjectMove(args.sourceProfileId, userDataPath) + const maintenance = await flushActiveProfileBeforeFileMutation(store, { flush: !pendingMove }) + let result: TransferOrcaProfileProjectResult try { if (profileHasPendingProjectMove(args.sourceProfileId, userDataPath)) { throw new Error('active_source_orca_profile_move_requires_recovery') } - return transferOrcaProfileProject(args, userDataPath) + result = transferOrcaProfileProject(args, userDataPath) } catch (error) { - if ( + const needsRecovery = (!hadDatabase && hasOrcaProfileStateDatabase(args.sourceProfileId, userDataPath)) || profileHasPendingProjectMove(args.sourceProfileId, userDataPath) - ) { - // Further writes would invalidate a retained move's recovery revision. - store.freezeWrites() - await reopenSource() - } + // Further writes would invalidate a retained move's recovery revision. + await (needsRecovery ? reopenSource() : maintenance.resume()) throw error } + if (result.status !== 'transferred' || args.mode !== 'move') { + await maintenance.resume() + } + return result } diff --git a/src/main/orca-profiles/profile-persistence-deadline.ts b/src/main/orca-profiles/profile-persistence-deadline.ts index 084a53c40fef..3d02aacaf96d 100644 --- a/src/main/orca-profiles/profile-persistence-deadline.ts +++ b/src/main/orca-profiles/profile-persistence-deadline.ts @@ -1,8 +1,13 @@ import type { Store } from '../persistence' +import type { ProfileStateMaintenance } from '../persistence/loading-store/profile-state-authority' +import type { ProfileStateMaintenanceOptions } from '../persistence/loading-store/profile-state-maintenance' const PROFILE_PERSISTENCE_TIMEOUT_MS = 20_000 -export async function flushActiveProfileBeforeFileMutation(store: Store): Promise { +export async function flushActiveProfileBeforeFileMutation( + store: Pick, + options: Pick = {} +): Promise { const controller = new AbortController() let timeout: ReturnType | null = null const deadline = new Promise((_resolve, reject) => { @@ -12,7 +17,10 @@ export async function flushActiveProfileBeforeFileMutation(store: Store): Promis }, PROFILE_PERSISTENCE_TIMEOUT_MS) }) try { - await Promise.race([store.flushPendingOrThrowAsync({ signal: controller.signal }), deadline]) + return await Promise.race([ + store.beginProfileMaintenance({ ...options, signal: controller.signal }), + deadline + ]) } finally { if (timeout) { clearTimeout(timeout) diff --git a/src/main/orcad/orcad-entry.test.ts b/src/main/orcad/orcad-entry.test.ts index 3cd4e9838499..c40bdf447aa7 100644 --- a/src/main/orcad/orcad-entry.test.ts +++ b/src/main/orcad/orcad-entry.test.ts @@ -5,32 +5,32 @@ describe('orcad profile-state shutdown', () => { it('flushes durably before closing the profile store', async () => { const events: string[] = [] const store = { - flushPendingOrThrowAsync: vi.fn(async () => { + flushFinalOrThrowAsync: vi.fn(async () => { events.push('flush') }), - freezeWrites: vi.fn(() => { + freezeWritesAsync: vi.fn(async () => { events.push('freeze') }) } await flushOrcadProfileStoreForShutdown(store) - expect(store.flushPendingOrThrowAsync).toHaveBeenCalledOnce() - expect(store.freezeWrites).toHaveBeenCalledOnce() + expect(store.flushFinalOrThrowAsync).toHaveBeenCalledOnce() + expect(store.freezeWritesAsync).toHaveBeenCalledOnce() expect(events).toEqual(['flush', 'freeze']) }) it('closes the profile store even when the durable flush fails', async () => { const flushError = new Error('profile flush failed') - const freezeWrites = vi.fn() + const freezeWritesAsync = vi.fn(async () => {}) const store = { - flushPendingOrThrowAsync: vi.fn(async () => { + flushFinalOrThrowAsync: vi.fn(async () => { throw flushError }), - freezeWrites + freezeWritesAsync } await expect(flushOrcadProfileStoreForShutdown(store)).rejects.toBe(flushError) - expect(freezeWrites).toHaveBeenCalledOnce() + expect(freezeWritesAsync).toHaveBeenCalledOnce() }) }) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 0553883e5c27..c5085931b77f 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -166,7 +166,7 @@ async function startOrcadRuntime( let rpc: InstanceType | null = null let profileStoreForShutdown: - | { flushPendingOrThrowAsync(): Promise; freezeWrites(): void } + | { flushFinalOrThrowAsync(): Promise; freezeWritesAsync(): Promise } | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} @@ -198,7 +198,7 @@ async function startOrcadRuntime( const runtimeUserDataPath = getAppEnvironment().getPath('userData') const { store: profileStore, authority: profileStateAuthority } = - createOrcadProfileStateStartup(runtimeUserDataPath) + await createOrcadProfileStateStartup(runtimeUserDataPath) const observedPaneIdentities = new AgentStatusObservedPaneIdentities() const observedStatusCapture = new AgentStatusObservedPaneIdentityCapture(observedPaneIdentities) // Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable` diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index fa2727410de7..ce3001d67089 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -35,12 +35,12 @@ export async function startOrcadWithLifecycle( } export async function flushOrcadProfileStoreForShutdown(store: { - flushPendingOrThrowAsync(): Promise - freezeWrites(): void + flushFinalOrThrowAsync(): Promise + freezeWritesAsync(): Promise }): Promise { try { - await store.flushPendingOrThrowAsync() + await store.flushFinalOrThrowAsync() } finally { - store.freezeWrites() + await store.freezeWritesAsync() } } diff --git a/src/main/orcad/orcad-profile-state-startup.test.ts b/src/main/orcad/orcad-profile-state-startup.test.ts index e9bc212d08a3..c0bea5de6e30 100644 --- a/src/main/orcad/orcad-profile-state-startup.test.ts +++ b/src/main/orcad/orcad-profile-state-startup.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' const { createProfileStateStoreForStartupMock, @@ -33,15 +33,19 @@ vi.mock('./orcad-profile-state-telemetry', () => ({ const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup') +beforeEach(() => { + vi.resetAllMocks() + ensureActiveOrcaProfileMock.mockReturnValue({ + dataFile: '/tmp/profile/orca-data.json', + stateDatabaseFile: '/tmp/profile/profile-state.db', + profile: { id: 'profile-1' } + }) + orcadProfileStateAuthorityModeMock.mockReturnValue('sqlite-candidate') +}) + describe('orcad profile-state startup', () => { - it('selects the capable authority once and publishes bounded metadata', () => { + it('selects the capable authority once and publishes bounded metadata', async () => { const store = { getSettings: vi.fn() } - ensureActiveOrcaProfileMock.mockReturnValue({ - dataFile: '/tmp/profile/orca-data.json', - stateDatabaseFile: '/tmp/profile/profile-state.db', - profile: { id: 'profile-1' } - }) - orcadProfileStateAuthorityModeMock.mockReturnValue('sqlite-candidate') createProfileStateStoreForStartupMock.mockReturnValue({ store, authority: { readSerializedState: vi.fn() }, @@ -50,7 +54,7 @@ describe('orcad profile-state startup', () => { migrated: true }) - const result = createOrcadProfileStateStartup('/tmp/user-data') + const result = await createOrcadProfileStateStartup('/tmp/user-data') expect(initOrcaProfilePathsMock).toHaveBeenCalledOnce() expect(ensureActiveOrcaProfileMock).toHaveBeenCalledWith('/tmp/user-data') @@ -74,4 +78,38 @@ describe('orcad profile-state startup', () => { }) expect(emitMock).toHaveBeenCalledWith(result.authority) }) + + it('publishes nothing before the profile writer is ready', async () => { + let refuse = (_error: Error) => {} + createProfileStateStoreForStartupMock.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + refuse = reject + }) + ) + const startup = createOrcadProfileStateStartup('/tmp/user-data') + const failure = new Error('writer startup refused') + const rejected = expect(startup).rejects.toBe(failure) + expect(initSshHostKeyStoreFileMock).not.toHaveBeenCalled() + expect(emitMock).not.toHaveBeenCalled() + refuse(failure) + await rejected + }) + + it('closes a ready writer if sidecar initialization fails', async () => { + const store = { freezeWritesAsync: vi.fn(async () => {}) } + createProfileStateStoreForStartupMock.mockResolvedValueOnce({ + store, + backend: 'sqlite', + classification: 'sqlite-only', + migrated: false + }) + const failure = new Error('sidecar initialization refused') + initSshHostKeyStoreFileMock.mockImplementationOnce(() => { + throw failure + }) + await expect(createOrcadProfileStateStartup('/tmp/user-data')).rejects.toBe(failure) + expect(store.freezeWritesAsync).toHaveBeenCalledOnce() + expect(emitMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/orcad/orcad-profile-state-startup.ts b/src/main/orcad/orcad-profile-state-startup.ts index db5c45013498..78b0339c7159 100644 --- a/src/main/orcad/orcad-profile-state-startup.ts +++ b/src/main/orcad/orcad-profile-state-startup.ts @@ -25,11 +25,13 @@ export type OrcadProfileStateStartup = { } /** Build the headless Store and publish its authority selection at one Node-only seam. */ -export function createOrcadProfileStateStartup(userDataPath: string): OrcadProfileStateStartup { +export async function createOrcadProfileStateStartup( + userDataPath: string +): Promise { initOrcaProfilePaths() const profile = ensureActiveOrcaProfile(userDataPath) const authorityMode = orcadProfileStateAuthorityMode() - const result = createProfileStateStoreForStartup({ + const result = await createProfileStateStoreForStartup({ dataFile: profile.dataFile, databaseFile: profile.stateDatabaseFile, profileId: profile.profile.id, @@ -37,7 +39,6 @@ export function createOrcadProfileStateStartup(userDataPath: string): OrcadProfi authorityMode, storageAuthority: 'runtime' }) - initSshHostKeyStoreFile(profile.dataFile) const authority = { backend: result.backend, classification: result.classification, @@ -45,6 +46,19 @@ export function createOrcadProfileStateStartup(userDataPath: string): OrcadProfi runtime: 'orcad' as const, migrated: result.migrated } - emitOrcadProfileStateAuthoritySelected(authority) - return { store: result.store, authority } + try { + initSshHostKeyStoreFile(profile.dataFile) + emitOrcadProfileStateAuthoritySelected(authority) + return { store: result.store, authority } + } catch (error) { + try { + await result.store.freezeWritesAsync() + } catch (closeError) { + console.error( + '[persistence] Failed to close profile persistence after startup failure:', + closeError + ) + } + throw error + } } diff --git a/src/main/persistence-flush-and-save-scheduling.test.ts b/src/main/persistence-flush-and-save-scheduling.test.ts index 2d6180d5fa8b..c40b092a02a9 100644 --- a/src/main/persistence-flush-and-save-scheduling.test.ts +++ b/src/main/persistence-flush-and-save-scheduling.test.ts @@ -85,6 +85,26 @@ describe('Store', () => { expect(persisted.repos[0].id).toBe('r1') }) + it('durably commits an exact JSON operation before a following microtask changes generation', async () => { + const store = await createStore() + const originalTabId = store.getWorkspaceSession().activeTabId + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + queueMicrotask(() => { + store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'newer-tab' }) + }) + return { value: undefined } + }) + expect(readDataFile()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: originalTabId } + }) + expect(store.getWorkspaceSession().activeTabId).toBe('newer-tab') + await store.flushPendingOrThrowAsync() + expect(readDataFile()).toHaveProperty(['workspaceSession', 'activeTabId'], 'newer-tab') + store.freezeWrites() + }) + it('flush remains safe when a debounced save is also pending', async () => { vi.useFakeTimers() try { @@ -220,11 +240,11 @@ describe('Store', () => { leafId: TEST_LEAF_1, ptyId: 'daemon-pty' } - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) const inoBefore = statSync(dataFile()).ino // Warm-restart re-bind storm: an identical binding re-asserted with a sync flush must not rewrite. - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) expect(statSync(dataFile()).ino).toBe(inoBefore) }) @@ -432,14 +452,14 @@ describe('Store', () => { async (hostId) => { const store = await createStore() store.setWorkspaceSession(boundSession(), hostId) - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) const inoBefore = statSync(dataFile()).ino - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration const cloneSpy = vi.spyOn(globalThis, 'structuredClone') - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) - expect(flushSpy).not.toHaveBeenCalled() + expect(runtimeCounters(store).lastDurableWriteGeneration).toBe(durableGenerationBefore) expect(cloneSpy).not.toHaveBeenCalled() expect(statSync(dataFile()).ino).toBe(inoBefore) } @@ -448,31 +468,37 @@ describe('Store', () => { it('flushes while a save is pending, and the sync hash match makes the next call durable', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) const inoBefore = statSync(dataFile()).ino // Bumps the write generation without changing any binding. store.setWorkspaceSession({ ...store.getWorkspaceSession() }) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(await store.persistPtyBinding(binding)).toBe(true) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(statSync(dataFile()).ino).toBe(inoBefore) // Without the writeToDiskSync counter fix the hash-match flush leaves the durable // generation one behind and this third bind would flush again. - expect(store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(await store.persistPtyBinding(binding)).toBe(true) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) }) it('falls through on an incarnation change and persists the new incarnation', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding({ ...binding, incarnationId: 'a' }) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + await store.persistPtyBinding({ ...binding, incarnationId: 'a' }) + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding({ ...binding, incarnationId: 'b' })).toBe(true) + expect(await store.persistPtyBinding({ ...binding, incarnationId: 'b' })).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(readDataFile()).toHaveProperty( ['workspaceSession', 'terminalPtyIncarnationsByPaneKey', paneKey], 'b' @@ -482,18 +508,19 @@ describe('Store', () => { it('does not acknowledge an unpersisted binding published after the final flush', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) await store.flushAsync() const next = boundSession() next.tabsByWorktree[WORKTREE][0].ptyId = 'pty-after-quit' next.terminalLayoutsByTabId.tab1.ptyIdsByLeafId = { [TEST_LEAF_1]: 'pty-after-quit' } - store.setWorkspaceSession(next) + expect(() => store.setWorkspaceSession(next)).toThrow('finalization') + Object.assign(store.getWorkspaceSession(), next) expect(store.getWorkspaceSession().tabsByWorktree[WORKTREE][0].ptyId).toBe('pty-after-quit') - expect(() => store.persistPtyBinding({ ...binding, ptyId: 'pty-after-quit' })).toThrow( - 'Cannot synchronously flush after final persistence has started' - ) + await expect( + store.persistPtyBinding({ ...binding, ptyId: 'pty-after-quit' }) + ).rejects.toThrow('Cannot mutate finalized profile persistence') expect(readDataFile()).toHaveProperty( ['workspaceSession', 'tabsByWorktree', WORKTREE, '0', 'ptyId'], 'pty-1' @@ -503,12 +530,14 @@ describe('Store', () => { it('treats an undefined incarnation against a recorded one as a miss', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding({ ...binding, incarnationId: 'a' }) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + await store.persistPtyBinding({ ...binding, incarnationId: 'a' }) + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding)).toBe(true) + expect(await store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) }) it('falls through on a tombstone and lets the write path clear it', async () => { @@ -532,11 +561,13 @@ describe('Store', () => { expect( store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey] ).toBeDefined() - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' })).toBe(true) + expect(await store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' })).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect( store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey] ).toBeUndefined() @@ -547,20 +578,22 @@ describe('Store', () => { store.setWorkspaceSession( boundSession({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } }) ) - store.persistPtyBinding({ ...binding, incarnationId: 'inc-stale' }) + await store.persistPtyBinding({ ...binding, incarnationId: 'inc-stale' }) const revisionBefore = store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo1 ?? 0 - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ ...binding, incarnationId: 'inc-live', expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } }) ).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo1).toBe( revisionBefore + 1 ) @@ -571,8 +604,8 @@ describe('Store', () => { store.setWorkspaceSession( boundSession({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-1' } }) ) - store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' }) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + await store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' }) + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration const refusals = [ { @@ -583,9 +616,9 @@ describe('Store', () => { { ...binding, tabId: 'missing-tab', mayCreate: false } ] for (const refusal of refusals) { - expect(store.persistPtyBinding(refusal)).toBe(false) + expect(await store.persistPtyBinding(refusal)).toBe(false) } - expect(flushSpy).not.toHaveBeenCalled() + expect(runtimeCounters(store).lastDurableWriteGeneration).toBe(durableGenerationBefore) }) it.each([undefined, 'ssh:ssh-1', 'runtime:runtime-1'])( @@ -593,14 +626,16 @@ describe('Store', () => { async (hostId) => { const store = await createStore() store.setWorkspaceSession(boundSession(), hostId) - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) store.addRepo(makeRepo({ id: 'r-dirty', path: '/dirty' })) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding, hostId)).toBe(true) - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(readDataFile()).toMatchObject({ repos: expect.arrayContaining([expect.objectContaining({ id: 'r-dirty' })]) }) @@ -610,27 +645,31 @@ describe('Store', () => { it('flushes again once the session object is replaced', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) // A renderer publish schedules another save, so global durability must be re-established. store.setWorkspaceSession({ ...store.getWorkspaceSession() }) store.addRepo(makeRepo({ id: 'r-dirty', path: '/dirty' })) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding)).toBe(true) + expect(await store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) }) it('flushes a changed pty for a pane whose old binding was durable', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) store.addRepo(makeRepo({ id: 'r-dirty', path: '/dirty' })) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding({ ...binding, ptyId: 'pty-next' })).toBe(true) + expect(await store.persistPtyBinding({ ...binding, ptyId: 'pty-next' })).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(readDataFile()).toHaveProperty( ['workspaceSession', 'terminalLayoutsByTabId', 'tab1', 'ptyIdsByLeafId', TEST_LEAF_1], 'pty-next' @@ -658,16 +697,16 @@ describe('Store', () => { ) const sibling = { ...binding, leafId: TEST_LEAF_2, ptyId: 'pty-2' } // First remount after a cold park: both panes reattach back to back. - expect(store.persistPtyBinding(binding)).toBe(true) - expect(store.persistPtyBinding(sibling)).toBe(true) + expect(await store.persistPtyBinding(binding)).toBe(true) + expect(await store.persistPtyBinding(sibling)).toBe(true) expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE]?.[0]?.ptyId).toBe('pty-1') - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration // Second remount: neither pane may rewrite the tab row, so neither flushes. - expect(store.persistPtyBinding(sibling)).toBe(true) - expect(store.persistPtyBinding(binding)).toBe(true) + expect(await store.persistPtyBinding(sibling)).toBe(true) + expect(await store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).not.toHaveBeenCalled() + expect(runtimeCounters(store).lastDurableWriteGeneration).toBe(durableGenerationBefore) expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE]?.[0]?.ptyId).toBe('pty-1') }) @@ -675,14 +714,14 @@ describe('Store', () => { const store = await createStore() const hostId = 'ssh:ssh-1' store.setWorkspaceSession(boundSession(), hostId) - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) const partitionBefore = store.getWorkspaceSession(hostId) const partitionsBefore = store['runtime'].state.workspaceSessionsByHostId - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) - expect(flushSpy).not.toHaveBeenCalled() + expect(runtimeCounters(store).lastDurableWriteGeneration).toBe(durableGenerationBefore) expect(store.getWorkspaceSession(hostId)).toBe(partitionBefore) expect(store['runtime'].state.workspaceSessionsByHostId).toBe(partitionsBefore) expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE]).toBeUndefined() @@ -716,9 +755,9 @@ describe('Store', () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) - store.persistPtyBinding(binding) - store.persistPtyBinding({ ...binding, tabId: 'missing-tab', mayCreate: false }) + await store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) + await store.persistPtyBinding({ ...binding, tabId: 'missing-tab', mayCreate: false }) const spans = records.filter( (record) => diff --git a/src/main/persistence-host-admitted-terminal-membership.test.ts b/src/main/persistence-host-admitted-terminal-membership.test.ts index d71dbc9882c0..6e75013c7ab3 100644 --- a/src/main/persistence-host-admitted-terminal-membership.test.ts +++ b/src/main/persistence-host-admitted-terminal-membership.test.ts @@ -57,7 +57,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( // `orca terminal create`: the host mints a tab the renderer has never seen. expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, @@ -77,7 +77,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( const store = await createStore() store.setWorkspaceSession(rendererSession()) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: OTHER_WORKTREE, tabId: 'host-tab-other', leafId: TEST_LEAF_2, @@ -94,7 +94,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( store.setWorkspaceSession(rendererSession()) expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, @@ -118,7 +118,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( const store = await createStore() store.setWorkspaceSession(rendererSession()) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: 'renderer-second-tab', leafId: TEST_LEAF_2, @@ -135,7 +135,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( it('still lets the authoritative retirement path close the host-admitted tab', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, diff --git a/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts b/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts index ff819432c54b..e3f75b3763f5 100644 --- a/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts +++ b/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' + +vi.mock('node:fs', { spy: true }) +import { rmSync, mkdtempSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' @@ -80,7 +82,7 @@ describe('Store SSH remote PTY bindings across host partitions', () => { store.setWorkspaceSession(makeBoundHostSession(null), 'local') store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1') - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'repo-1::/worktree', tabId: 'tab-1', @@ -102,11 +104,11 @@ describe('Store SSH remote PTY bindings across host partitions', () => { const store = await createStore() store.setWorkspaceSession(makeBoundHostSession(null), 'local') store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1') - const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + const flush = vi.mocked(writeFileSync).mockImplementationOnce(() => { throw new Error('disk unavailable') }) - expect(() => + await expect( store.persistPtyBinding( { worktreeId: 'repo-1::/worktree', @@ -116,7 +118,7 @@ describe('Store SSH remote PTY bindings across host partitions', () => { }, 'ssh:ssh-1' ) - ).toThrow('disk unavailable') + ).rejects.toThrow('disk unavailable') flush.mockRestore() expect( diff --git a/src/main/persistence-initial-load.test.ts b/src/main/persistence-initial-load.test.ts index 934ab44e1cb6..43a4f3e0561a 100644 --- a/src/main/persistence-initial-load.test.ts +++ b/src/main/persistence-initial-load.test.ts @@ -16,6 +16,7 @@ import { makeProjectHostSetup } from './persistence-test-harness' import { TEST_LEAF_1 } from './persistence-session-fixtures' +import * as durableFileWrite from './durable-file-write' import { getLocalWorktreeScanGeneration, isLocalWorktreeScanGenerationCurrent @@ -79,7 +80,7 @@ describe('Store', () => { expect(store.getRepos()).toEqual([]) }, 15_000) - it('clone-reads and synchronously persists the main-owned Codex reset ledger', async () => { + it('clone-reads and durably persists the main-owned Codex reset ledger', async () => { const store = await createStore() const ledger = { version: 1 as const, @@ -97,7 +98,7 @@ describe('Store', () => { ] } - store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) + await store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) const firstRead = store.getCodexResetCreditAttemptLedger() firstRead.attempts.splice(0, 1) @@ -105,32 +106,35 @@ describe('Store', () => { expect((readDataFile() as PersistedState).codexResetCreditAttemptLedger).toEqual(ledger) }) - it('rolls the in-memory Codex reset ledger back when its sync flush fails', async () => { + it('rolls the in-memory Codex reset ledger back when its durable write fails', async () => { const store = await createStore() const before = store.getCodexResetCreditAttemptLedger() - vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + const write = vi.spyOn(durableFileWrite, 'writeFileDurableSync').mockImplementationOnce(() => { throw new Error('disk full') }) - expect(() => - store.replaceCodexResetCreditAttemptLedgerAndFlush({ - version: 1, - attempts: [ - { - idempotencyKey: '11111111-1111-4111-8111-111111111111', - expectedScope: { - target: { runtime: 'host', wslDistro: null }, - accountId: 'account-host', - accountRevision: 42, - offerRevision: 'v1:offer' - }, - state: 'providerPending' - } - ] - }) - ).toThrow('disk full') - - expect(store.getCodexResetCreditAttemptLedger()).toEqual(before) + try { + await expect( + store.replaceCodexResetCreditAttemptLedgerAndFlush({ + version: 1, + attempts: [ + { + idempotencyKey: '11111111-1111-4111-8111-111111111111', + expectedScope: { + target: { runtime: 'host', wslDistro: null }, + accountId: 'account-host', + accountRevision: 42, + offerRevision: 'v1:offer' + }, + state: 'providerPending' + } + ] + }) + ).rejects.toThrow('disk full') + expect(store.getCodexResetCreditAttemptLedger()).toEqual(before) + } finally { + write.mockRestore() + } }) it('preserves a corrupt Codex reset ledger as a fail-closed read error', async () => { diff --git a/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts index 2f0dc41daaf8..7cccd060c443 100644 --- a/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts +++ b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts @@ -46,7 +46,7 @@ describe('findTerminalTabIdForLeaf after persistPtyBinding grafts a leaf', () => expect(findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1)).toBe('tab1') expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -73,7 +73,7 @@ describe('findTerminalTabIdForLeaf after persistPtyBinding grafts a leaf', () => expect(findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1)).toBeUndefined() expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, diff --git a/src/main/persistence-pty-binding-reconciliation.test.ts b/src/main/persistence-pty-binding-reconciliation.test.ts index f72234b6823f..47e4bd4ae76e 100644 --- a/src/main/persistence-pty-binding-reconciliation.test.ts +++ b/src/main/persistence-pty-binding-reconciliation.test.ts @@ -204,7 +204,7 @@ describe('Store', () => { } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, @@ -228,7 +228,7 @@ describe('Store', () => { terminalTopologyRevisionByRepoId: { wt1: 1 } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'fresh-tab', leafId: TEST_LEAF_1, @@ -330,7 +330,7 @@ describe('Store', () => { }) expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, @@ -370,7 +370,7 @@ describe('Store', () => { const staleRendererSession = structuredClone(store.getWorkspaceSession(hostId)) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'different-target-tab', @@ -393,7 +393,7 @@ describe('Store', () => { ).toBe(false) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt-canonical', tabId: 'tab1', @@ -425,7 +425,7 @@ describe('Store', () => { }) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'rejected-tab', diff --git a/src/main/persistence-split-pane-incarnation.test.ts b/src/main/persistence-split-pane-incarnation.test.ts index b092c3d7d9e8..25c12fd07b19 100644 --- a/src/main/persistence-split-pane-incarnation.test.ts +++ b/src/main/persistence-split-pane-incarnation.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' + +vi.mock('node:fs', { spy: true }) +import { rmSync, mkdtempSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' @@ -77,7 +79,7 @@ describe('Store', () => { }) expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -127,7 +129,7 @@ describe('Store', () => { store.setWorkspaceSession(sourceSession, hostId) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'tab1', @@ -174,7 +176,7 @@ describe('Store', () => { ) expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -213,7 +215,7 @@ describe('Store', () => { { ptyId: 'pty-current', expectedIncarnationId: 'inc-replaced' } ]) { expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, @@ -253,7 +255,7 @@ describe('Store', () => { store.setWorkspaceSession(structuredClone(session), 'ssh:ssh-1') expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'tab1', @@ -302,7 +304,7 @@ describe('Store', () => { ) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'tab1', @@ -348,11 +350,11 @@ describe('Store', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } }) - vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + vi.mocked(writeFileSync).mockImplementationOnce(() => { throw new Error('disk full') }) - expect(() => + await expect( store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', @@ -361,7 +363,7 @@ describe('Store', () => { incarnationId: 'inc-live', expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } }) - ).toThrow('disk full') + ).rejects.toThrow('disk full') expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( 'inc-stale' ) diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index 1b092293c682..afecbf56883c 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -782,4 +782,39 @@ describe('Store', () => { const store = await createStore() expect(store.getUI().browserKagiSessionLink).toBe(sessionLink) }) + + it.each(['shutdown', 'freeze', 'maintenance'] as const)( + 'rejects legacy SSH mutations before changing memory during %s', + async (gate) => { + const store = await createStore() + const recovery = { + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 3, + ownerGeneration: 5, + ownerLease: 'secret-owner-lease' + } + await store.upsertSshPtyConsumerRecovery(recovery) + store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' }) + await store.flushPendingOrThrowAsync() + const closing = + gate === 'shutdown' + ? store.flushAsync() + : gate === 'freeze' + ? store.freezeWritesAsync() + : store.beginProfileMaintenance() + await Promise.all( + [ + store.upsertSshPtyConsumerRecovery({ ...recovery, clientInstanceId: 'refused-owner' }), + store.removeSshPtyConsumerRecovery('ssh-1'), + store.markSshRemotePtyLeasesAsync('ssh-1', 'terminated'), + store.markSshRemotePtyLeasesAttachedAsync('ssh-1', ['pty-1']) + ].map((operation) => expect(operation).rejects.toThrow('finalized profile persistence')) + ) + expect(store.getSshPtyConsumerRecovery('ssh-1')).toEqual(recovery) + expect(store.getSshRemotePtyLeases('ssh-1')[0]?.state).toBe('detached') + await closing + } + ) }) diff --git a/src/main/persistence-worktree-deletion-fencing.test.ts b/src/main/persistence-worktree-deletion-fencing.test.ts index 62619bbd05bb..7674d0aecebe 100644 --- a/src/main/persistence-worktree-deletion-fencing.test.ts +++ b/src/main/persistence-worktree-deletion-fencing.test.ts @@ -87,7 +87,7 @@ describe('Store', () => { } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -132,7 +132,7 @@ describe('Store', () => { terminalTopologyRevisionByRepoId: { wt1: 1 } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -179,7 +179,7 @@ describe('Store', () => { store.setWorkspaceSession(stale) expect(store.getWorkspaceSession().tabsByWorktree.wt1).toEqual([]) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'fresh-tab', leafId: TEST_LEAF_2, @@ -261,7 +261,7 @@ describe('Store', () => { expect(store.getWorkspaceSession().tabsByWorktree[worktreeA]?.[0]?.id).toBe('tab-a') expect(store.getWorkspaceSession().tabsByWorktree[worktreeB]?.[0]?.id).toBe('tab-b') - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: worktreeB, tabId: 'fresh-tab', leafId: TEST_LEAF_1, @@ -281,7 +281,7 @@ describe('Store', () => { for (let index = 0; index < 25; index += 1) { const worktreeId = `repo::/worktree-${index}` store.setWorktreeMeta(worktreeId, { displayName: `Worktree ${index}` }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId, tabId: `tab-${index}`, leafId: TEST_LEAF_1, @@ -364,7 +364,7 @@ describe('Store', () => { } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts index 3b259989906e..07770fe70b91 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts @@ -1,3 +1,4 @@ +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import type { SshPtyConsumerRecovery } from '../../../shared/ssh-types' import type { PersistedState } from '../../../shared/persisted-state-types' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' @@ -7,16 +8,7 @@ import { normalizeSshPtyConsumerRecovery } from './ssh-normalization' export type SshPtyConsumerRecoveryOperations = { state: PersistedState protectedSecrets: Pick - flushDurableStateOrThrowAsync: () => Promise -} - -async function flushSshPtyConsumerRecovery( - operations: SshPtyConsumerRecoveryOperations -): Promise { - // Why: ownership must be durable before relay setup continues, but this runs on the live - // establish/reconnect path — a sync flush would park the main thread on a stalled profile mount. - // Why not caught here: the failure must reach the awaiting caller. - await operations.flushDurableStateOrThrowAsync() + runDurableMutation: StoreRuntimeState['runDurableMutation'] } export function getSshPtyConsumerRecovery( @@ -46,24 +38,37 @@ export async function upsertSshPtyConsumerRecovery( if (!normalized) { throw new Error('Invalid SSH PTY consumer recovery record') } - const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] - operations.state.sshPtyConsumerRecoveries = [ - ...recoveries.filter((candidate) => candidate.targetId !== normalized.targetId), - normalized - ] - await flushSshPtyConsumerRecovery(operations) + await operations.runDurableMutation(() => { + const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] + operations.state.sshPtyConsumerRecoveries = [ + ...recoveries.filter((candidate) => candidate.targetId !== normalized.targetId), + normalized + ] + return { value: undefined } + }) } export async function removeSshPtyConsumerRecovery( operations: SshPtyConsumerRecoveryOperations, - targetId: string + targetId: string, + expectedClientInstanceId?: string ): Promise { - const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] - const next = recoveries.filter((record) => record.targetId !== targetId) - if (next.length === recoveries.length) { - return + await operations.runDurableMutation(() => { + const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] + const current = recoveries.find((record) => record.targetId === targetId) + if ( + expectedClientInstanceId !== undefined && + current && + current.clientInstanceId !== expectedClientInstanceId + ) { + return { value: undefined, persist: false } + } + operations.state.sshPtyConsumerRecoveries = recoveries.filter( + (record) => record.targetId !== targetId + ) + return { value: undefined } + }) + if (!operations.state.sshPtyConsumerRecoveries?.some((record) => record.targetId === targetId)) { + operations.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId)) } - operations.state.sshPtyConsumerRecoveries = next - operations.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId)) - await flushSshPtyConsumerRecovery(operations) } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index f06e5b0ece71..b2e3cef030b9 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -1,3 +1,4 @@ +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' @@ -12,7 +13,7 @@ export type SshPtyLeaseOperations = { clearBindingsForTarget: (targetId: string) => void clearBindingsForLeases: (targetId: string, leases: SshRemotePtyLease[]) => boolean flush: () => void - flushDurableStateOrThrowAsync: () => Promise + runDurableMutation: StoreRuntimeState['runDurableMutation'] } /** @@ -93,7 +94,8 @@ function updateSshRemotePtyLeaseStates( operations: SshPtyLeaseOperations, targetId: string, state: SshRemotePtyLease['state'], - ptyIds?: ReadonlySet + ptyIds?: ReadonlySet, + admittedLeases?: ReadonlySet ): boolean { const now = Date.now() let changed = false @@ -101,7 +103,11 @@ function updateSshRemotePtyLeaseStates( const leasesToClear: SshRemotePtyLease[] = [] operations.state.sshRemotePtyLeases ??= [] for (const lease of operations.state.sshRemotePtyLeases) { - if (lease.targetId !== targetId || (ptyIds && !ptyIds.has(lease.ptyId))) { + if ( + lease.targetId !== targetId || + (ptyIds && !ptyIds.has(lease.ptyId)) || + (admittedLeases && !admittedLeases.has(lease)) + ) { continue } if (state === 'attached' && lease.state === 'terminated') { @@ -179,9 +185,12 @@ export async function markSshRemotePtyLeasesAsync( targetId: string, state: SshRemotePtyLease['state'] ): Promise { - if (updateSshRemotePtyLeaseStates(operations, targetId, state)) { - await operations.flushDurableStateOrThrowAsync() - } + // A newer connection can replace a lease while this operation waits for the writer. + const admittedLeases = new Set(getSshRemotePtyLeases(operations.state, targetId)) + await operations.runDurableMutation(() => { + updateSshRemotePtyLeaseStates(operations, targetId, state, undefined, admittedLeases) + return { value: undefined } + }) } export async function markSshRemotePtyLeasesAttachedAsync( @@ -190,9 +199,12 @@ export async function markSshRemotePtyLeasesAttachedAsync( ptyIds: readonly string[] ): Promise { const relayPtyIds = new Set(ptyIds.map((ptyId) => operations.toStoredPtyId(targetId, ptyId))) - if (updateSshRemotePtyLeaseStates(operations, targetId, 'attached', relayPtyIds)) { - await operations.flushDurableStateOrThrowAsync() - } + // A newer connection can replace a lease while this operation waits for the writer. + const admittedLeases = new Set(getSshRemotePtyLeases(operations.state, targetId)) + await operations.runDurableMutation(() => { + updateSshRemotePtyLeaseStates(operations, targetId, 'attached', relayPtyIds, admittedLeases) + return { value: undefined } + }) } /** `relayIdRecycled` is the pending-stop replay's evidence that the host now lists this id under a diff --git a/src/main/persistence/loading-store/primary-state-write-runtime.ts b/src/main/persistence/loading-store/primary-state-write-runtime.ts index 3100ca6bad5d..57f63bed3f96 100644 --- a/src/main/persistence/loading-store/primary-state-write-runtime.ts +++ b/src/main/persistence/loading-store/primary-state-write-runtime.ts @@ -7,6 +7,7 @@ export type PrimaryStateWriteOperationsRuntime = Pick< | 'dataFile' | 'dirtyProfileStateDomains' | 'flushOrThrow' + | 'runDurableMutation' | 'firstPendingSaveAt' | 'inFlightAsyncTmpFile' | 'lastDurableWriteGeneration' @@ -14,6 +15,7 @@ export type PrimaryStateWriteOperationsRuntime = Pick< | 'pendingSnapshotFileWork' | 'pendingAutomationRunsAfter' | 'pendingWrite' + | 'profileMaintenancePending' | 'profileStateAuthority' | 'protectedSecrets' | 'quitFlushStarted' @@ -39,6 +41,9 @@ export function canReuseDurableProfileState( return false } const authority = runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited revision check') + } if (authority && !authority.assertCurrentRevision) { return false } diff --git a/src/main/persistence/loading-store/primary-state-write-sync.ts b/src/main/persistence/loading-store/primary-state-write-sync.ts index d7d4a55ab2dd..afc7302dfcc6 100644 --- a/src/main/persistence/loading-store/primary-state-write-sync.ts +++ b/src/main/persistence/loading-store/primary-state-write-sync.ts @@ -11,10 +11,13 @@ import { export function writeToDiskSync( context: PrimaryStateWriteOperationsContext, opts: { force?: boolean; skipBackupRotation?: boolean; expectedGeneration?: number } = {} -): void { +): boolean { const { runtime, serialization, backups } = context if (runtime.writesFrozen) { - return + return false + } + if (runtime.profileStateAuthority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited flush') } const isCurrent = opts.expectedGeneration === undefined @@ -29,7 +32,7 @@ export function writeToDiskSync( ) if (selective.handled) { if (selective.aborted) { - return + return false } if (selective.consumedAutomationRuns) { runtime.pendingAutomationRunsAfter = undefined @@ -38,23 +41,23 @@ export function writeToDiskSync( runtime.protectedSecrets.commitRetentionUpdates(selective.protectedSecretUpdates) markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) runtime.profileStateAuthority?.scheduleBackup?.() - return + return true } const built = serialization.buildStateToSave( runtime.profileStateAuthority?.writeCompleteSerializedDomains !== undefined ) const { stateHash, protectedSecretUpdates } = built + if (isCurrent && !isCurrent()) { + return false + } // Why: matching hash means the file already holds this state; force overrides an async rename race. if (!opts.force && canReuseDurableProfileState(runtime, stateHash)) { runtime.dirtyProfileStateDomains = new Set() runtime.pendingAutomationRunsAfter = undefined markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) - return + return true } if (runtime.profileStateAuthority) { - if (isCurrent && !isCurrent()) { - return - } if (built.domains && runtime.profileStateAuthority.writeCompleteSerializedDomains) { runtime.profileStateAuthority.writeCompleteSerializedDomains(built.domains) } else { @@ -70,7 +73,7 @@ export function writeToDiskSync( } markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) runtime.profileStateAuthority.scheduleBackup?.() - return + return true } const dataFile = runtime.dataFile const payload = built.payload @@ -88,4 +91,5 @@ export function writeToDiskSync( if (!opts.skipBackupRotation && backups.shouldRotateBackups(now, dataFile)) { backups.rotateBackupsSync(dataFile) } + return true } diff --git a/src/main/persistence/loading-store/primary-state-write-worker.ts b/src/main/persistence/loading-store/primary-state-write-worker.ts new file mode 100644 index 000000000000..2d21b9e91b52 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-worker.ts @@ -0,0 +1,99 @@ +import type { AsyncProfileStateAuthority } from './profile-state-authority' +import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' +import { markPrimaryStateWriteDurable } from './primary-state-write-runtime' +import { prepareSelectiveProfileStateWrite } from './profile-state-selective-write' + +/** The queued operation owns its captured intent; later edits belong to the next write. */ +export async function writeProfileStateInWorker( + { runtime, serialization }: PrimaryStateWriteOperationsContext, + authority: AsyncProfileStateAuthority +): Promise { + authority.assertWritable() + const generation = runtime.writeGeneration + const dirtyDomains = runtime.dirtyProfileStateDomains + const automationRuns = runtime.pendingAutomationRunsAfter + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + + const restoreIntent = (): void => { + if (dirtyDomains === null) { + runtime.dirtyProfileStateDomains = null + } else if (runtime.dirtyProfileStateDomains !== null) { + for (const domain of dirtyDomains) { + runtime.dirtyProfileStateDomains.add(domain) + } + } + runtime.pendingAutomationRunsAfter ??= automationRuns + } + + try { + const prepared = beginWrite( + authority, + serialization, + dirtyDomains, + automationRuns, + runtime.lastWrittenStateHash, + () => runtime.writeGeneration === generation + ) + if (!prepared) { + restoreIntent() + return false + } + await prepared.completion + runtime.protectedSecrets.commitRetentionUpdates(prepared.protectedSecretUpdates) + runtime.lastWrittenStateHash = + runtime.writeGeneration === generation ? prepared.stateHash : null + markPrimaryStateWriteDurable(runtime, generation) + } catch (error) { + restoreIntent() + throw error + } + try { + authority.scheduleBackup?.() + } catch (error) { + console.error('[persistence] Failed to schedule profile state backup:', error) + } + return true +} + +/** Release copied payloads before the acknowledgement; retain only commit bookkeeping. */ +function beginWrite( + authority: AsyncProfileStateAuthority, + serialization: PrimaryStateWriteOperationsContext['serialization'], + dirtyDomains: ReadonlySet | null, + automationRuns: PrimaryStateWriteOperationsContext['runtime']['pendingAutomationRunsAfter'], + lastWrittenStateHash: string | null, + isCurrent: () => boolean +) { + const selective = prepareSelectiveProfileStateWrite( + authority, + serialization, + dirtyDomains, + automationRuns + ) + if (selective) { + if (!isCurrent()) { + return undefined + } + const completion = + selective.automationRuns !== undefined + ? authority.writeSerializedAutomationRuns(selective.replacements, selective.automationRuns) + : authority.writeSerializedDomains(selective.replacements) + return { completion, stateHash: null, protectedSecretUpdates: selective.protectedSecretUpdates } + } + const complete = serialization.buildStateToSave(true) + if (!isCurrent()) { + return undefined + } + const unchanged = complete.stateHash === lastWrittenStateHash + const completion = unchanged + ? authority.assertCurrentRevision() + : complete.domains + ? authority.writeCompleteSerializedDomains(complete.domains) + : authority.writeSerializedState(complete.payload) + return { + completion, + stateHash: complete.stateHash, + protectedSecretUpdates: unchanged ? [] : complete.protectedSecretUpdates + } +} diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index bbc2abd0d618..2d395268c390 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -15,6 +15,9 @@ import type { StateSerializationSecretHandlingOperations } from './state-seriali import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' import { writeToDiskSync } from './primary-state-write-sync' +import { writeProfileStateInWorker } from './primary-state-write-worker' +import type { DurableProfileStateMutation } from './store-runtime-state' +import { profileStateWriterFailureOutcome } from '../profile-state/profile-state-writer-errors' const primaryStateWriteOperationsContext = Symbol('PrimaryStateWriteOperations') export class PrimaryStateWriteOperations { @@ -31,9 +34,12 @@ export class PrimaryStateWriteOperations { flushOrThrow(): void { const context = this[primaryStateWriteOperationsContext] const { runtime } = context - if (runtime.quitFlushStarted) { + if (runtime.quitFlushStarted || runtime.profileMaintenancePending) { throw new Error('Cannot synchronously flush after final persistence has started') } + if (runtime.profileStateAuthority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited flush') + } if (runtime.writeTimer) { clearTimeout(runtime.writeTimer) runtime.writeTimer = null @@ -61,54 +67,118 @@ export class PrimaryStateWriteOperations { } flushActiveViewPreferenceOrThrow(): void { + if (this[primaryStateWriteOperationsContext].runtime.profileMaintenancePending) { + throw new Error('Cannot flush active-view persistence during profile maintenance') + } this[primaryStateWriteOperationsContext].runtime.activeViewPreference.flushOrThrow() } + runDurableMutation(mutate: () => DurableProfileStateMutation): Promise { + const { runtime } = this[primaryStateWriteOperationsContext] + if (runtime.writesFrozen || runtime.quitFlushStarted || runtime.profileMaintenancePending) { + return Promise.reject(new Error('Cannot mutate finalized profile persistence')) + } + return enqueuePrimaryStateOperation(this, async () => { + if (runtime.profileStateAuthority?.asynchronous) { + runtime.profileStateAuthority.assertWritable() + } + const mutation = mutate() + if (mutation.persist === false) { + return mutation.value + } + runtime.writeGeneration++ + const requiredGeneration = runtime.writeGeneration + try { + const captured = runtime.profileStateAuthority?.asynchronous + ? await writeToDiskAsync(this) + : writeToDiskSync(this[primaryStateWriteOperationsContext], { + expectedGeneration: requiredGeneration + }) + if (!captured || runtime.lastDurableWriteGeneration < requiredGeneration) { + throw new Error('Profile mutation changed while preparing its durable snapshot') + } + } catch (error) { + if (profileStateWriterFailureOutcome(error) !== 'indeterminate') { + mutation.rollback?.() + } + throw error + } + return mutation.value + }) + } + getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { return parseCodexResetCreditAttemptLedger( this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger ) } - replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { + replaceCodexResetCreditAttemptLedgerAndFlush( + ledger: CodexResetCreditAttemptLedger + ): Promise { const { runtime } = this[primaryStateWriteOperationsContext] - if (runtime.writesFrozen) { - throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') - } const next = parseCodexResetCreditAttemptLedger(ledger) - const previous = runtime.state.codexResetCreditAttemptLedger - ? structuredClone(runtime.state.codexResetCreditAttemptLedger) - : undefined - runtime.state.codexResetCreditAttemptLedger = next - runtime.dirtyProfileStateDomains?.add('codexResetCreditAttemptLedger') - try { - runtime.flushOrThrow() - } catch (error) { - // Why: callers use a successful return as the durability barrier before - // handing a scarce-credit mutation to the provider. - runtime.state.codexResetCreditAttemptLedger = previous - throw error - } + return this.runDurableMutation(() => { + const previous = runtime.state.codexResetCreditAttemptLedger + runtime.state.codexResetCreditAttemptLedger = next + runtime.dirtyProfileStateDomains?.add('codexResetCreditAttemptLedger') + return { + value: undefined, + rollback: () => { + if (runtime.state.codexResetCreditAttemptLedger === next) { + runtime.state.codexResetCreditAttemptLedger = previous + } + } + } + }) } } export function enqueueWrite( owner: PrimaryStateWriteOperations, - options: { fullCheckpoint?: boolean } = {} + options: { fullCheckpoint?: boolean; signal?: AbortSignal } = {} ): Promise { - const { runtime } = owner[primaryStateWriteOperationsContext] - const previousWrite = Promise.all([ - runtime.pendingWrite ?? runtime.staleTempCleanup, - runtime.pendingSnapshotFileWork ?? Promise.resolve() - ]).then(() => {}) - const write = previousWrite.then(() => { + return enqueuePrimaryStateOperation(owner, async () => { + const { runtime } = owner[primaryStateWriteOperationsContext] + const { signal } = options + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } // A queued predecessor can clear dirty domains before this checkpoint runs. if (options.fullCheckpoint) { runtime.dirtyProfileStateDomains = null } - return writeToDiskAsync(owner) + const authority = runtime.profileStateAuthority + const abort = () => { + if (authority?.asynchronous) { + void authority + .abort() + .catch((error) => + console.error('[persistence] Failed to stop aborted profile writer:', error) + ) + } + } + signal?.addEventListener('abort', abort, { once: true }) + try { + await writeToDiskAsync(owner) + } finally { + signal?.removeEventListener('abort', abort) + } }) +} + +export function enqueuePrimaryStateOperation( + owner: PrimaryStateWriteOperations, + operation: () => Promise +): Promise { + const { runtime } = owner[primaryStateWriteOperationsContext] + const previousWrite = Promise.all([ + runtime.pendingWrite ?? runtime.staleTempCleanup, + runtime.pendingSnapshotFileWork ?? Promise.resolve() + ]).then(() => {}) + const write = previousWrite.then(operation) const trackedWrite = write + .then(() => {}) .catch((err) => { console.error('[persistence] Failed to write state:', err) }) @@ -121,16 +191,21 @@ export function enqueueWrite( return write } -export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { +export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { const { runtime, serialization, backups } = owner[primaryStateWriteOperationsContext] if (runtime.writesFrozen) { - return + return false } const gen = runtime.writeGeneration + if (runtime.profileStateAuthority?.asynchronous) { + return writeProfileStateInWorker( + owner[primaryStateWriteOperationsContext], + runtime.profileStateAuthority + ) + } if (runtime.profileStateAuthority) { // SQL commits are synchronous so both entry points share the same generation fence. - writeToDiskSync(owner[primaryStateWriteOperationsContext], { expectedGeneration: gen }) - return + return writeToDiskSync(owner[primaryStateWriteOperationsContext], { expectedGeneration: gen }) } const built = serialization.buildStateToSave() const { stateHash, protectedSecretUpdates } = built @@ -139,7 +214,7 @@ export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Prom runtime.dirtyProfileStateDomains = new Set() runtime.pendingAutomationRunsAfter = undefined markPrimaryStateWriteDurable(runtime, gen) - return + return true } const dataFile = runtime.dataFile const payload = built.payload @@ -161,7 +236,7 @@ export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Prom } // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. if (runtime.writeGeneration !== gen) { - return + return false } runtime.inFlightAsyncTmpFile = tmpFile try { @@ -197,13 +272,14 @@ export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Prom } } if (!renamed) { - return + return false } // Why (#1158): rotate only after the primary rename while this write still owns its generation. if (runtime.writeGeneration !== gen) { - return + return true } await backups.rotateBackupsAsync(dataFile) + return true } export function installPrimaryStateWriteOperationsContext( diff --git a/src/main/persistence/loading-store/profile-state-authority.ts b/src/main/persistence/loading-store/profile-state-authority.ts index e59d7818932c..f366622a1fb7 100644 --- a/src/main/persistence/loading-store/profile-state-authority.ts +++ b/src/main/persistence/loading-store/profile-state-authority.ts @@ -1,14 +1,14 @@ import type { AutomationRun } from '../../../shared/automations-types' import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' -/** - * The primary profile-state boundary used by Store. - * - * The legacy implementation is still the default. Keeping this contract - * independent of SQLite lets the Node 18 orcad bundle load Store without - * eagerly loading a newer runtime's `node:sqlite` module. - */ +export type ProfileStateMaintenance = { + /** Re-admit the unchanged profile before permitting any new persistence work. */ + resume(): Promise +} + +/** Keep offline/compatibility persistence loadable without eagerly importing SQLite. */ export type ProfileStateAuthority = { + readonly asynchronous?: false /** Return storage-form JSON, or undefined when this authority has no state yet. */ readSerializedState(): string | undefined @@ -59,8 +59,49 @@ export type ProfileStateAuthority = { /** Release any process-local database handle before a profile is switched or removed. */ close?: () => void + + /** Only a clean maintenance close may provide an explicit resume capability. */ + pauseForMaintenance?: () => Promise +} + +export type AsyncProfileStateAuthority = Omit< + ProfileStateAuthority, + | 'asynchronous' + | 'assertCurrentRevision' + | 'writeSerializedDomains' + | 'writeSerializedAutomationRuns' + | 'writeSerializedState' + | 'writeCompleteSerializedDomains' + | 'writeJsonExport' + | 'writeJsonCompatibilityExport' + | 'quarantineDatabase' + | 'close' +> & { + readonly asynchronous: true + assertWritable(): void + abort(): Promise + assertCurrentRevision(): Promise + writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): Promise + writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ): Promise + writeSerializedState(payload: Buffer): Promise + writeCompleteSerializedDomains( + replacements: readonly ProfileStateDomainReplacement[] + ): Promise + writeJsonExport(targetPath: string): Promise + writeLatestJsonExport(dataFile: string): Promise + writeJsonCompatibilityExport(targetPath: string): Promise + quarantineDatabase( + quarantineRoot?: string, + reason?: string + ): Promise + close(): Promise } +export type ProfileStatePersistenceAuthority = ProfileStateAuthority | AsyncProfileStateAuthority + export type ProfileStateDomainReplacement = { domain: string /** Storage-form JSON for the domain, or null to remove its row. */ @@ -68,8 +109,10 @@ export type ProfileStateDomainReplacement = { } /** A startup read paired with the authority that observed its revision. */ -export type ProfileStateAuthorityInitialState = { - readonly authority: ProfileStateAuthority +export type ProfileStateAuthorityInitialState< + Authority extends ProfileStatePersistenceAuthority = ProfileStateAuthority +> = { + readonly authority: Authority } & ( | { readonly serializedState: string | undefined; readonly takeParsedState?: never } | { diff --git a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts new file mode 100644 index 000000000000..57a4915ad3f7 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts @@ -0,0 +1,130 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, vi } from 'vitest' +import type { AutomationRun } from '../../../shared/automations-types' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { writeVersionedProfileStateExport } from '../profile-state/profile-state-versioned-export' +import type { + AsyncProfileStateAuthority, + ProfileStateDomainReplacement +} from './profile-state-authority' +import { Store } from './store' + +export function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((accept, refuse) => { + resolve = accept + reject = refuse + }) + return { promise, resolve, reject } +} + +/** Delay the authority boundary while retaining real SQLite and Store serialization. */ +export class DelayedAuthority implements AsyncProfileStateAuthority { + readonly asynchronous = true + private next: + | { started: ReturnType>; finish: ReturnType> } + | undefined + readonly captures: ProfileStateDomainReplacement[][] = [] + readonly close = vi.fn(async () => { + this.inner.close() + }) + + constructor(readonly inner: ProfileStateSqliteAuthority) {} + + pause() { + const gate = { started: deferred(), finish: deferred() } + this.next = gate + return gate + } + + assertWritable() {} + async abort() {} + readSerializedState() { + return this.inner.readSerializedState() + } + async assertCurrentRevision() { + await this.dispatch(() => this.inner.assertCurrentRevision()) + } + async writeSerializedState(payload: Buffer) { + const captured = Buffer.from(payload) + await this.dispatch(() => this.inner.writeSerializedState(captured)) + } + async writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]) { + const captured = structuredClone(replacements) + this.captures.push([...captured]) + await this.dispatch(() => this.inner.writeSerializedDomains(captured)) + } + async writeCompleteSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]) { + const captured = structuredClone(replacements) + this.captures.push([...captured]) + await this.dispatch(() => this.inner.writeCompleteSerializedDomains(captured)) + } + async writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ) { + const captured = structuredClone(replacements) + const capturedRuns = structuredClone(runs) + await this.dispatch(() => this.inner.writeSerializedAutomationRuns(captured, capturedRuns)) + } + async writeJsonExport(path: string) { + return this.inner.writeJsonExport(path) + } + async writeLatestJsonExport(path: string) { + return writeVersionedProfileStateExport(path, this.inner.writeJsonExport.bind(this.inner)) + } + async writeJsonCompatibilityExport(path: string) { + return this.inner.writeJsonCompatibilityExportAsync(path) + } + async quarantineDatabase(root?: string, reason?: string) { + return this.inner.quarantineDatabase(root, reason) + } + private async dispatch(operation: () => void) { + const gate = this.next + this.next = undefined + gate?.started.resolve() + await gate?.finish.promise + operation() + } +} + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } + vi.restoreAllMocks() +}) + +export async function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-worker-coordination-')) + const path = join(directory, 'profile-state.db') + const inner = new ProfileStateSqliteAuthority(path, 'coordination-test') + inner.writeSerializedState( + Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(directory))) + ) + const authority = new DelayedAuthority(inner) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) + cleanups.push(async () => { + await store.freezeWritesAsync() + rmSync(directory, { recursive: true, force: true }) + }) + await store.flushPendingOrThrowAsync() + authority.captures.length = 0 + const readState = () => { + const reader = new ProfileStateSqliteAuthority(path, 'coordination-test') + try { + return JSON.parse(reader.readSerializedState() ?? '{}') + } finally { + reader.close() + } + } + return { store, authority, readState } +} diff --git a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts index 2be5aadf960c..9cf3d1ec2d1c 100644 --- a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts +++ b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts @@ -68,7 +68,7 @@ function fixture(seedState?: unknown) { } describe('SQLite durability barriers with another selective write pending', () => { - it('commits a reset-credit claim before returning to its provider caller', () => { + it('commits a reset-credit claim before returning to its provider caller', async () => { const state = fixture() const ledger: CodexResetCreditAttemptLedger = { version: 1, @@ -86,7 +86,7 @@ describe('SQLite durability barriers with another selective write pending', () = ] } state.pendSession() - state.store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) + await state.store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) expect(state.read('codexResetCreditAttemptLedger')).toMatchObject({ kind: 'value', value: ledger diff --git a/src/main/persistence/loading-store/profile-state-flush-lifetime.ts b/src/main/persistence/loading-store/profile-state-flush-lifetime.ts new file mode 100644 index 000000000000..215c1babe854 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-flush-lifetime.ts @@ -0,0 +1,34 @@ +import type { StoreRuntimeState } from './store-runtime-state' + +/** Lifecycle cleanup must join every accepted operation before closing its writer. */ +export async function drainProfileStateOperations( + operations: Iterable | null | undefined> +): Promise { + const settled = await Promise.allSettled( + Array.from(operations, (operation) => Promise.resolve(operation)) + ) + for (const result of settled) { + if (result.status === 'rejected') { + throw result.reason + } + } +} + +/** A flush may dispatch again after SQL completes while its sidecars are still pending. */ +export async function runProfileStateFlush( + runtime: Pick, + operation: () => Promise +): Promise { + let finish!: () => void + const pending = new Promise((resolve) => { + finish = resolve + }) + runtime.pendingProfileFlushes.add(pending) + try { + await operation() + } finally { + // Each caller owns its result; lifecycle barriers may retry a known failed capture. + runtime.pendingProfileFlushes.delete(pending) + finish() + } +} diff --git a/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts b/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts new file mode 100644 index 000000000000..3aee8de1dfb6 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts @@ -0,0 +1,103 @@ +import { build } from 'esbuild' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, vi } from 'vitest' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { ProfileStateWorkerAuthority } from '../profile-state/profile-state-worker-authority' +import { Store } from './store' + +let bundleRoot: string +let workerOptions: { workerPath: string; backupWorkerPath: string } +const stores: Store[] = [] +const roots: string[] = [] +const releases: (() => void)[] = [] +type ProfileFixtureLocation = { directory: string; profileId: string; cleanupRoot?: string } + +beforeAll(async () => { + bundleRoot = mkdtempSync(join(tmpdir(), 'orca-maintenance-worker-')) + workerOptions = { + workerPath: join(bundleRoot, 'profile-state-writer-worker-entry.js'), + backupWorkerPath: join(bundleRoot, 'profile-state-backup-worker-entry.js') + } + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts'), + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outdir: bundleRoot, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterEach(async () => { + for (const release of releases.splice(0)) { + release() + } + await Promise.all(stores.splice(0).map((store) => store.freezeWritesAsync().catch(() => {}))) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + vi.restoreAllMocks() + vi.useRealTimers() +}) +afterAll(() => rmSync(bundleRoot, { recursive: true, force: true })) + +export function maintenanceBarrier() { + const gate = Promise.withResolvers() + releases.push(gate.resolve) + return gate +} + +function paths(profile?: ProfileFixtureLocation) { + const directory = profile?.directory ?? mkdtempSync(join(tmpdir(), 'orca-maintenance-profile-')) + roots.push(profile?.cleanupRoot ?? directory) + return { + directory, + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: profile?.profileId ?? 'maintenance-test' + } +} + +export async function createWorkerMaintenanceFixture(profile?: ProfileFixtureLocation) { + const input = paths(profile) + const bootstrap = new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + bootstrap.writeSerializedState( + Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(input.directory))) + ) + const state = bootstrap.readInitialState().takeParsedState?.() + const authority = new ProfileStateWorkerAuthority(bootstrap.retireForWorker(), workerOptions) + await authority.ready + const store = new Store({ + dataFile: input.dataFile, + profileStateAuthority: authority, + initialAuthorityState: { authority, takeParsedState: () => state } + }) + stores.push(store) + const backup = vi.spyOn(authority, 'scheduleBackup').mockImplementation(() => {}) + await store.flushPendingOrThrowAsync() + backup.mockRestore() + const peer = () => new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + const readState = () => { + const reader = peer() + try { + return JSON.parse(reader.readSerializedState() ?? '{}') + } finally { + reader.close() + } + } + return { ...input, store, authority, peer, readState } +} + +export function createJsonMaintenanceFixture() { + const input = paths() + writeFileSync(input.dataFile, JSON.stringify(buildProfileStateCutoverFixture(input.directory))) + const store = new Store({ dataFile: input.dataFile }) + stores.push(store) + return { ...input, store } +} diff --git a/src/main/persistence/loading-store/profile-state-maintenance.test.ts b/src/main/persistence/loading-store/profile-state-maintenance.test.ts new file mode 100644 index 000000000000..16246cb29d71 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance.test.ts @@ -0,0 +1,287 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { getActiveViewPreferenceFile } from '../../active-view-preference' +import * as backupWorker from '../profile-state/profile-state-backup-worker' +import { profileStateDatabaseBackups } from '../profile-state/profile-state-backup-path' +import { + createJsonMaintenanceFixture, + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('profile maintenance admission', () => { + it('drains accepted writes and captures newer edits after blocking new durable operations', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const gate = maintenanceBarrier() + const started = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await gate.promise + await write(domains) + }) + const accepted = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: 'accepted' } + }) + await started.promise + const stopped = vi.spyOn(authority, 'close') + const paused = store.beginProfileMaintenance() + const refused = vi.fn(() => ({ value: undefined })) + await expect(store.runDurableMutation(refused)).rejects.toThrow('finalized') + expect(refused).not.toHaveBeenCalled() + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + await expect(store.writeLatestProfileStateJsonExportAsync()).rejects.toThrow('finalized') + expect(() => store.stageWorkspaceSessionBeforeUnload(store.getWorkspaceSession())).toThrow( + 'maintenance' + ) + store.updateSettings({ theme: 'light' }) + store.getWorkspaceSession().activeTabId = 'during-maintenance' + expect(stopped).not.toHaveBeenCalled() + gate.resolve() + await expect(accepted).resolves.toBe('accepted') + const maintenance = await paused + expect(stopped).toHaveBeenCalledOnce() + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'during-maintenance' } + }) + store.updateSettings({ theme: 'dark' }) + await maintenance.resume() + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('dark') + await expect(maintenance.resume()).rejects.toThrow('finalization') + }) + + it('refuses re-admission after a competing write without adopting its revision', async () => { + const { store, peer, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'dark' }) + const maintenance = await store.beginProfileMaintenance() + const writer = peer() + try { + writer.readSerializedState() + writer.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) + } finally { + writer.close() + } + await expect(maintenance.resume()).rejects.toThrow('Profile state revision changed') + const mutate = vi.fn(() => ({ value: undefined })) + await expect(store.runDurableMutation(mutate)).rejects.toThrow('finalized') + expect(mutate).not.toHaveBeenCalled() + expect(readState().peer).toEqual({ preserved: true }) + }) + + it.each(['maintenance', 'freeze', 'final'] as const)( + '%s waits for an admitted flush between SQL passes', + async (kind) => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(authority, 'drainBackups').mockImplementationOnce(async () => { + started.resolve() + await release.promise + }) + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await started.promise + store.updateSettings({ theme: 'light' }) + const capture = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const close = vi.spyOn(authority, 'close') + const paused = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : store.flushFinalOrThrowAsync() + await new Promise((resolve) => setImmediate(resolve)) + expect(capture).not.toHaveBeenCalled() + expect(close).not.toHaveBeenCalled() + release.resolve() + await older + await paused + expect(close).toHaveBeenCalled() + expect(readState().settings.theme).toBe('light') + } + ) + + it.each(['maintenance', 'freeze', 'final'] as const)( + '%s waits for accepted retries after another flush reports a known failure', + async (kind) => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(authority, 'drainBackups').mockImplementationOnce(async () => { + started.resolve() + await release.promise + }) + store.updateSettings({ theme: 'dark' }) + const accepted = store.flushPendingOrThrowAsync() + await started.promise + store.updateSettings({ theme: 'light' }) + const failureStarted = maintenanceBarrier() + const fail = maintenanceBarrier() + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { + failureStarted.resolve() + await fail.promise + throw new Error('disk refused') + }) + const failed = expect( + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ).rejects.toThrow('disk refused') + await failureStarted.promise + const close = vi.spyOn(authority, 'close') + const paused = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : store.flushFinalOrThrowAsync() + fail.resolve() + await failed + await new Promise((resolve) => setImmediate(resolve)) + expect(close).not.toHaveBeenCalled() + release.resolve() + await accepted + await paused + expect(close).toHaveBeenCalledOnce() + expect(readState().settings.theme).toBe('light') + } + ) + + it('waits for the backup worker before closing and releasing maintenance', async () => { + const { store, authority, databaseFile } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const run = backupWorker.runProfileStateBackupWorker + vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( + async (...args) => { + started.resolve() + await release.promise + await run(...args) + } + ) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await started.promise + const close = vi.spyOn(authority, 'close') + const paused = store.beginProfileMaintenance() + let done = false + void paused.then(() => { + done = true + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(done).toBe(false) + expect(close).not.toHaveBeenCalled() + release.resolve() + await paused + expect(close).toHaveBeenCalledOnce() + expect(profileStateDatabaseBackups(databaseFile)).toHaveLength(1) + }) + + it('drains an accepted flush before rejecting canceled maintenance', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(authority, 'drainBackups').mockImplementationOnce(async () => { + started.resolve() + await release.promise + }) + store.updateSettings({ theme: 'dark' }) + const accepted = store.flushPendingOrThrowAsync() + await started.promise + store.updateSettings({ theme: 'light' }) + const close = vi.spyOn(authority, 'close') + const controller = new AbortController() + controller.abort() + const rejected = expect( + store.beginProfileMaintenance({ signal: controller.signal }) + ).rejects.toThrow('aborted') + await new Promise((resolve) => setImmediate(resolve)) + expect(close).not.toHaveBeenCalled() + release.resolve() + await accepted + await rejected + expect(close).toHaveBeenCalledOnce() + expect(readState().settings.theme).toBe('light') + }) + + it('joins an ongoing maintenance close at final shutdown without reopening or rewriting', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const close = authority.close.bind(authority) + vi.spyOn(authority, 'close').mockImplementationOnce(async () => { + started.resolve() + await release.promise + await close() + }) + const paused = store.beginProfileMaintenance() + await started.promise + const write = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const final = store.flushFinalOrThrowAsync() + release.resolve() + const maintenance = await paused + await final + expect(write).not.toHaveBeenCalled() + await expect(maintenance.resume()).rejects.toThrow('finalization') + }) + + it('quarantines a faulted worker only after closing it, without writing current memory', async () => { + const { store, authority, directory, readState } = await createWorkerMaintenanceFixture() + const durable = readState() + store.updateSettings({ theme: durable.settings.theme === 'dark' ? 'light' : 'dark' }) + await authority.abort() + const result = await store.quarantineProfileStateDatabaseAsync(directory, 'worker-failure') + expect(result.copiedFiles.some((path) => path.endsWith('profile-state.db'))).toBe(true) + expect(readState()).toEqual(durable) + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + }) + + it('never provides a resume token after a faulted normal-maintenance attempt', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await authority.abort() + await expect(store.beginProfileMaintenance()).rejects.toThrow('aborted') + await expect(store.beginProfileMaintenance()).rejects.toThrow('already stopped') + }) + + it('pauses JSON and preference timers, then persists edits after unchanged-source resume', async () => { + const { store, dataFile } = createJsonMaintenanceFixture() + const maintenance = await store.beginProfileMaintenance() + const before = readFileSync(dataFile) + const preference = getActiveViewPreferenceFile(dataFile) + const preferenceBefore = readFileSync(preference) + vi.useFakeTimers() + store.updateSettings({ theme: 'dark' }) + store.updateUI({ activeView: 'settings' }) + await vi.advanceTimersByTimeAsync(6_000) + expect(readFileSync(dataFile)).toEqual(before) + expect(readFileSync(preference)).toEqual(preferenceBefore) + vi.useRealTimers() + await maintenance.resume() + await store.flushPendingOrThrowAsync() + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('dark') + expect(JSON.parse(readFileSync(preference, 'utf8')).activeView).toBe('settings') + }) + + it('refuses legacy JSON resume if the source changed during maintenance', async () => { + const { store, dataFile } = createJsonMaintenanceFixture() + const maintenance = await store.beginProfileMaintenance() + writeFileSync(dataFile, '{"peer":true}') + await expect(maintenance.resume()).rejects.toThrow('Profile storage changed') + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + expect(readFileSync(dataFile, 'utf8')).toBe('{"peer":true}') + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance.ts b/src/main/persistence/loading-store/profile-state-maintenance.ts new file mode 100644 index 000000000000..ef7da5b5a637 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance.ts @@ -0,0 +1,161 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { profileStateDatabaseFile } from '../../../shared/profile-state-storage-paths' +import { hasProfileStateDatabaseFiles } from '../profile-state/profile-state-storage-classification' +import type { ProfileStateMaintenance } from './profile-state-authority' +import type { StoreDomains } from './store-domain-composition' +import type { StoreRuntimeState } from './store-runtime-state' +import { drainProfileStateOperations } from './profile-state-flush-lifetime' +import { flushCurrentStateAsync } from './write-flush-barriers' +import { scheduleSave } from './write-scheduling' + +export type ProfileStateMaintenanceOptions = { + signal?: AbortSignal + /** Recovery must preserve the existing database even when its state cannot be flushed. */ + flush?: boolean +} + +export function freezeProfileStateWrites(runtime: StoreRuntimeState): void { + if (runtime.profileStateAuthority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited close') + } + runtime.writesFrozen = true + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.profileStateAuthority?.close?.() +} + +export async function freezeProfileStateWritesAsync(runtime: StoreRuntimeState): Promise { + runtime.quitFlushStarted = true + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + try { + await drainProfileStateOperations([ + runtime.pendingProfileMaintenance, + runtime.activeViewPreference.flushAsync(), + drainProfileFileWork(runtime) + ]) + } finally { + runtime.writesFrozen = true + await runtime.profileStateAuthority?.close?.() + } +} + +/** Stop admission before the first await; only unchanged-source maintenance may resume. */ +export function beginProfileStateMaintenance( + runtime: StoreRuntimeState, + domains: StoreDomains, + options: ProfileStateMaintenanceOptions = {} +): Promise { + if (runtime.profileMaintenancePending || runtime.quitFlushStarted || runtime.writesFrozen) { + return Promise.reject(new Error('Profile persistence is already stopped for maintenance')) + } + runtime.profileMaintenancePending = true + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + const resumePreference = runtime.activeViewPreference.pauseForMaintenance() + const paused = pauseProfileState(runtime, domains, options).then((authority) => { + let consumed = false + return { + resume: async () => { + if (consumed || runtime.quitFlushStarted || !runtime.profileMaintenancePending) { + throw new Error('Profile persistence cannot resume after finalization') + } + consumed = true + await authority.resume() + if (runtime.quitFlushStarted) { + await runtime.profileStateAuthority?.close?.() + throw new Error('Profile persistence finalized during maintenance admission') + } + runtime.writesFrozen = false + runtime.profileMaintenancePending = false + runtime.pendingProfileMaintenance = null + resumePreference() + scheduleSave(domains.scheduling) + } + } + }) + runtime.pendingProfileMaintenance = paused.then(() => {}) + void runtime.pendingProfileMaintenance.catch(() => {}) + return paused +} + +async function pauseProfileState( + runtime: StoreRuntimeState, + domains: StoreDomains, + { signal, flush = true }: ProfileStateMaintenanceOptions +): Promise { + const authority = runtime.profileStateAuthority + try { + if (signal?.aborted) { + throw new Error('Profile maintenance aborted') + } + await drainProfileStateOperations(runtime.pendingProfileFlushes) + await (flush + ? flushCurrentStateAsync(domains.flushBarriers, false, signal, true, true, true) + : drainProfileFileWork(runtime)) + runtime.writesFrozen = true + if (!flush) { + await authority?.drainBackups?.() + await authority?.close?.() + return { + resume: async () => { + throw new Error('Recovery maintenance requires reloading the profile') + } + } + } + if (authority?.pauseForMaintenance) { + return await authority.pauseForMaintenance() + } + await authority?.drainBackups?.() + await authority?.close?.() + if (authority) { + throw new Error('Profile authority cannot safely resume from maintenance') + } + return await pauseJsonProfile(runtime.dataFile) + } catch (error) { + try { + await drainProfileFileWork(runtime) + } finally { + runtime.writesFrozen = true + await authority?.close?.() + } + throw error + } +} + +async function drainProfileFileWork(runtime: StoreRuntimeState): Promise { + await drainProfileStateOperations([ + ...runtime.pendingProfileFlushes, + runtime.pendingWrite, + runtime.pendingSnapshotFileWork, + runtime.pendingGithubCacheWrite, + runtime.activeViewPreference.waitForPendingWrite() + ]) +} + +async function pauseJsonProfile(dataFile: string): Promise { + const before = createHash('sha256') + .update(await readFile(dataFile)) + .digest('hex') + return { + resume: async () => { + const current = createHash('sha256') + .update(await readFile(dataFile)) + .digest('hex') + if ( + hasProfileStateDatabaseFiles(profileStateDatabaseFile(dirname(dataFile))) || + current !== before + ) { + throw new Error('Profile storage changed during maintenance; reload is required') + } + } + } +} diff --git a/src/main/persistence/loading-store/profile-state-selective-write.ts b/src/main/persistence/loading-store/profile-state-selective-write.ts index 3e87f34ad7b6..060078ea4365 100644 --- a/src/main/persistence/loading-store/profile-state-selective-write.ts +++ b/src/main/persistence/loading-store/profile-state-selective-write.ts @@ -1,5 +1,9 @@ import type { ProtectedSecretRetentionUpdate } from '../../protected-secret-persistence' -import type { ProfileStateAuthority } from './profile-state-authority' +import type { + ProfileStateAuthority, + ProfileStateDomainReplacement, + ProfileStatePersistenceAuthority +} from './profile-state-authority' import { buildProfileStateDomainReplacements } from './profile-state-authority-writes' import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' import type { AutomationRun } from '../../../shared/automations-types' @@ -11,6 +15,13 @@ export type SelectiveProfileStateWriteResult = { protectedSecretUpdates: ProtectedSecretRetentionUpdate[] } +export type PreparedSelectiveProfileStateWrite = { + replacements: ProfileStateDomainReplacement[] + automationRuns: readonly AutomationRun[] | undefined + consumedAutomationRuns: boolean + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] +} + export function writeSelectiveProfileState( authority: ProfileStateAuthority | undefined, serialization: StateSerializationSecretHandlingOperations, @@ -18,27 +29,13 @@ export function writeSelectiveProfileState( pendingAutomationRunsAfter: readonly AutomationRun[] | undefined, isCurrent?: () => boolean ): SelectiveProfileStateWriteResult { - if ( - !authority || - dirtyDomains === null || - dirtyDomains.size === 0 || - !authority.writeSerializedDomains - ) { - return { - handled: false, - aborted: false, - consumedAutomationRuns: false, - protectedSecretUpdates: [] - } - } - const useAutomationDelta = - pendingAutomationRunsAfter !== undefined && - authority.writeSerializedAutomationRuns !== undefined - const serializableDomains = useAutomationDelta - ? new Set([...dirtyDomains].filter((domain) => domain !== 'automationRuns')) - : dirtyDomains - const built = serialization.buildStateDomainsToSave(serializableDomains) - if (built === undefined) { + const prepared = prepareSelectiveProfileStateWrite( + authority, + serialization, + dirtyDomains, + pendingAutomationRunsAfter + ) + if (!prepared) { return { handled: false, aborted: false, @@ -46,7 +43,6 @@ export function writeSelectiveProfileState( protectedSecretUpdates: [] } } - const { payload, protectedSecretUpdates } = built if (isCurrent && !isCurrent()) { return { handled: true, @@ -55,18 +51,48 @@ export function writeSelectiveProfileState( protectedSecretUpdates: [] } } - if (useAutomationDelta) { - authority.writeSerializedAutomationRuns?.( - buildProfileStateDomainReplacements(payload, serializableDomains), - pendingAutomationRunsAfter - ) + if (prepared.automationRuns !== undefined) { + authority?.writeSerializedAutomationRuns?.(prepared.replacements, prepared.automationRuns) } else { - authority.writeSerializedDomains(buildProfileStateDomainReplacements(payload, dirtyDomains)) + authority?.writeSerializedDomains?.(prepared.replacements) } - dirtyDomains.clear() + dirtyDomains?.clear() return { handled: true, aborted: false, + consumedAutomationRuns: prepared.consumedAutomationRuns, + protectedSecretUpdates: prepared.protectedSecretUpdates + } +} + +export function prepareSelectiveProfileStateWrite( + authority: ProfileStatePersistenceAuthority | undefined, + serialization: StateSerializationSecretHandlingOperations, + dirtyDomains: ReadonlySet | null, + pendingAutomationRunsAfter: readonly AutomationRun[] | undefined +): PreparedSelectiveProfileStateWrite | undefined { + if ( + !authority || + dirtyDomains === null || + dirtyDomains.size === 0 || + !authority.writeSerializedDomains + ) { + return undefined + } + const useAutomationDelta = + pendingAutomationRunsAfter !== undefined && + authority.writeSerializedAutomationRuns !== undefined + const serializableDomains = useAutomationDelta + ? new Set([...dirtyDomains].filter((domain) => domain !== 'automationRuns')) + : dirtyDomains + const built = serialization.buildStateDomainsToSave(serializableDomains) + if (built === undefined) { + return undefined + } + const { payload, protectedSecretUpdates } = built + return { + replacements: buildProfileStateDomainReplacements(payload, serializableDomains), + automationRuns: useAutomationDelta ? pendingAutomationRunsAfter : undefined, consumedAutomationRuns: pendingAutomationRunsAfter !== undefined, protectedSecretUpdates } diff --git a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts index 624055a77ee5..ef3fe5d40209 100644 --- a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts +++ b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts @@ -388,7 +388,7 @@ describe('Store with an injected SQLite profile-state authority', () => { reloaded.freezeWrites() }) - it('writes a PTY rebind through the workspace-session domain', () => { + it('writes a PTY rebind through the workspace-session domain', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-pty-domain-write-')) temporaryDirectories.push(directory) const dataFile = join(directory, 'orca-data.json') @@ -416,7 +416,7 @@ describe('Store with an injected SQLite profile-state authority', () => { } expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId, tabId, leafId, @@ -441,7 +441,7 @@ describe('Store with an injected SQLite profile-state authority', () => { throw new Error('fixture did not produce a normalized remote PTY binding') } expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: remoteWorktreeId, tabId: remoteTabId, diff --git a/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts new file mode 100644 index 000000000000..3af43a808f85 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('worker-owned Store writes', () => { + it('handles a rejected debounced save and retains it for an explicit retry', async () => { + const { store, authority, readState } = await fixture() + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + vi.useFakeTimers() + try { + store.updateSettings({ theme: 'dark' }) + await vi.advanceTimersByTimeAsync(1000) + await gate.started.promise + gate.finish.reject(new Error('disk refused')) + await store.waitForPendingWrite() + expect(log).toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('dark') + }) + + it('refuses an export when serialization invalidates its full checkpoint', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const publish = vi.spyOn(authority, 'writeLatestJsonExport') + const session = store.getWorkspaceSession() + Object.defineProperty(session, 'toJSON', { + configurable: true, + value: () => { + store.updateSettings({ theme: 'light' }) + return { ...session } + } + }) + try { + await expect(store.writeLatestProfileStateJsonExportAsync()).rejects.toThrow( + 'changed while preparing its export' + ) + expect(publish).not.toHaveBeenCalled() + } finally { + Reflect.deleteProperty(session, 'toJSON') + } + await store.writeLatestProfileStateJsonExportAsync() + expect(readState().settings.theme).toBe('light') + expect(publish).toHaveBeenCalledOnce() + }) + + it('retains a newer edit after an older write is acknowledged', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ theme: 'light' }) + gate.finish.resolve() + await first + expect(readState().settings.theme).toBe('dark') + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('light') + }) + + it('merges a failed older write with dirty state added while it was in flight', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const rejected = expect(first).rejects.toThrow('disk refused') + await gate.started.promise + store.getWorkspaceSession().activeTabId = 'newer-tab' + store.setWorkspaceSession(store.getWorkspaceSession()) + gate.finish.reject(new Error('disk refused')) + await rejected + await store.flushPendingOrThrowAsync() + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'newer-tab' } + }) + }) + + it('captures a full checkpoint after an older save even without a new generation', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.getWorkspaceSession().activeTabId = 'getter-only-tab' + const final = store.flushAsync() + gate.finish.resolve() + await first + await final + expect(readState().workspaceSession.activeTabId).toBe('getter-only-tab') + expect(authority.captures.at(-1)?.some(({ domain }) => domain === 'workspaceSession')).toBe( + true + ) + }) + + it('reserves ordering before an exact mutation changes live state', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const mutate = vi.fn(() => { + store.updateSettings({ theme: 'light' }) + return { value: 'durable' } + }) + const exact = store.runDurableMutation(mutate) + await Promise.resolve() + expect(mutate).not.toHaveBeenCalled() + expect(store.getSettings().theme).toBe('dark') + gate.finish.resolve() + await first + await expect(exact).resolves.toBe('durable') + expect(readState().settings.theme).toBe('light') + }) + + it('retains dirty intent while many durability waiters share an active snapshot', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ terminalFontSize: 12 }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const waiters: Promise[] = [first] + for (let size = 13; size <= 32; size++) { + store.updateSettings({ terminalFontSize: size }) + waiters.push(store.flushPendingOrThrowAsync({ drainToStableGeneration: false })) + } + await Promise.resolve() + expect(authority.captures).toHaveLength(1) + gate.finish.resolve() + await Promise.all(waiters) + expect(readState().settings.terminalFontSize).toBe(32) + }) + + it('cancels a queued checkpoint without aborting the preceding writer or losing edits', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const abortWriter = vi.spyOn(authority, 'abort') + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const controller = new AbortController() + store.updateSettings({ theme: 'light' }) + const canceled = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const refused = expect(canceled).rejects.toThrow('aborted') + controller.abort() + gate.finish.resolve() + await first + await refused + expect(abortWriter).not.toHaveBeenCalled() + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('light') + }) + + it.each(['known-failure', 'indeterminate'] as const)( + 'only rolls back a mutation with a known failure (%s)', + async (outcome) => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rollback = vi.fn() + const exact = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined, rollback } + }) + const rejected = expect(exact).rejects.toThrow('write failed') + await gate.started.promise + gate.finish.reject(new ProfileStateWriterError('test', 'write failed', outcome)) + await rejected + expect(rollback).toHaveBeenCalledTimes(outcome === 'known-failure' ? 1 : 0) + } + ) + + it('awaits accepted operations before closing and refuses new exact mutations', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + const first = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + const closing = store.freezeWritesAsync() + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) + expect(authority.close).not.toHaveBeenCalled() + gate.finish.resolve() + await first + await closing + expect(authority.close).toHaveBeenCalledTimes(1) + expect(readState().settings.theme).toBe('dark') + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts new file mode 100644 index 000000000000..09e004bde1e7 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getSecretStore, setSecretStore } from '../../../shared/secret-store' +import { fixture } from './profile-state-delayed-authority-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +let encryptionAvailable = true +let previousSecretStore: ReturnType +const ciphertext = (plaintext: string) => Buffer.from(`sealed:${plaintext}`).toString('base64') + +beforeEach(() => { + encryptionAvailable = true + previousSecretStore = getSecretStore() + setSecretStore({ + isEncryptionAvailable: () => encryptionAvailable, + encryptString: (value) => Buffer.from(`sealed:${value}`), + decryptString: (value) => value.toString().slice('sealed:'.length), + describeProtectionGap: () => null + }) +}) +afterEach(() => setSecretStore(previousSecretStore)) + +describe('Store secret retention across worker acknowledgements', () => { + it('does not restore ciphertext cleared while its commit acknowledgement was pending', async () => { + const { store, authority, readState } = await fixture() + store.updateSettings({ opencodeSessionCookie: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ opencodeSessionCookie: 'in-flight' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ opencodeSessionCookie: '' }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(readState().settings.opencodeSessionCookie).toBe('') + expect(store.getSettings().opencodeSessionCookie).toBe('') + }) + + it('retains confirmed ciphertext until a newer secret can be encrypted', async () => { + const { store, authority, readState } = await fixture() + store.updateSettings({ opencodeSessionCookie: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ opencodeSessionCookie: 'in-flight' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ opencodeSessionCookie: 'newer' }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(readState().settings.opencodeSessionCookie).toBe(ciphertext('in-flight')) + expect(store.getSettings().opencodeSessionCookie).toBe('newer') + encryptionAvailable = true + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings.opencodeSessionCookie).toBe(ciphertext('newer')) + }) + + it('retains the earlier ciphertext after a failed write and retries newer plaintext', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.updateSettings({ opencodeSessionCookie: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ opencodeSessionCookie: 'failed' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const failure = expect(write).rejects.toThrow('disk refused') + await gate.started.promise + store.updateSettings({ opencodeSessionCookie: 'newer' }) + encryptionAvailable = false + gate.finish.reject(new Error('disk refused')) + await failure + await store.flushPendingOrThrowAsync() + expect(readState().settings.opencodeSessionCookie).toBe(ciphertext('durable')) + encryptionAvailable = true + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings.opencodeSessionCookie).toBe(ciphertext('newer')) + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-async-durability.test.ts b/src/main/persistence/loading-store/pty-binding-async-durability.test.ts new file mode 100644 index 000000000000..ef4de7e130a8 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-async-durability.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'async-binding-tab', + leafId: TEST_LEAF_1, + ptyId: 'async-binding-pty', + incarnationId: 'async-binding-incarnation' +} + +describe('durable asynchronous PTY binding', () => { + it('acknowledges only after the binding reaches SQLite', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + let acknowledged = false + const pending = store.persistPtyBinding(binding).then((result) => { + acknowledged = true + return result + }) + await gate.started.promise + expect(acknowledged).toBe(false) + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + gate.finish.resolve() + expect(await pending).toBe(true) + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({ + [binding.leafId]: binding.ptyId + }) + }) + + it('evaluates membership refusal after an older write finishes', async () => { + const { store, authority } = await fixture() + await store.persistPtyBinding(binding) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const resolveBinding = vi.fn(() => ({ ...binding, mayCreate: false })) + const queued = store.persistPtyBinding(resolveBinding) + expect(resolveBinding).not.toHaveBeenCalled() + const session = store.getWorkspaceSession() + session.tabsByWorktree[binding.worktreeId] = [] + delete session.terminalLayoutsByTabId[binding.tabId] + store.setWorkspaceSession(session) + gate.finish.resolve() + await older + expect(await queued).toBe(false) + expect(resolveBinding).toHaveBeenCalledTimes(1) + }) + + it('restores the binding after a known write failure', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const before = structuredClone(store.getWorkspaceSession()) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect(store.getWorkspaceSession()).toEqual(before) + }) + + it('preserves newer getter edits when an older binding write fails', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId = { + [binding.leafId]: 'newer-pty' + } + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({ + [binding.leafId]: 'newer-pty' + }) + }) + + it('rolls back a failed binding while retaining an unrelated newer navigation edit', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + store.getWorkspaceSession().activeRepoId = 'newer-repo' + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect(store.getWorkspaceSession().activeRepoId).toBe('newer-repo') + expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + }) + + it('removes a failed new binding while retaining a newer sibling tab', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + const tabs = store.getWorkspaceSession().tabsByWorktree[binding.worktreeId] + const boundTab = tabs.find((tab) => tab.id === binding.tabId) + if (!boundTab) { + throw new Error('binding did not create its terminal row') + } + tabs.push({ ...boundTab, id: 'newer-sibling', ptyId: 'newer-sibling-pty' }) + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect( + store.getWorkspaceSession().tabsByWorktree[binding.worktreeId].map((tab) => tab.id) + ).toContain('newer-sibling') + expect( + store.getWorkspaceSession().tabsByWorktree[binding.worktreeId].map((tab) => tab.id) + ).not.toContain(binding.tabId) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + }) + + it('retains a binding whose commit outcome is indeterminate', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('worker exited') + await gate.started.promise + gate.finish.reject( + new ProfileStateWriterError('test-worker-exit', 'worker exited', 'indeterminate') + ) + await rejected + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({ + [binding.leafId]: binding.ptyId + }) + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 242a94941f00..faf828932fce 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -1,15 +1,8 @@ +import { isDeepStrictEqual } from 'node:util' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' -import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' -import { - cloneLayoutNode, - layoutContainsLeafId -} from '../restoring-sessions/terminal-layout-normalization' -import { - cloneWorkspaceSessionState, - createMinimalPersistedTerminalTab -} from '../restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../restoring-sessions/workspace-session-write-rollback' +import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields' import type { PtyBindingSourceExpectation } from './store' @@ -18,12 +11,12 @@ import type { SessionHostPartitionOperations } from './session-host-partitions' import { resolveHostId } from './session-host-partitions' import { evaluatePtyBindingFastLane } from './pty-binding-fast-lane' import { ptyBindingIsRefused } from './pty-binding-refusals' -import { startPtyBindingSpan, type PtyBindingOrigin } from './pty-binding-span' -import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' +import { startPtyBindingSpan, type PtyBindingOrigin, type PtyBindingSpan } from './pty-binding-span' +import { applyPtyBinding } from './pty-binding-session-update' type PtyBindingPersistenceOperationsRuntime = Pick< StoreRuntimeState, - | 'flushOrThrow' + | 'runDurableMutation' | 'lastDurableWriteGeneration' | 'pendingWrite' | 'quitFlushStarted' @@ -33,7 +26,7 @@ type PtyBindingPersistenceOperationsRuntime = Pick< | 'writeTimer' > -type PersistPtyBindingArgs = { +export type PersistPtyBindingArgs = { worktreeId: string tabId: string leafId: string @@ -73,43 +66,57 @@ export class PtyBindingPersistenceOperations { this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } } - persistPtyBinding(args: PersistPtyBindingArgs, hostId?: string | null): boolean { - const runtime = this[ptyBindingPersistenceOperationsContext].runtime + async persistPtyBinding( + input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs | null), + hostId?: string | null + ): Promise { + const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] const resolvedHostId = resolveHostId(hostId) - const session = - this[ptyBindingPersistenceOperationsContext].sessions.getWorkspaceSession(resolvedHostId) - const paneKey = `${args.tabId}:${args.leafId}` - const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId - const span = startPtyBindingSpan({ - hostKind: parseExecutionHostId(resolvedHostId)?.kind ?? 'local', - origin: args.origin ?? 'unknown', - savePending: runtime.writeTimer !== null || runtime.pendingWrite !== null, - generationGap: runtime.writeGeneration - runtime.lastDurableWriteGeneration - }) - if (ptyBindingIsRefused(args, session, bindingWorktreeId, paneKey)) { - span.finish('refused') - return false - } - // A durable reattach needs neither a session clone nor whole-state serialization. - const verdict = evaluatePtyBindingFastLane( - args, - session, - bindingWorktreeId, - !runtime.quitFlushStarted && runtime.lastDurableWriteGeneration >= runtime.writeGeneration - ) - span.setEligibility(verdict) - if (verdict.eligible) { - span.finish('fast_lane') - return true - } + const savePending = runtime.writeTimer !== null || runtime.pendingWrite !== null + let span: PtyBindingSpan | undefined + let outcome: 'refused' | 'fast_lane' | 'flushed' = 'flushed' try { - writePtyBinding(this, args, session, resolvedHostId, bindingWorktreeId, paneKey) - } catch (err) { - span.finish('threw', err) - throw err + const persisted = await runtime.runDurableMutation(() => { + const args = typeof input === 'function' ? input() : input + if (!args) { + return { value: false, persist: false } + } + // Measure the admitted binding operation; queue time precedes its current-state checks. + span = startPtyBindingSpan({ + hostKind: parseExecutionHostId(resolvedHostId)?.kind ?? 'local', + origin: args.origin ?? 'unknown', + savePending, + generationGap: runtime.writeGeneration - runtime.lastDurableWriteGeneration + }) + const paneKey = `${args.tabId}:${args.leafId}` + const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId + const session = sessions.getWorkspaceSession(resolvedHostId) + if (ptyBindingIsRefused(args, session, bindingWorktreeId, paneKey)) { + outcome = 'refused' + return { value: false, persist: false } + } + const verdict = evaluatePtyBindingFastLane( + args, + session, + bindingWorktreeId, + !runtime.quitFlushStarted && runtime.lastDurableWriteGeneration >= runtime.writeGeneration + ) + span.setEligibility(verdict) + if (verdict.eligible) { + outcome = 'fast_lane' + return { value: true, persist: false } + } + return { + value: true, + rollback: writePtyBinding(this, args, session, resolvedHostId, bindingWorktreeId, paneKey) + } + }) + span?.finish(outcome) + return persisted + } catch (error) { + span?.finish('threw', error) + throw error } - span.finish('flushed') - return true } } @@ -120,164 +127,54 @@ function writePtyBinding( resolvedHostId: ReturnType, bindingWorktreeId: string, paneKey: string -): void { - const runtime = owner[ptyBindingPersistenceOperationsContext].runtime +): () => void { + const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext] const sessionBeforeBinding = cloneWorkspaceSessionState(session) - try { - if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { - runtime.state.workspaceSessionsByHostId = { - ...runtime.state.workspaceSessionsByHostId, - [resolvedHostId]: session - } - } - applyPtyBinding(args, session, bindingWorktreeId, paneKey) - // The binding path flushes synchronously; mark the domain without scheduling a second timer. - const dirtyDomains = runtime.dirtyProfileStateDomains - if (dirtyDomains !== null) { - dirtyDomains.add( - resolvedHostId === LOCAL_EXECUTION_HOST_ID - ? 'workspaceSession' - : 'workspaceSessionsByHostId' - ) - } - runtime.flushOrThrow() - } catch (err) { + const restore = (restoredSession = sessionBeforeBinding): void => { if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { - runtime.state.workspaceSession = sessionBeforeBinding + runtime.state.workspaceSession = restoredSession } else { runtime.state.workspaceSessionsByHostId = { ...runtime.state.workspaceSessionsByHostId, - [resolvedHostId]: sessionBeforeBinding + [resolvedHostId]: restoredSession } } - throw err - } -} - -function applyPtyBinding( - args: PersistPtyBindingArgs, - session: WorkspaceSessionState, - bindingWorktreeId: string, - paneKey: string -): void { - const reconciledIncarnation = - args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId - let terminalMembershipChanged = false - let hostAdmittedTabCreated = false - const advanceTopologyFence = (): void => { - const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) - const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - // Why: a split, or a host-admitted tab the renderer has never seen, is itself - // the authority — with no fence the renderer's pre-create tab list replays - // over it and the tab is lost even on the repo's first such change. - const establishesMembershipAuthority = - args.expectedSourceBinding !== undefined || hostAdmittedTabCreated - if ( - !reconciledIncarnation && - (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) - ) { - return - } - // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. - session.terminalTopologyRevisionByRepoId = { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: currentRevision + 1 - } } - if (args.incarnationId) { - session.terminalPtyIncarnationsByPaneKey = { - ...session.terminalPtyIncarnationsByPaneKey, - [paneKey]: args.incarnationId - } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { - session.terminalSurfaceTombstonesByPaneKey = { - ...session.terminalSurfaceTombstonesByPaneKey + try { + if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: session } - delete session.terminalSurfaceTombstonesByPaneKey[paneKey] } - } - const tabs = session.tabsByWorktree?.[bindingWorktreeId] - const tab = tabs?.find((t) => t.id === args.tabId) - if (tab) { - tab.ptyId = tabRowPtyIdAfterLeafBinding( - tab, - session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId, - args.leafId, - args.ptyId + applyPtyBinding(args, session, bindingWorktreeId, paneKey) + runtime.dirtyProfileStateDomains?.add( + resolvedHostId === LOCAL_EXECUTION_HOST_ID ? 'workspaceSession' : 'workspaceSessionsByHostId' ) - } else { - terminalMembershipChanged = true - hostAdmittedTabCreated = args.hostAdmittedMembership === true - // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. - const nextTabs = [ - ...(tabs ?? []), - createMinimalPersistedTerminalTab({ - ...args, - worktreeId: bindingWorktreeId, - existingTabCount: tabs?.length ?? 0 + const boundSession = cloneWorkspaceSessionState(session) + return () => { + const current = sessions.getWorkspaceSession(resolvedHostId) + const ownerState = (value: WorkspaceSessionState) => ({ + tab: value.tabsByWorktree[bindingWorktreeId]?.find((tab) => tab.id === args.tabId), + layout: value.terminalLayoutsByTabId[args.tabId], + incarnation: value.terminalPtyIncarnationsByPaneKey?.[paneKey] }) - ] - session.tabsByWorktree = { - ...session.tabsByWorktree, - [bindingWorktreeId]: nextTabs - } - session.activeWorktreeId ??= bindingWorktreeId - session.activeTabId ??= args.tabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId - } - } - // Why: host-initiated persist snapshots used to omit this write-once guard, so every launch or reattach treated the worktree as never having default terminals applied. - session.defaultTerminalTabsAppliedByWorktreeId = { - ...session.defaultTerminalTabsAppliedByWorktreeId, - [bindingWorktreeId]: true - } - if (!isTerminalLeafId(args.leafId)) { - // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyFence() - return - } - const layout = session.terminalLayoutsByTabId?.[args.tabId] - if (layout) { - if (!layout.root) { - terminalMembershipChanged = true - // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. - layout.root = { type: 'leaf', leafId: args.leafId } - layout.activeLeafId = args.leafId - layout.expandedLeafId = null - } else if (!layoutContainsLeafId(layout.root, args.leafId)) { - terminalMembershipChanged = true - // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. - layout.root = { - type: 'split', - direction: 'vertical', - first: cloneLayoutNode(layout.root), - second: { type: 'leaf', leafId: args.leafId } - } - layout.activeLeafId = args.leafId - if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { - layout.expandedLeafId = null + if (!isDeepStrictEqual(ownerState(current), ownerState(boundSession))) { + return } - } - layout.ptyIdsByLeafId = { - ...layout.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } - } else { - terminalMembershipChanged = true - // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. - session.terminalLayoutsByTabId = { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [args.leafId]: args.ptyId } + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( + sessionBeforeBinding, + boundSession, + current + ) + if (rolledBack !== current) { + restore(rolledBack) } } + } catch (error) { + restore() + throw error } - advanceTopologyFence() } export function installPtyBindingPersistenceOperationsContext( diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts new file mode 100644 index 000000000000..054d87bffd43 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -0,0 +1,136 @@ +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { + cloneLayoutNode, + layoutContainsLeafId +} from '../restoring-sessions/terminal-layout-normalization' +import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' +import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' +import type { PersistPtyBindingArgs } from './pty-binding-persistence' + +export function applyPtyBinding( + args: PersistPtyBindingArgs, + session: WorkspaceSessionState, + bindingWorktreeId: string, + paneKey: string +): void { + const reconciledIncarnation = + args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId + let terminalMembershipChanged = false + let hostAdmittedTabCreated = false + const advanceTopologyFence = (): void => { + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) + const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + // Why: a split, or a host-admitted tab the renderer has never seen, is itself + // the authority — with no fence the renderer's pre-create tab list replays + // over it and the tab is lost even on the repo's first such change. + const establishesMembershipAuthority = + args.expectedSourceBinding !== undefined || hostAdmittedTabCreated + if ( + !reconciledIncarnation && + (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) + ) { + return + } + // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. + session.terminalTopologyRevisionByRepoId = { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: currentRevision + 1 + } + } + if (args.incarnationId) { + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [paneKey]: args.incarnationId + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + session.terminalSurfaceTombstonesByPaneKey = { + ...session.terminalSurfaceTombstonesByPaneKey + } + delete session.terminalSurfaceTombstonesByPaneKey[paneKey] + } + } + const tabs = session.tabsByWorktree?.[bindingWorktreeId] + const tab = tabs?.find((t) => t.id === args.tabId) + if (tab) { + tab.ptyId = tabRowPtyIdAfterLeafBinding( + tab, + session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId, + args.leafId, + args.ptyId + ) + } else { + terminalMembershipChanged = true + hostAdmittedTabCreated = args.hostAdmittedMembership === true + // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. + const nextTabs = [ + ...(tabs ?? []), + createMinimalPersistedTerminalTab({ + ...args, + worktreeId: bindingWorktreeId, + existingTabCount: tabs?.length ?? 0 + }) + ] + session.tabsByWorktree = { + ...session.tabsByWorktree, + [bindingWorktreeId]: nextTabs + } + session.activeWorktreeId ??= bindingWorktreeId + session.activeTabId ??= args.tabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId + } + } + // Why: host-initiated persist snapshots used to omit this write-once guard, so every launch or reattach treated the worktree as never having default terminals applied. + session.defaultTerminalTabsAppliedByWorktreeId = { + ...session.defaultTerminalTabsAppliedByWorktreeId, + [bindingWorktreeId]: true + } + if (!isTerminalLeafId(args.leafId)) { + // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. + advanceTopologyFence() + return + } + const layout = session.terminalLayoutsByTabId?.[args.tabId] + if (layout) { + if (!layout.root) { + terminalMembershipChanged = true + // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. + layout.root = { type: 'leaf', leafId: args.leafId } + layout.activeLeafId = args.leafId + layout.expandedLeafId = null + } else if (!layoutContainsLeafId(layout.root, args.leafId)) { + terminalMembershipChanged = true + // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. + layout.root = { + type: 'split', + direction: 'vertical', + first: cloneLayoutNode(layout.root), + second: { type: 'leaf', leafId: args.leafId } + } + layout.activeLeafId = args.leafId + if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { + layout.expandedLeafId = null + } + } + layout.ptyIdsByLeafId = { + ...layout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } else { + terminalMembershipChanged = true + // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [args.leafId]: args.ptyId } + } + } + } + advanceTopologyFence() +} diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index 2389b4471516..8af16c7e3b2d 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -18,7 +18,12 @@ import { scheduleSave } from './write-scheduling' type SessionSnapshotOperationsRuntime = Pick< StoreRuntimeState, - 'pendingSnapshotFileWork' | 'state' | 'terminalScrollbackSnapshotStorage' + | 'pendingSnapshotFileWork' + | 'profileMaintenancePending' + | 'quitFlushStarted' + | 'state' + | 'terminalScrollbackSnapshotStorage' + | 'writesFrozen' > const sessionSnapshotOperationsContext = Symbol('SessionSnapshotOperations') @@ -44,6 +49,7 @@ export class SessionSnapshotOperations { setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { const resolved = resolveHostId(hostId) if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.assertSnapshotAdmission() setLocalWorkspaceSession(this, session) return } @@ -56,6 +62,7 @@ export class SessionSnapshotOperations { ): void { const resolved = resolveHostId(hostId) if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.assertSnapshotAdmission() setLocalWorkspaceSession(this, session, true) return } @@ -89,6 +96,13 @@ export class SessionSnapshotOperations { resolved === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId'] ) } + + private assertSnapshotAdmission(): void { + const { runtime } = this[sessionSnapshotOperationsContext] + if (runtime.profileMaintenancePending || runtime.quitFlushStarted || runtime.writesFrozen) { + throw new Error('Profile maintenance or finalization is blocking new terminal snapshot work') + } + } } export function getSessionSnapshotOperationsContext(owner: SessionSnapshotOperations) { diff --git a/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts b/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts new file mode 100644 index 000000000000..6c646285d4bb --- /dev/null +++ b/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { removeSshPtyConsumerOwnerRecovery } from '../../ssh/ssh-pty-consumer-recovery' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const recovery = { + targetId: 'async-target', + clientInstanceId: 'first-owner', + serverBuildId: 'build', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'owner-lease' +} + +describe('reserved SSH persistence', () => { + it('reserves consumer replacement before mutation and durably writes both exact owners', async () => { + const { store, authority } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await gate.started.promise + const first = store.upsertSshPtyConsumerRecovery(recovery) + const second = store.upsertSshPtyConsumerRecovery({ + ...recovery, + clientInstanceId: 'next-owner' + }) + expect(store.getSshPtyConsumerRecovery(recovery.targetId)).toBeNull() + gate.finish.resolve() + await Promise.all([older, first, second]) + const ownerWrites = authority.captures + .flat() + .filter(({ domain }) => domain === 'sshPtyConsumerRecoveries') + expect(ownerWrites).toHaveLength(2) + expect(ownerWrites[0]?.payload).toContain('first-owner') + expect(ownerWrites[1]?.payload).toContain('next-owner') + }) + + it('retries a failed consumer removal through durability even after memory is already empty', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.upsertSshPtyConsumerRecovery(recovery) + const gate = authority.pause() + const removal = expect( + removeSshPtyConsumerOwnerRecovery(recovery.targetId, recovery.clientInstanceId, store) + ).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject(new Error('disk refused')) + await removal + expect(readState().sshPtyConsumerRecoveries).toHaveLength(1) + await removeSshPtyConsumerOwnerRecovery(recovery.targetId, recovery.clientInstanceId, store) + expect(readState().sshPtyConsumerRecoveries).toEqual([]) + }) + + it('does not let an old consumer remove the newer owner ahead of it in the write queue', async () => { + const { store, authority, readState } = await fixture() + await store.upsertSshPtyConsumerRecovery(recovery) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await gate.started.promise + const replacement = store.upsertSshPtyConsumerRecovery({ + ...recovery, + clientInstanceId: 'next-owner' + }) + const removal = removeSshPtyConsumerOwnerRecovery( + recovery.targetId, + recovery.clientInstanceId, + store + ) + gate.finish.resolve() + await Promise.all([older, replacement, removal]) + expect(readState().sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('next-owner') + }) + + it('does not detach a newer replacement lease while waiting for the writer', async () => { + const { store, authority, readState } = await fixture() + const lease = { targetId: recovery.targetId, ptyId: 'relay-pty', state: 'attached' as const } + store.upsertSshRemotePtyLease(lease) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await gate.started.promise + const detach = store.markSshRemotePtyLeasesAsync(recovery.targetId, 'detached') + expect(store.getSshRemotePtyLeases(recovery.targetId)[0]?.state).toBe('attached') + store.upsertSshRemotePtyLease({ ...lease, worktreeId: 'new-worktree' }) + gate.finish.resolve() + await Promise.all([older, detach]) + expect(readState().sshRemotePtyLeases).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ptyId: 'relay-pty', + worktreeId: 'new-worktree', + state: 'attached' + }) + ]) + ) + }) + + it('does not acknowledge a failed attachment retry before its lease reaches disk', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.upsertSshRemotePtyLease({ + targetId: recovery.targetId, + ptyId: 'relay-pty', + state: 'expired' + }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + const attachment = expect( + store.markSshRemotePtyLeasesAttachedAsync(recovery.targetId, ['relay-pty']) + ).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject(new Error('disk refused')) + await attachment + expect(readState().sshRemotePtyLeases[0].state).toBe('expired') + await store.markSshRemotePtyLeasesAttachedAsync(recovery.targetId, ['relay-pty']) + expect(readState().sshRemotePtyLeases[0].state).toBe('attached') + }) +}) diff --git a/src/main/persistence/loading-store/ssh-lease-durable-mutation.ts b/src/main/persistence/loading-store/ssh-lease-durable-mutation.ts new file mode 100644 index 000000000000..28f361a12cd3 --- /dev/null +++ b/src/main/persistence/loading-store/ssh-lease-durable-mutation.ts @@ -0,0 +1,42 @@ +import type { DurableProfileStateMutation, StoreRuntimeState } from './store-runtime-state' +import { + flushDurableStateOrThrowAsync, + type WriteFlushBarrierOperations +} from './write-flush-barriers' + +export type SshLeaseDurableMutationRuntime = Pick< + StoreRuntimeState, + | 'dirtyProfileStateDomains' + | 'profileMaintenancePending' + | 'profileStateAuthority' + | 'quitFlushStarted' + | 'runDurableMutation' + | 'writesFrozen' +> + +export async function runSshLeaseDurableMutation( + runtime: SshLeaseDurableMutationRuntime, + barriers: WriteFlushBarrierOperations, + domain: 'sshPtyConsumerRecoveries' | 'sshRemotePtyLeases', + mutate: () => DurableProfileStateMutation +): Promise { + const writeMutation = (): DurableProfileStateMutation => { + const mutation = mutate() + if (mutation.persist !== false) { + runtime.dirtyProfileStateDomains?.add(domain) + } + return mutation + } + if (runtime.profileStateAuthority?.asynchronous) { + return runtime.runDurableMutation(writeMutation) + } + if (runtime.writesFrozen || runtime.quitFlushStarted || runtime.profileMaintenancePending) { + throw new Error('Cannot mutate finalized profile persistence') + } + // Legacy SSH recovery keeps its existing asynchronous disk barrier on older runtimes. + const mutation = writeMutation() + if (mutation.persist !== false) { + await flushDurableStateOrThrowAsync(barriers) + } + return mutation.value +} diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts index 1c1da638cc96..7188b4efb664 100644 --- a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -43,13 +43,14 @@ import type { StoreRuntimeState } from './store-runtime-state' import type { WriteFlushBarrierOperations } from './write-flush-barriers' import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' import type { WriteSchedulingOperations } from './write-scheduling' -import { flushDurableStateOrThrowAsync } from './write-flush-barriers' import { scheduleSave } from './write-scheduling' +import { + runSshLeaseDurableMutation, + type SshLeaseDurableMutationRuntime +} from './ssh-lease-durable-mutation' -type SshLeaseRecoveryOperationsRuntime = Pick< - StoreRuntimeState, - 'dirtyProfileStateDomains' | 'protectedSecrets' | 'state' -> +type SshLeaseRecoveryOperationsRuntime = SshLeaseDurableMutationRuntime & + Pick const sshLeaseRecoveryOperationsContext = Symbol('SshLeaseRecoveryOperations') type SshLeaseRecoveryOperationsContext = { @@ -84,8 +85,15 @@ export class SshLeaseRecoveryOperations { await upsertSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), record) } - async removeSshPtyConsumerRecovery(targetId: string): Promise { - await removeSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), targetId) + async removeSshPtyConsumerRecovery( + targetId: string, + expectedClientInstanceId?: string + ): Promise { + await removeSshPtyConsumerRecoveryOperation( + getSshPtyConsumerRecoveryOperations(this), + targetId, + expectedClientInstanceId + ) } getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { @@ -201,12 +209,13 @@ export function getSshPtyConsumerRecoveryOperations( return { state: owner[sshLeaseRecoveryOperationsContext].runtime.state, protectedSecrets: owner[sshLeaseRecoveryOperationsContext].runtime.protectedSecrets, - flushDurableStateOrThrowAsync: () => { - owner[sshLeaseRecoveryOperationsContext].runtime.dirtyProfileStateDomains?.add( - 'sshPtyConsumerRecoveries' + runDurableMutation: (mutate) => + runSshLeaseDurableMutation( + owner[sshLeaseRecoveryOperationsContext].runtime, + owner[sshLeaseRecoveryOperationsContext].flushBarriers, + 'sshPtyConsumerRecoveries', + mutate ) - return flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) - } } } @@ -254,12 +263,13 @@ export function getSshPtyLeaseOperations(owner: SshLeaseRecoveryOperations): Ssh ) owner[sshLeaseRecoveryOperationsContext].flushBarriers.flush() }, - flushDurableStateOrThrowAsync: () => { - owner[sshLeaseRecoveryOperationsContext].runtime.dirtyProfileStateDomains?.add( - 'sshRemotePtyLeases' + runDurableMutation: (mutate) => + runSshLeaseDurableMutation( + owner[sshLeaseRecoveryOperationsContext].runtime, + owner[sshLeaseRecoveryOperationsContext].flushBarriers, + 'sshRemotePtyLeases', + mutate ) - return flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) - } } } diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index 881bc2d07240..aa6e9efa5d1d 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -19,13 +19,19 @@ import type { AutomationListProjectionCache, AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' -import type { ProfileStateAuthority } from './profile-state-authority' +import type { ProfileStatePersistenceAuthority } from './profile-state-authority' import type { AutomationRun } from '../../../shared/automations-types' +export type DurableProfileStateMutation = { + value: T + persist?: boolean + rollback?: () => void +} + export type StoreRuntimeOptions = { dataFile?: string storageAuthority?: AutomationStorageAuthority - profileStateAuthority?: ProfileStateAuthority + profileStateAuthority?: ProfileStatePersistenceAuthority } /** Mutable coordination state shared only with this Store's private collaborators. */ @@ -33,7 +39,7 @@ export class StoreRuntimeState { state!: PersistedState readonly dataFile: string readonly storageAuthority: AutomationStorageAuthority - readonly profileStateAuthority: ProfileStateAuthority | undefined + readonly profileStateAuthority: ProfileStatePersistenceAuthority | undefined automationListProjectionCache: AutomationListProjectionCache | null = null activeViewPreference!: ActiveViewPreference readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage @@ -45,6 +51,9 @@ export class StoreRuntimeState { inFlightAsyncTmpFile: string | null = null backupRotationInFlight = false writesFrozen = false + profileMaintenancePending = false + pendingProfileMaintenance: Promise | null = null + readonly pendingProfileFlushes = new Set>() quitFlushStarted = false quitFlushPromise: Promise | null = null lastWrittenStateHash: string | null = null @@ -61,6 +70,7 @@ export class StoreRuntimeState { readonly protectedSecrets = new ProtectedSecretPersistence() loadNeedsSave = false flushOrThrow!: () => void + runDurableMutation!: (mutate: () => DurableProfileStateMutation) => Promise settingsChangeListeners = new Set< ( updates: Partial, diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index f20dade9da37..4dac86f5845e 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { mkdirSync } from 'node:fs' import { dirname } from 'node:path' import { setMigrationUnsupportedPty, @@ -17,13 +17,10 @@ import { } from './store-domain-composition' import type { PersistedState } from '../../../shared/persisted-state-types' import { scheduleSave } from './write-scheduling' -import { - durableWriteTempPath, - renameDurableSync, - writeFileDurableSync -} from '../../durable-file-write' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' import type { WriteSchedulingOperations } from './write-scheduling' import type { PrimaryStateWriteOperations } from './primary-state-writes' +import { enqueuePrimaryStateOperation, writeToDiskAsync } from './primary-state-writes' import type { ProjectCollectionOperations } from './project-collection-operations' import type { RepoLifecycleOperations } from './repo-lifecycle-operations' import type { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence' @@ -39,14 +36,25 @@ import type { RetiredWorktreeNamePersistence } from './retired-worktree-name-per import type { SshLeaseRecoveryOperations } from './ssh-lease-recovery-operations' import type { WriteFlushBarrierOperations } from './write-flush-barriers' import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' -import { profileStateJsonExportPath } from '../profile-state/profile-state-export-path' -import type { ProfileStateAuthorityInitialState } from './profile-state-authority' +import { writeVersionedProfileStateExport } from '../profile-state/profile-state-versioned-export' +import { + beginProfileStateMaintenance, + freezeProfileStateWrites, + freezeProfileStateWritesAsync, + type ProfileStateMaintenanceOptions +} from './profile-state-maintenance' +import type { + AsyncProfileStateAuthority, + ProfileStateAuthorityInitialState, + ProfileStatePersistenceAuthority, + ProfileStateMaintenance +} from './profile-state-authority' export type StoreOptions = StoreRuntimeOptions & { /** Storage-form JSON supplied by a read-only profile migration/import boundary. */ serializedState?: string /** Reuse the authority's validated startup read without retaining a cached copy. */ - initialAuthorityState?: ProfileStateAuthorityInitialState + initialAuthorityState?: ProfileStateAuthorityInitialState } export type PreparedProfileStateExport = { @@ -85,6 +93,7 @@ export class Store { this.domains = createStoreDomains(this.runtime) installStoreDomainContexts(this, this.domains) this.runtime.flushOrThrow = () => this.flushOrThrow() + this.runtime.runDurableMutation = (mutate) => this.runDurableMutation(mutate) let loaded: PersistedState if (options.profileStateAuthority !== undefined) { if (initial !== undefined) { @@ -174,6 +183,9 @@ export class Store { this.runtime.dirtyProfileStateDomains = null this.flushOrThrow() const authority = this.runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile exports require an awaited export') + } if (authority?.writeJsonExport) { return authority.writeJsonExport(targetPath) } @@ -188,47 +200,25 @@ export class Store { /** Publish the latest SQLite revision as a durable, versioned rollback export. */ writeLatestProfileStateJsonExport(): number | undefined { const authority = this.runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile exports require an awaited export') + } if (!authority?.writeJsonExport) { return undefined } this.runtime.dirtyProfileStateDomains = null this.flushOrThrow() - const stagingPath = `${this.runtime.dataFile}.sqlite-export.pending.${process.pid}.${Date.now()}.tmp` - let published = false - try { - const revision = authority.writeJsonExport(stagingPath) - if (revision === 0) { - rmSync(stagingPath, { force: true }) - published = true - return undefined - } - const targetPath = profileStateJsonExportPath(this.runtime.dataFile, revision) - mkdirSync(dirname(targetPath), { recursive: true }) - if (existsSync(targetPath)) { - const staged = readFileSync(stagingPath) - const existing = readFileSync(targetPath) - if (!staged.equals(existing)) { - throw new Error( - `Profile state export revision ${revision} already exists with different content` - ) - } - rmSync(stagingPath, { force: true }) - } else { - renameDurableSync(stagingPath, targetPath) - } - published = true - return revision - } finally { - if (!published) { - rmSync(stagingPath, { force: true }) - } - } + const writeExport = authority.writeJsonExport.bind(authority) + return writeVersionedProfileStateExport(this.runtime.dataFile, writeExport) } /** Publish canonical JSON for a pre-update older-build compatibility window. */ writeLatestProfileStateJsonCompatibilityExport(): number | undefined { const authority = this.runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile exports require an awaited export') + } if (!authority?.writeJsonCompatibilityExport) { return undefined } @@ -237,6 +227,47 @@ export class Store { return authority.writeJsonCompatibilityExport(this.runtime.dataFile) } + writeLatestProfileStateJsonExportAsync(): Promise { + if (!this.runtime.profileStateAuthority?.asynchronous) { + return Promise.resolve(this.writeLatestProfileStateJsonExport()) + } + return this.enqueueProfileExport((authority) => + authority.writeLatestJsonExport(this.runtime.dataFile) + ) + } + + writeLatestProfileStateJsonCompatibilityExportAsync(): Promise { + if (!this.runtime.profileStateAuthority?.asynchronous) { + return Promise.resolve(this.writeLatestProfileStateJsonCompatibilityExport()) + } + return this.enqueueProfileExport((authority) => + authority.writeJsonCompatibilityExport(this.runtime.dataFile) + ) + } + + private enqueueProfileExport( + exportState: (authority: AsyncProfileStateAuthority) => Promise + ): Promise { + if ( + this.runtime.writesFrozen || + this.runtime.quitFlushStarted || + this.runtime.profileMaintenancePending + ) { + return Promise.reject(new Error('Cannot export finalized profile persistence')) + } + const authority = this.runtime.profileStateAuthority + if (!authority?.asynchronous) { + return Promise.resolve(undefined) + } + return enqueuePrimaryStateOperation(this.domains.writes, async () => { + this.runtime.dirtyProfileStateDomains = null + if (!(await writeToDiskAsync(this.domains.writes))) { + throw new Error('Profile state changed while preparing its export') + } + return exportState(authority) + }) + } + /** Freeze writes, then preserve the SQLite family for an explicit recovery decision. */ quarantineProfileStateDatabase( quarantineRoot?: string, @@ -244,6 +275,9 @@ export class Store { ): ProfileStateDatabaseQuarantine { this.freezeWrites() const authority = this.runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile quarantine requires an awaited close') + } if (!authority?.quarantineDatabase) { throw new Error('SQLite profile-state quarantine is unavailable') } @@ -251,12 +285,29 @@ export class Store { } freezeWrites(): void { - this.runtime.writesFrozen = true - if (this.runtime.writeTimer) { - clearTimeout(this.runtime.writeTimer) - this.runtime.writeTimer = null + freezeProfileStateWrites(this.runtime) + } + + beginProfileMaintenance( + options?: ProfileStateMaintenanceOptions + ): Promise { + return beginProfileStateMaintenance(this.runtime, this.domains, options) + } + + freezeWritesAsync(): Promise { + return freezeProfileStateWritesAsync(this.runtime) + } + + async quarantineProfileStateDatabaseAsync( + quarantineRoot?: string, + reason?: string + ): Promise { + await this.beginProfileMaintenance({ flush: false }) + const authority = this.runtime.profileStateAuthority + if (!authority?.quarantineDatabase) { + throw new Error('SQLite profile-state quarantine is unavailable') } - this.runtime.profileStateAuthority?.close?.() + return authority.quarantineDatabase(quarantineRoot, reason) } } diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts index de4bb8dd5537..a05d7f70cad6 100644 --- a/src/main/persistence/loading-store/write-flush-barriers.ts +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -6,6 +6,7 @@ import { getGithubCacheFile } from './user-data-path' import type { StoreRuntimeState } from './store-runtime-state' import type { PrimaryStateWriteOperations } from './primary-state-writes' import { enqueueWrite } from './primary-state-writes' +import { drainProfileStateOperations, runProfileStateFlush } from './profile-state-flush-lifetime' type WriteFlushBarrierOperationsRuntime = Pick< StoreRuntimeState, @@ -17,6 +18,9 @@ type WriteFlushBarrierOperationsRuntime = Pick< | 'githubCacheGeneration' | 'lastDurableWriteGeneration' | 'pendingGithubCacheWrite' + | 'pendingProfileFlushes' + | 'pendingProfileMaintenance' + | 'profileMaintenancePending' | 'profileStateAuthority' | 'quitFlushPromise' | 'quitFlushStarted' @@ -31,6 +35,7 @@ const writeFlushBarrierOperationsContext = Symbol('WriteFlushBarrierOperations') type WriteFlushBarrierOperationsContext = { runtime: WriteFlushBarrierOperationsRuntime writes: PrimaryStateWriteOperations + bestEffortFinalFlush?: Promise } export class WriteFlushBarrierOperations { @@ -42,7 +47,17 @@ export class WriteFlushBarrierOperations { flush(): void { this[writeFlushBarrierOperationsContext].runtime.automationListProjectionCache = null - if (this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted) { + if ( + this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted || + this[writeFlushBarrierOperationsContext].runtime.profileMaintenancePending + ) { + return + } + if (this[writeFlushBarrierOperationsContext].runtime.profileStateAuthority?.asynchronous) { + this[writeFlushBarrierOperationsContext].runtime.writeGeneration++ + void flushCurrentStateAsync(this, false, undefined, false).catch((error) => + console.error('[persistence] Failed to flush state:', error) + ) return } try { @@ -59,22 +74,46 @@ export class WriteFlushBarrierOperations { } flushAsync(options: { exportJsonCompatibility?: boolean } = {}): Promise { + const context = this[writeFlushBarrierOperationsContext] + context.bestEffortFinalFlush ??= this.flushFinalOrThrowAsync(options).catch((error) => + console.error('[persistence] Failed to flush final state:', error) + ) + return context.bestEffortFinalFlush + } + + flushFinalOrThrowAsync(options: { exportJsonCompatibility?: boolean } = {}): Promise { const { runtime } = this[writeFlushBarrierOperationsContext] if (runtime.quitFlushPromise) { return runtime.quitFlushPromise } runtime.quitFlushStarted = true - runtime.quitFlushPromise = flushCurrentStateAsync(this, true) + const maintenance = runtime.profileMaintenancePending + runtime.quitFlushPromise = ( + maintenance + ? Promise.resolve(runtime.pendingProfileMaintenance) + : drainProfileStateOperations(runtime.pendingProfileFlushes).then(() => + flushCurrentStateAsync(this, true) + ) + ) .then(async () => { - if (options.exportJsonCompatibility) { + if (options.exportJsonCompatibility && !maintenance) { await runtime.profileStateAuthority?.writeJsonCompatibilityExportAsync?.(runtime.dataFile) } }) - .catch((error) => console.error('[persistence] Failed to flush final state:', error)) + .finally(async () => { + if (runtime.profileStateAuthority?.asynchronous) { + runtime.writesFrozen = true + await runtime.profileStateAuthority.close() + } + }) return runtime.quitFlushPromise } flushPendingAsync(): Promise { + const { runtime } = this[writeFlushBarrierOperationsContext] + if (runtime.writesFrozen || runtime.quitFlushStarted || runtime.profileMaintenancePending) { + return Promise.resolve() + } // Best-effort callers must not livelock while the live app keeps mutating state. return flushCurrentStateAsync(this, false, undefined, false).catch(() => {}) } @@ -84,6 +123,7 @@ export class WriteFlushBarrierOperations { ): Promise { if ( this[writeFlushBarrierOperationsContext].runtime.writesFrozen || + this[writeFlushBarrierOperationsContext].runtime.profileMaintenancePending || this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted ) { return Promise.reject(new Error('Cannot flush while persistence is finalized')) @@ -101,24 +141,24 @@ export class WriteFlushBarrierOperations { export async function flushDurableStateOrThrowAsync( owner: WriteFlushBarrierOperations ): Promise { - if ( - owner[writeFlushBarrierOperationsContext].runtime.writesFrozen || - owner[writeFlushBarrierOperationsContext].runtime.quitFlushStarted - ) { + const { runtime, writes } = owner[writeFlushBarrierOperationsContext] + if (runtime.writesFrozen || runtime.profileMaintenancePending || runtime.quitFlushStarted) { throw new Error('Cannot flush while persistence is finalized') } - for (;;) { - if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { - clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) - owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null - } - owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null - const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration - await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) - if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { - break + return runProfileStateFlush(runtime, async () => { + for (;;) { + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.firstPendingSaveAt = null + const generation = runtime.writeGeneration + await enqueueWrite(writes) + if (generation === runtime.writeGeneration) { + break + } } - } + }) } export async function flushCurrentStateAsync( @@ -126,55 +166,55 @@ export async function flushCurrentStateAsync( final: boolean, signal?: AbortSignal, drainToStableGeneration = true, - requireInitialGenerationDurable = false + requireInitialGenerationDurable = false, + fullCheckpoint = final ): Promise { - const requiredDurableGeneration = requireInitialGenerationDurable - ? owner[writeFlushBarrierOperationsContext].runtime.writeGeneration - : null - for (;;) { - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { - clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) - owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null - } - owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null - const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration - try { - await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes, { - fullCheckpoint: final - }) - } finally { - await (final - ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() - : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( - signal - )) - await writeGithubCacheSnapshotAsync(owner, final, signal) - if (final || drainToStableGeneration) { - await owner[ - writeFlushBarrierOperationsContext - ].runtime.profileStateAuthority?.drainBackups?.() + const { runtime, writes } = owner[writeFlushBarrierOperationsContext] + return runProfileStateFlush(runtime, async () => { + const requiredDurableGeneration = requireInitialGenerationDurable + ? runtime.writeGeneration + : null + for (;;) { + if (signal?.aborted) { + throw new Error('Persistence flush aborted') } - } - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (!drainToStableGeneration) { - if ( - requiredDurableGeneration === null || - owner[writeFlushBarrierOperationsContext].runtime.lastDurableWriteGeneration >= - requiredDurableGeneration - ) { + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.firstPendingSaveAt = null + const generation = runtime.writeGeneration + try { + await enqueueWrite(writes, { + fullCheckpoint, + signal + }) + } finally { + await (final + ? runtime.activeViewPreference.flushAsync() + : runtime.activeViewPreference.flushPendingAsync(signal)) + await writeGithubCacheSnapshotAsync(owner, final, signal) + if (final || drainToStableGeneration) { + await runtime.profileStateAuthority?.drainBackups?.() + } + } + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (!drainToStableGeneration) { + if ( + requiredDurableGeneration === null || + runtime.lastDurableWriteGeneration >= requiredDurableGeneration + ) { + break + } + continue + } + if (generation === runtime.writeGeneration) { break } - continue - } - if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { - break } - } + }) } export async function writeGithubCacheSnapshotAsync( @@ -182,39 +222,28 @@ export async function writeGithubCacheSnapshotAsync( drainToStableGeneration = true, signal?: AbortSignal ): Promise { - if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + const { runtime } = owner[writeFlushBarrierOperationsContext] + if (!runtime.githubCacheDirty) { return } - const previousWrite = - owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite ?? - owner[writeFlushBarrierOperationsContext].runtime.staleGithubCacheTempCleanup + const previousWrite = runtime.pendingGithubCacheWrite ?? runtime.staleGithubCacheTempCleanup const nextWrite = previousWrite .then(async () => { - while (owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + while (runtime.githubCacheDirty) { if (signal?.aborted) { throw new Error('GitHub cache flush aborted') } - const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration - const cacheFile = getGithubCacheFile( - owner[writeFlushBarrierOperationsContext].runtime.dataFile - ) + const generation = runtime.githubCacheGeneration + const cacheFile = getGithubCacheFile(runtime.dataFile) const tmpFile = durableWriteTempPath(cacheFile) let renamed = false try { - await writeFile( - tmpFile, - JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), - 'utf-8' - ) - if ( - generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration - ) { + await writeFile(tmpFile, JSON.stringify(runtime.state.githubCache), 'utf-8') + if (generation === runtime.githubCacheGeneration) { await rename(tmpFile, cacheFile) renamed = true - if ( - generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration - ) { - owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + if (generation === runtime.githubCacheGeneration) { + runtime.githubCacheDirty = false } } } finally { @@ -234,34 +263,31 @@ export async function writeGithubCacheSnapshotAsync( console.warn('[persistence] Failed to write github cache snapshot:', err) }) .finally(() => { - if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite === nextWrite) { - owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = null + if (runtime.pendingGithubCacheWrite === nextWrite) { + runtime.pendingGithubCacheWrite = null } }) - owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = nextWrite + runtime.pendingGithubCacheWrite = nextWrite await nextWrite } export function writeGithubCacheSnapshotSync(owner: WriteFlushBarrierOperations): void { - if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + const { runtime } = owner[writeFlushBarrierOperationsContext] + if (!runtime.githubCacheDirty) { return } - if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite) { + if (runtime.pendingGithubCacheWrite) { void writeGithubCacheSnapshotAsync(owner) return } - const cacheFile = getGithubCacheFile(owner[writeFlushBarrierOperationsContext].runtime.dataFile) - const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + const cacheFile = getGithubCacheFile(runtime.dataFile) + const generation = runtime.githubCacheGeneration const tmpFile = durableWriteTempPath(cacheFile) try { - writeFileSync( - tmpFile, - JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), - 'utf-8' - ) + writeFileSync(tmpFile, JSON.stringify(runtime.state.githubCache), 'utf-8') renameSync(tmpFile, cacheFile) - if (generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration) { - owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + if (generation === runtime.githubCacheGeneration) { + runtime.githubCacheDirty = false } } catch (err) { try { diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts index 987edfb97f99..888c93b9eb15 100644 --- a/src/main/persistence/loading-store/write-scheduling.ts +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -12,6 +12,7 @@ type WriteSchedulingOperationsRuntime = Pick< | 'dirtyProfileStateDomains' | 'firstPendingSaveAt' | 'pendingWrite' + | 'profileMaintenancePending' | 'quitFlushStarted' | 'writeGeneration' | 'writeTimer' @@ -58,6 +59,9 @@ export function scheduleSave( return } owner[writeSchedulingOperationsContext].runtime.writeGeneration += 1 + if (owner[writeSchedulingOperationsContext].runtime.profileMaintenancePending) { + return + } const now = Date.now() owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt ??= now if (owner[writeSchedulingOperationsContext].runtime.writeTimer) { @@ -71,7 +75,7 @@ export function scheduleSave( owner[writeSchedulingOperationsContext].runtime.writeTimer = setTimeout(() => { owner[writeSchedulingOperationsContext].runtime.writeTimer = null owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt = null - void enqueueWrite(owner[writeSchedulingOperationsContext].writes) + void enqueueWrite(owner[writeSchedulingOperationsContext].writes).catch(() => {}) }, delay) } diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts index 453c4c6ac3bc..bd4b4811dd99 100644 --- a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts @@ -2,10 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { randomUUID } from 'node:crypto' import { dirname } from 'node:path' import { publishFileDurableSync } from '../../durable-file-write' -import type { - ProfileStateAuthority, - ProfileStateAuthorityInitialState -} from '../loading-store/profile-state-authority' +import type { ProfileStateAuthorityInitialState } from '../loading-store/profile-state-authority' import { Store } from '../loading-store/store' import { isProfileStateSqliteAvailable, openProfileStateDatabase } from './profile-state-database' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' @@ -25,7 +22,10 @@ export type ProfileStateAuthorityBootstrapResult = { classification: ProfileStateStorageClassification migrated: boolean } & ( - | { authority: ProfileStateAuthority; initialState: ProfileStateAuthorityInitialState } + | { + authority: ProfileStateSqliteAuthority + initialState: ProfileStateAuthorityInitialState + } | { authority: undefined; initialState?: never } ) diff --git a/src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts b/src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts new file mode 100644 index 000000000000..b53dc25fd69e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' + +describe('profile export snapshot revision', () => { + it.each(['json', 'compatibility-sync', 'compatibility-async'] as const)( + 'refuses a competing revision before publishing %s', + async (kind) => { + const root = mkdtempSync(join(tmpdir(), 'orca-export-fence-')) + const database = join(root, 'profile-state.db') + const target = join(root, 'retained.json') + const owner = new ProfileStateSqliteAuthority(database, 'export-fence') + const peer = new ProfileStateSqliteAuthority(database, 'export-fence') + try { + owner.writeSerializedState(Buffer.from('{"settings":{"theme":"dark"}}')) + writeFileSync(target, 'retained export') + owner.assertCurrentRevision() + peer.readSerializedState() + peer.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"light"}' }]) + await expect( + Promise.resolve().then(() => { + if (kind === 'json') { + return owner.writeJsonExport(target) + } + if (kind === 'compatibility-sync') { + return owner.writeJsonCompatibilityExport(target) + } + return owner.writeJsonCompatibilityExportAsync(target) + }) + ).rejects.toBeInstanceOf(ProfileStateRevisionConflictError) + expect(readFileSync(target, 'utf8')).toBe('retained export') + expect(JSON.parse(peer.readSerializedState() ?? '{}').settings.theme).toBe('light') + } finally { + owner.close() + peer.close() + rmSync(root, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-authority-exports.ts b/src/main/persistence/profile-state/profile-state-authority-exports.ts new file mode 100644 index 000000000000..5b40486da691 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-exports.ts @@ -0,0 +1,75 @@ +import { existsSync, mkdirSync, readFileSync } from 'node:fs' +import { mkdir, readFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { + durableWriteTempPath, + writeFileDurable, + writeFileDurableSync +} from '../../durable-file-write' +import { + readProfileStateSnapshot, + stageProfileStateJsonCompatibility, + acceptProfileStateJsonCompatibility +} from './profile-state-documents' +import type Database from '../../sqlite/sync-database' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' + +function readExportSnapshot(db: Database.Database, expectedRevision?: number) { + const snapshot = readProfileStateSnapshot(db) + if (expectedRevision !== undefined && snapshot.revision !== expectedRevision) { + throw new ProfileStateRevisionConflictError(expectedRevision, snapshot.revision) + } + return snapshot +} + +/** Publish a durable JSON rollback/compatibility export without changing authority. */ +export function writeProfileStateAuthorityJsonExport( + db: Database.Database, + targetPath: string, + expectedRevision?: number +): number { + const snapshot = readExportSnapshot(db, expectedRevision) + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, snapshot.json) + return snapshot.revision +} + +/** Stage both accepted versions before replacing canonical JSON for an older build. */ +export function writeProfileStateAuthorityCompatibilityExport( + db: Database.Database, + targetPath: string, + expectedRevision?: number +): number | undefined { + const snapshot = readExportSnapshot(db, expectedRevision) + if (snapshot.revision === 0) { + return undefined + } + const retained = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : undefined + stageProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision, retained) + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, snapshot.json) + acceptProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision) + return snapshot.revision +} + +export async function writeProfileStateAuthorityCompatibilityExportAsync( + db: Database.Database, + targetPath: string, + expectedRevision?: number +): Promise { + const snapshot = readExportSnapshot(db, expectedRevision) + if (snapshot.revision === 0) { + return undefined + } + const retained = await readFile(targetPath, 'utf8').catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + }) + stageProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision, retained) + await mkdir(dirname(targetPath), { recursive: true }) + await writeFileDurable(durableWriteTempPath(targetPath), targetPath, snapshot.json) + acceptProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision) + return snapshot.revision +} diff --git a/src/main/persistence/profile-state/profile-state-backup-rotation.ts b/src/main/persistence/profile-state/profile-state-backup-rotation.ts index dcd3196ac655..e5b5eb3c8b2c 100644 --- a/src/main/persistence/profile-state/profile-state-backup-rotation.ts +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.ts @@ -20,7 +20,8 @@ export class ProfileStateBackupRotation { constructor( private readonly databasePath: string, private readonly profileId: string, - private readonly now: () => number = Date.now + private readonly now: () => number = Date.now, + private readonly runBackup = runProfileStateBackup ) {} schedule(): void { @@ -77,7 +78,7 @@ export class ProfileStateBackupRotation { ) this.copying = true try { - await runProfileStateBackup({ + await this.runBackup({ databasePath: this.databasePath, profileId: this.profileId, targetPath: target diff --git a/src/main/persistence/profile-state/profile-state-complete-replacements.ts b/src/main/persistence/profile-state/profile-state-complete-replacements.ts new file mode 100644 index 000000000000..2f7ea20f5376 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-complete-replacements.ts @@ -0,0 +1,36 @@ +import type { ProfileStateDomainReplacement } from '../loading-store/profile-state-authority' +import type { openProfileStateDatabase } from './profile-state-database' + +export function buildCompleteDocumentReplacements( + db: ReturnType['db'], + replacements: readonly ProfileStateDomainReplacement[] +): ProfileStateDomainReplacement[] { + const domains = new Set(replacements.map(({ domain }) => domain)) + const incoming = new Set(domains) + for (const row of db + .prepare(`SELECT domain FROM profile_state_documents + UNION SELECT domain FROM profile_state_automation_runs_meta WHERE presence <> 'document'`) + .all()) { + if (isDomainRow(row)) { + domains.add(row.domain) + } + } + + return [ + ...replacements, + ...[...domains] + .filter((domain) => !incoming.has(domain)) + .map((domain) => ({ domain, payload: null })) + ] +} + +function isDomainRow(value: unknown): value is { domain: string } { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + 'domain' in value && + typeof value.domain === 'string' && + value.domain.length > 0 + ) +} diff --git a/src/main/persistence/profile-state/profile-state-database.ts b/src/main/persistence/profile-state/profile-state-database.ts index a05df51d7a49..2bb165f64abf 100644 --- a/src/main/persistence/profile-state/profile-state-database.ts +++ b/src/main/persistence/profile-state/profile-state-database.ts @@ -7,7 +7,8 @@ import { PROFILE_STATE_DATABASE_SCHEMA_VERSION, PROFILE_STATE_META_PROFILE_ID } from './profile-state-database-schema' -import { existsSync } from 'node:fs' +import { existsSync, mkdirSync } from 'node:fs' +import { dirname } from 'node:path' import { PROFILE_STATE_DATABASE_FILE_NAME, profileStateDatabaseFile @@ -59,6 +60,19 @@ export type OpenProfileStateDatabase = { export { PROFILE_STATE_DATABASE_FILE_NAME, profileStateDatabaseFile } +export function openWritableProfileStateDatabase( + databasePath: string, + profileId: string +): OpenProfileStateDatabase { + mkdirSync(dirname(databasePath), { recursive: true }) + const opened = openProfileStateDatabase(databasePath, profileId) + if (opened.readOnly) { + opened.db.close() + throw new Error('Cannot write a future profile state schema') + } + return opened +} + /** * Open the database belonging to one profile. * diff --git a/src/main/persistence/profile-state/profile-state-domain-writes.ts b/src/main/persistence/profile-state/profile-state-domain-writes.ts index 646f4ddbfb94..792487ef3b11 100644 --- a/src/main/persistence/profile-state/profile-state-domain-writes.ts +++ b/src/main/persistence/profile-state/profile-state-domain-writes.ts @@ -15,7 +15,6 @@ import { prepareProfileStateAutomationRunsDelta, prepareProfileStateAutomationRunsReplacement } from './profile-state-automation-runs' -import type { AutomationRun } from '../../../shared/automations-types' import { isRecord, validateProfileStateDocumentRow } from './profile-state-document-validation' import { assertProfileStateDocumentRevision } from './profile-state-revision' import { @@ -51,7 +50,7 @@ export type ProfileStateDomainTransaction = { expectedRevision: number replacements: readonly ProfileStateDomainMutation[] /** Changed run projection supplied by Store for selective row updates. */ - automationRunsAfter?: readonly AutomationRun[] + automationRunsAfter?: readonly unknown[] } export type ProfileStateDomainWriteResult = { diff --git a/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts new file mode 100644 index 000000000000..9b13a608a5cd --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts @@ -0,0 +1,152 @@ +import { build } from 'esbuild' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import type { Store } from '../loading-store/store' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { createLiveProfileStateStore } from './profile-state-live-store-factory' +import { profileStateJsonExportPath } from './profile-state-export-path' +import { profileStateDatabaseBackups } from './profile-state-backup-path' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +let bundleRoot: string +let workerOptions: { workerPath: string; backupWorkerPath: string } +const roots: string[] = [] +const stores: Store[] = [] + +beforeAll(async () => { + bundleRoot = mkdtempSync(join(tmpdir(), 'orca-live-writer-bundle-')) + workerOptions = { + workerPath: join(bundleRoot, 'profile-state-writer-worker-entry.js'), + backupWorkerPath: join(bundleRoot, 'profile-state-backup-worker-entry.js') + } + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts'), + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outdir: bundleRoot, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterEach(async () => { + await Promise.all(stores.splice(0).map((store) => store.freezeWritesAsync())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) +afterAll(() => rmSync(bundleRoot, { recursive: true, force: true })) + +function options() { + const root = mkdtempSync(join(tmpdir(), 'orca-live-profile-')) + roots.push(root) + return { + dataFile: join(root, 'orca-data.json'), + databaseFile: join(root, 'profile-state.db'), + profileId: 'live-profile-test', + authorityMode: 'sqlite-candidate' as const + } +} + +async function open(input: ReturnType) { + const result = await createLiveProfileStateStore(input, workerOptions) + stores.push(result.store) + return result +} + +function readState(input: ReturnType) { + const reader = new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + try { + return JSON.parse(reader.readSerializedState() ?? '{}') + } finally { + reader.close() + } +} + +describe('live profile authority admission', () => { + it('migrates once, loads admitted state and reopens worker-acknowledged writes', async () => { + const input = options() + writeFileSync( + input.dataFile, + JSON.stringify(buildProfileStateCutoverFixture(join(input.dataFile, '..'))) + ) + const { store, migrated, backend } = await open(input) + expect({ migrated, backend }).toEqual({ migrated: true, backend: 'sqlite' }) + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() + const reopened = await open(input) + expect(reopened.migrated).toBe(false) + expect(reopened.store.getSettings().theme).toBe('dark') + expect(readState(input).automationRuns).toHaveLength(1) + }) + + it('never adopts a competing revision between bootstrap and worker readiness', async () => { + const input = options() + const original = ProfileStateSqliteAuthority.prototype.retireForWorker + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'retireForWorker').mockImplementation( + function (this: ProfileStateSqliteAuthority) { + const handoff = original.call(this) + const peer = new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + try { + peer.readSerializedState() + peer.writeSerializedDomains([{ domain: 'peer', payload: '{"retained":true}' }]) + } finally { + peer.close() + } + return handoff + } + ) + await expect(open(input)).rejects.toThrow('Profile state revision changed') + expect(readState(input).peer).toEqual({ retained: true }) + }) + + it('refuses startup when the worker is unavailable without selecting JSON', async () => { + const input = options() + await expect( + createLiveProfileStateStore(input, { workerPath: join(bundleRoot, 'missing.js') }) + ).rejects.toThrow('Profile state writer') + expect(() => readFileSync(input.dataFile)).toThrow() + const reopened = await open(input) + expect(reopened.backend).toBe('sqlite') + }) + + it('orders exports with full checkpoints and closes backup and writer handles on final flush', async () => { + const input = options() + const { store } = await open(input) + store.getWorkspaceSession().activeTabId = 'getter-export' + store.updateSettings({ theme: 'dark' }) + const revision = await store.writeLatestProfileStateJsonExportAsync() + expect(revision).toBeTypeOf('number') + if (revision === undefined) { + throw new Error('Expected a persisted profile revision') + } + expect( + JSON.parse(readFileSync(profileStateJsonExportPath(input.dataFile, revision), 'utf8')) + .workspaceSession.activeTabId + ).toBe('getter-export') + store.updateSettings({ theme: 'light' }) + await store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + expect(JSON.parse(readFileSync(input.dataFile, 'utf8')).settings.theme).toBe('light') + expect(profileStateDatabaseBackups(input.databaseFile).length).toBeGreaterThan(0) + expect(readState(input).settings.theme).toBe('light') + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + const reopened = await open(input) + expect(reopened.store.getSettings().theme).toBe('light') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-live-store-factory.ts b/src/main/persistence/profile-state/profile-state-live-store-factory.ts new file mode 100644 index 000000000000..7fb27a8523e7 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.ts @@ -0,0 +1,51 @@ +import { Store } from '../loading-store/store' +import { + prepareProfileStateStore, + type ProfileStateStoreFactoryOptions, + type ProfileStateStoreFactoryResult +} from './profile-state-store-factory' +import { ProfileStateWorkerAuthority } from './profile-state-worker-authority' + +/** Publish live state only after its exact bootstrap revision has a worker owner. */ +export async function createLiveProfileStateStore( + options: ProfileStateStoreFactoryOptions, + workerOptions: { workerPath?: string; backupWorkerPath?: string } = {} +): Promise { + const { initialState: initial, ...prepared } = prepareProfileStateStore(options) + if (!initial) { + return { + ...prepared, + store: new Store({ dataFile: options.dataFile, storageAuthority: options.storageAuthority }) + } + } + + // Consume before retirement: the bootstrap snapshot carries the original revision fence. + const parsed = initial.takeParsedState?.() + const serializedState = initial.serializedState + const authority = new ProfileStateWorkerAuthority( + initial.authority.retireForWorker(), + workerOptions + ) + try { + await authority.ready + const initialAuthorityState = initial.takeParsedState + ? { authority, takeParsedState: () => parsed } + : { authority, serializedState } + return { + ...prepared, + store: new Store({ + dataFile: options.dataFile, + storageAuthority: options.storageAuthority, + profileStateAuthority: authority, + initialAuthorityState + }) + } + } catch (error) { + try { + await authority.close() + } catch (closeError) { + console.error('[persistence] Failed to close a refused profile writer:', closeError) + } + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-migration.ts b/src/main/persistence/profile-state/profile-state-migration.ts index 172927c260a0..de1535027e2b 100644 --- a/src/main/persistence/profile-state/profile-state-migration.ts +++ b/src/main/persistence/profile-state/profile-state-migration.ts @@ -25,7 +25,7 @@ type ProfileStateMigrationOptions = { /** Publish an imported database only after its complete source has committed durably. */ export function migrateProfileStateToSqlite(options: ProfileStateMigrationOptions): { authority: ProfileStateSqliteAuthority - initialState: ProfileStateAuthorityInitialState + initialState: ProfileStateAuthorityInitialState } { assertMigrationSourceUnchanged(options) assertNoRetainedProfileStateExports(options) diff --git a/src/main/persistence/profile-state/profile-state-revision-readmission.ts b/src/main/persistence/profile-state/profile-state-revision-readmission.ts new file mode 100644 index 000000000000..8a3c3df338de --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-revision-readmission.ts @@ -0,0 +1,20 @@ +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { readProfileStateRevision } from './profile-state-documents' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' + +/** Reopening a live snapshot cannot adopt another writer's intervening revision. */ +export function assertProfileStateRevisionOnDisk( + databasePath: string, + profileId: string, + revision: number +): void { + const admitted = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + const actual = readProfileStateRevision(admitted.db) + if (actual !== revision) { + throw new ProfileStateRevisionConflictError(revision, actual) + } + } finally { + admitted.db.close() + } +} diff --git a/src/main/persistence/profile-state/profile-state-sqlite-authority.ts b/src/main/persistence/profile-state/profile-state-sqlite-authority.ts index d6f2473c28fc..158357924d23 100644 --- a/src/main/persistence/profile-state/profile-state-sqlite-authority.ts +++ b/src/main/persistence/profile-state/profile-state-sqlite-authority.ts @@ -1,27 +1,19 @@ -import { existsSync, mkdirSync, readFileSync } from 'node:fs' -import { mkdir, readFile } from 'node:fs/promises' -import { dirname } from 'node:path' -import { - durableWriteTempPath, - writeFileDurable, - writeFileDurableSync -} from '../../durable-file-write' +import { existsSync } from 'node:fs' import type { ProfileStateAuthority, ProfileStateAuthorityInitialState, - ProfileStateDomainReplacement + ProfileStateDomainReplacement, + ProfileStateMaintenance } from '../loading-store/profile-state-authority' import { - acceptProfileStateJsonCompatibility, importProfileStateJson, readAcceptedProfileStateParsedSnapshot, readProfileStateParsedSnapshot, readProfileStateRevision, - readProfileStateSnapshot, - stageProfileStateJsonCompatibility + readProfileStateSnapshot } from './profile-state-documents' import { - openProfileStateDatabase, + openWritableProfileStateDatabase, openProfileStateDatabaseReadOnly } from './profile-state-database' import { writeProfileStateDomains } from './profile-state-domain-writes' @@ -33,12 +25,19 @@ import { parseProfileStateRoot, ProfileStateRevisionConflictError } from './profile-state-document-validation' -import type { AutomationRun } from '../../../shared/automations-types' +import { assertProfileStateRevisionOnDisk } from './profile-state-revision-readmission' import { quarantineProfileStateDatabase, type ProfileStateDatabaseQuarantine } from './profile-state-database-quarantine' +import { + writeProfileStateAuthorityJsonExport, + writeProfileStateAuthorityCompatibilityExport, + writeProfileStateAuthorityCompatibilityExportAsync +} from './profile-state-authority-exports' +import { buildCompleteDocumentReplacements } from './profile-state-complete-replacements' import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import type { ProfileStateWriterInitialization } from './profile-state-writer-protocol' /** * Complete-document authority for the Store cutover. @@ -50,8 +49,9 @@ import { ProfileStateBackupRotation } from './profile-state-backup-rotation' * not rebuilt; Store callers can still opt into narrower dirty-domain writes. */ export class ProfileStateSqliteAuthority implements ProfileStateAuthority { + private retired = false private observedRevision: number | undefined - private writableDatabase: ReturnType | undefined + private writableDatabase: ReturnType | undefined private backupRotation: ProfileStateBackupRotation | undefined constructor( @@ -59,8 +59,44 @@ export class ProfileStateSqliteAuthority implements ProfileStateAuthority { private readonly profileId: string ) {} + retireForWorker(): ProfileStateWriterInitialization { + this.assertActive() + if (this.observedRevision === undefined || this.backupRotation !== undefined) { + throw new Error('Profile state worker handoff requires an admitted bootstrap authority') + } + const initialization = { + databasePath: this.databasePath, + profileId: this.profileId, + revision: this.observedRevision + } + this.close() + this.retired = true + return initialization + } + + initializeFromRevision(revision: number): void { + this.assertActive() + if (this.observedRevision !== undefined || !Number.isSafeInteger(revision) || revision < 0) { + throw new Error('Invalid profile state worker revision handoff') + } + assertProfileStateRevisionOnDisk(this.databasePath, this.profileId, revision) + this.observedRevision = revision + this.assertCurrentRevision() + } + + get revision(): number { + this.assertActive() + if (this.observedRevision === undefined) { + throw new Error('Profile state authority has no admitted revision') + } + return this.observedRevision + } + /** Keep the startup payload and write fence on the same accepted revision. */ - readAcceptedState(rawJson: string): ProfileStateAuthorityInitialState | undefined { + readAcceptedState( + rawJson: string + ): ProfileStateAuthorityInitialState | undefined { + this.assertActive() const opened = openProfileStateDatabaseReadOnly(this.databasePath, this.profileId) try { const snapshot = readAcceptedProfileStateParsedSnapshot(opened.db, rawJson) @@ -73,7 +109,8 @@ export class ProfileStateSqliteAuthority implements ProfileStateAuthority { } } - readInitialState(): ProfileStateAuthorityInitialState { + readInitialState(): ProfileStateAuthorityInitialState { + this.assertActive() if (!this.writableDatabase && !existsSync(this.databasePath)) { return this.createInitialState(0, undefined) } @@ -93,6 +130,7 @@ export class ProfileStateSqliteAuthority implements ProfileStateAuthority { } readSerializedState(): string | undefined { + this.assertActive() if (!this.writableDatabase && !existsSync(this.databasePath)) { // Treat an absent database as the empty revision so a concurrent creator // cannot race this authority's first commit. @@ -112,18 +150,22 @@ export class ProfileStateSqliteAuthority implements ProfileStateAuthority { } } - writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): void { + writeSerializedDomains( + replacements: readonly ProfileStateDomainReplacement[], + automationRunsAfter?: readonly unknown[] + ): void { + this.assertActive() if (this.observedRevision === undefined) { // Store normally reads before its first write. Establishing the revision // here keeps direct authority callers fenced too. this.readSerializedState() } const opened = this.openWritableDatabase() - const result = writeProfileStateDomains(opened.db, { + this.observedRevision = writeProfileStateDomains(opened.db, { expectedRevision: this.observedRevision ?? 0, - replacements - }) - this.observedRevision = result.revision + replacements, + automationRunsAfter + }).revision } assertCurrentRevision(): void { @@ -135,21 +177,13 @@ export class ProfileStateSqliteAuthority implements ProfileStateAuthority { writeSerializedAutomationRuns( replacements: readonly ProfileStateDomainReplacement[], - runs: readonly AutomationRun[] + runs: readonly unknown[] ): void { - if (this.observedRevision === undefined) { - this.readSerializedState() - } - const opened = this.openWritableDatabase() - const result = writeProfileStateDomains(opened.db, { - expectedRevision: this.observedRevision ?? 0, - replacements, - automationRunsAfter: runs - }) - this.observedRevision = result.revision + this.writeSerializedDomains(replacements, runs) } writeSerializedState(payload: Buffer): void { + this.assertActive() const serialized = payload.toString('utf8') if (!Buffer.from(serialized, 'utf8').equals(payload)) { throw new Error('Profile state payload is not valid UTF-8') @@ -167,6 +201,7 @@ export class ProfileStateSqliteAuthority implements ProfileStateAuthority { } writeCompleteSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): void { + this.assertActive() if (this.observedRevision === undefined) { this.readSerializedState() } @@ -193,81 +228,83 @@ export class ProfileStateSqliteAuthority implements ProfileStateAuthority { }) return } - const result = writeProfileStateDomains(opened.db, { + this.observedRevision = writeProfileStateDomains(opened.db, { expectedRevision: this.observedRevision ?? currentRevision, replacements: complete - }) - this.observedRevision = result.revision + }).revision } scheduleBackup(): void { + this.assertActive() this.backupRotation ??= new ProfileStateBackupRotation(this.databasePath, this.profileId) this.backupRotation.schedule() } async drainBackups(): Promise { + this.assertActive() await this.backupRotation?.drain() } - /** Publish a durable JSON rollback/compatibility export without changing authority. */ writeJsonExport(targetPath: string): number { - const opened = this.openWritableDatabase() - const snapshot = readProfileStateSnapshot(opened.db) - mkdirSync(dirname(targetPath), { recursive: true }) - writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, snapshot.json) - return snapshot.revision + return writeProfileStateAuthorityJsonExport( + this.openWritableDatabase().db, + targetPath, + this.observedRevision + ) } - /** Stage both accepted versions before replacing canonical JSON for an older build. */ writeJsonCompatibilityExport(targetPath: string): number | undefined { - const opened = this.openWritableDatabase() - const snapshot = readProfileStateSnapshot(opened.db) - if (snapshot.revision === 0) { - return undefined - } - const retained = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : undefined - stageProfileStateJsonCompatibility(opened.db, snapshot.json, snapshot.revision, retained) - mkdirSync(dirname(targetPath), { recursive: true }) - writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, snapshot.json) - acceptProfileStateJsonCompatibility(opened.db, snapshot.json, snapshot.revision) - return snapshot.revision + return writeProfileStateAuthorityCompatibilityExport( + this.openWritableDatabase().db, + targetPath, + this.observedRevision + ) } - async writeJsonCompatibilityExportAsync(targetPath: string): Promise { - const opened = this.openWritableDatabase() - const snapshot = readProfileStateSnapshot(opened.db) - if (snapshot.revision === 0) { - return undefined - } - const retained = await readFile(targetPath, 'utf8').catch((error: unknown) => { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return undefined - } - throw error - }) - stageProfileStateJsonCompatibility(opened.db, snapshot.json, snapshot.revision, retained) - await mkdir(dirname(targetPath), { recursive: true }) - await writeFileDurable(durableWriteTempPath(targetPath), targetPath, snapshot.json) - acceptProfileStateJsonCompatibility(opened.db, snapshot.json, snapshot.revision) - return snapshot.revision + writeJsonCompatibilityExportAsync(targetPath: string): Promise { + return writeProfileStateAuthorityCompatibilityExportAsync( + this.openWritableDatabase().db, + targetPath, + this.observedRevision + ) } quarantineDatabase(quarantineRoot?: string, reason?: string): ProfileStateDatabaseQuarantine { + this.assertActive() this.backupRotation?.assertIdle() this.close() return quarantineProfileStateDatabase(this.databasePath, this.profileId, quarantineRoot, reason) } close(): void { + this.assertActive() this.backupRotation?.stop() this.writableDatabase?.db.close() this.writableDatabase = undefined } + async pauseForMaintenance(): Promise { + const revision = this.revision + await this.drainBackups() + this.close() + let consumed = false + return { + resume: async () => { + if (consumed) { + throw new Error('Profile maintenance resume has already been consumed') + } + consumed = true + assertProfileStateRevisionOnDisk(this.databasePath, this.profileId, revision) + this.assertCurrentRevision() + this.backupRotation = undefined + } + } + } + private createInitialState( revision: number, value: Record | undefined - ): ProfileStateAuthorityInitialState { + ): ProfileStateAuthorityInitialState { let pending: { revision: number; value: Record | undefined } | undefined = { revision, value @@ -276,6 +313,7 @@ export class ProfileStateSqliteAuthority implements ProfileStateAuthority { return { authority: this, takeParsedState: () => { + this.assertActive() const snapshot = pending if (snapshot === undefined) { throw new Error('Profile state startup snapshot has already been consumed') @@ -287,51 +325,15 @@ export class ProfileStateSqliteAuthority implements ProfileStateAuthority { } } - private openWritableDatabase(): NonNullable { - if (this.writableDatabase) { - return this.writableDatabase - } - mkdirSync(dirname(this.databasePath), { recursive: true }) - const opened = openProfileStateDatabase(this.databasePath, this.profileId) - if (opened.readOnly) { - opened.db.close() - throw new Error('Cannot write a future profile state schema') + private assertActive(): void { + if (this.retired) { + throw new Error('Profile state authority was retired for worker ownership') } - this.writableDatabase = opened - return opened } -} -function buildCompleteDocumentReplacements( - db: ReturnType['db'], - replacements: readonly ProfileStateDomainReplacement[] -): ProfileStateDomainReplacement[] { - const domains = new Set(replacements.map(({ domain }) => domain)) - const incoming = new Set(domains) - for (const row of db - .prepare(`SELECT domain FROM profile_state_documents - UNION SELECT domain FROM profile_state_automation_runs_meta WHERE presence <> 'document'`) - .all()) { - if (isDomainRow(row)) { - domains.add(row.domain) - } + private openWritableDatabase(): NonNullable { + this.assertActive() + this.writableDatabase ??= openWritableProfileStateDatabase(this.databasePath, this.profileId) + return this.writableDatabase } - - return [ - ...replacements, - ...[...domains] - .filter((domain) => !incoming.has(domain)) - .map((domain) => ({ domain, payload: null })) - ] -} - -function isDomainRow(value: unknown): value is { domain: string } { - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - 'domain' in value && - typeof value.domain === 'string' && - value.domain.length > 0 - ) } diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.test.ts b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts index db5c33c6e46d..bce6bf6aa989 100644 --- a/src/main/persistence/profile-state/profile-state-startup-authority.test.ts +++ b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts @@ -1,7 +1,9 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { build } from 'esbuild' +import type * as WorkerEntryPath from '../../worker-thread-entry-path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' import { createProfileStateStoreForStartup, @@ -12,6 +14,37 @@ import { } from './profile-state-startup-authority' import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +const bundle = vi.hoisted(() => ({ directory: '' })) +vi.mock('../../worker-thread-entry-path', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + resolveWorkerThreadEntryPath: ( + layout: Parameters[0], + name: string + ) => + name.startsWith('profile-state-') + ? join(bundle.directory, name) + : original.resolveWorkerThreadEntryPath(layout, name) + } +}) + +beforeAll(async () => { + bundle.directory = mkdtempSync(join(tmpdir(), 'orca-startup-writers-')) + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts'), + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outdir: bundle.directory, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) +afterAll(() => rmSync(bundle.directory, { recursive: true, force: true })) + vi.mock('electron', () => ({ app: { getPath: () => tmpdir(), @@ -55,7 +88,7 @@ describe('profile-state startup authority boundary', () => { expect(orcadProfileStateAuthorityMode(false)).toBe('legacy') }) - it('imports legacy desktop state by default and reopens acknowledged SQLite state', () => { + it('imports legacy desktop state by default and reopens acknowledged SQLite state', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-startup-authority-')) temporaryDirectories.push(directory) const dataFile = join(directory, 'orca-data.json') @@ -68,16 +101,16 @@ describe('profile-state startup authority boundary', () => { profileId: 'startup-authority-test', storageAuthority: 'desktop' } - const legacy = createProfileStateStoreForStartup({ + const legacy = await createProfileStateStoreForStartup({ ...base, runtime: 'desktop', authorityMode: 'legacy' }) expect(legacy.backend).toBe('json') expect(existsSync(databaseFile)).toBe(false) - legacy.store.freezeWrites() + await legacy.store.freezeWritesAsync() - const candidate = createProfileStateStoreForStartup({ + const candidate = await createProfileStateStoreForStartup({ ...base, runtime: 'desktop', authorityMode: desktopProfileStateAuthorityMode() @@ -85,11 +118,11 @@ describe('profile-state startup authority boundary', () => { expect(candidate.backend).toBe('sqlite') expect(candidate.migrated).toBe(true) candidate.store.updateSettings({ theme: 'dark' }) - candidate.store.flushOrThrow() - candidate.store.freezeWrites() + await candidate.store.flushPendingOrThrowAsync() + await candidate.store.freezeWritesAsync() rmSync(dataFile) - const restarted = createProfileStateStoreForStartup({ + const restarted = await createProfileStateStoreForStartup({ ...base, runtime: 'desktop', authorityMode: desktopProfileStateAuthorityMode() @@ -97,9 +130,9 @@ describe('profile-state startup authority boundary', () => { expect(restarted.backend).toBe('sqlite') expect(restarted.classification).toBe('sqlite-only') expect(restarted.store.getSettings().theme).toBe('dark') - restarted.store.freezeWrites() + await restarted.store.freezeWritesAsync() - const packaged = createProfileStateStoreForStartup({ + const packaged = await createProfileStateStoreForStartup({ ...base, runtime: 'desktop', authorityMode: desktopProfileStateAuthorityMode() @@ -107,36 +140,36 @@ describe('profile-state startup authority boundary', () => { expect(packaged.backend).toBe('sqlite') expect(packaged.classification).toBe('sqlite-only') expect(packaged.store.getSettings().theme).toBe('dark') - packaged.store.freezeWrites() + await packaged.store.freezeWritesAsync() - expect(() => + await expect( createProfileStateStoreForStartup({ ...base, runtime: 'desktop', authorityMode: 'legacy' }) - ).toThrowError(expect.objectContaining({ code: 'profile-state-authority-required' })) + ).rejects.toThrowError(expect.objectContaining({ code: 'profile-state-authority-required' })) - const orcad = createProfileStateStoreForStartup({ + const orcad = await createProfileStateStoreForStartup({ ...base, runtime: 'orcad', authorityMode: 'sqlite-candidate', storageAuthority: 'runtime' }) expect(orcad.backend).toBe('sqlite') - orcad.store.freezeWrites() + await orcad.store.freezeWritesAsync() - expect(() => + await expect( createProfileStateStoreForStartup({ ...base, runtime: 'orcad', authorityMode: 'legacy', storageAuthority: 'runtime' }) - ).toThrowError(expect.objectContaining({ code: 'profile-state-authority-required' })) + ).rejects.toThrowError(expect.objectContaining({ code: 'profile-state-authority-required' })) }) - it('rejects an orcad candidate request on a Node 18-style host', () => { + it('rejects an orcad candidate request on a Node 18-style host', async () => { const original = process.getBuiltinModule vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { if (id === 'node:sqlite') { @@ -145,7 +178,7 @@ describe('profile-state startup authority boundary', () => { return original(id) }) - expect(() => + await expect( createProfileStateStoreForStartup({ dataFile: join(tmpdir(), 'missing-orca-data.json'), databaseFile: join(tmpdir(), 'missing-profile-state.db'), @@ -154,10 +187,10 @@ describe('profile-state startup authority boundary', () => { authorityMode: 'sqlite-candidate', storageAuthority: 'runtime' }) - ).toThrowError(ProfileStateStartupAuthorityError) + ).rejects.toThrowError(ProfileStateStartupAuthorityError) }) - it('creates an empty desktop profile directly in SQLite and preserves its first acknowledged write', () => { + it('creates an empty desktop profile directly in SQLite and preserves its first acknowledged write', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-default-empty-profile-')) temporaryDirectories.push(directory) const options: ProfileStateStartupAuthorityOptions = { @@ -168,30 +201,30 @@ describe('profile-state startup authority boundary', () => { authorityMode: desktopProfileStateAuthorityMode(), storageAuthority: 'desktop' } - const first = createProfileStateStoreForStartup(options) + const first = await createProfileStateStoreForStartup(options) try { expect(first.backend).toBe('sqlite') expect(first.classification).toBe('neither') first.store.updateSettings({ terminalFontSize: 19 }) - first.store.flushOrThrow() + await first.store.flushPendingOrThrowAsync() expect(existsSync(options.databaseFile)).toBe(true) expect(existsSync(options.dataFile)).toBe(false) } finally { - first.store.freezeWrites() + await first.store.freezeWritesAsync() } - const reopened = createProfileStateStoreForStartup(options) + const reopened = await createProfileStateStoreForStartup(options) try { expect(reopened.backend).toBe('sqlite') expect(reopened.migrated).toBe(false) expect(reopened.store.getSettings().terminalFontSize).toBe(19) } finally { - reopened.store.freezeWrites() + await reopened.store.freezeWritesAsync() } }) it.each(['corrupt', 'future-schema', 'ambiguous'] as const)( 'refuses %s storage under the desktop default without replacing the authority', - (kind) => { + async (kind) => { const directory = mkdtempSync(join(tmpdir(), 'orca-default-invalid-profile-')) temporaryDirectories.push(directory) const options: ProfileStateStartupAuthorityOptions = { @@ -217,7 +250,7 @@ describe('profile-state startup authority boundary', () => { } } const before = readFileSync(options.databaseFile) - expect(() => createProfileStateStoreForStartup(options)).toThrow() + await expect(createProfileStateStoreForStartup(options)).rejects.toThrow() expect(readFileSync(options.databaseFile)).toEqual(before) if (kind === 'ambiguous') { expect(readFileSync(options.dataFile, 'utf8')).toBe('{"settings":{"theme":"dark"}}') @@ -225,14 +258,14 @@ describe('profile-state startup authority boundary', () => { } ) - it('migrates a JSON-only orcad profile when the runtime exposes SQLite', () => { + it('migrates a JSON-only orcad profile when the runtime exposes SQLite', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-orcad-capable-')) temporaryDirectories.push(directory) const dataFile = join(directory, 'orca-data.json') const databaseFile = profileStateDatabaseFile(directory) writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'dark' } })) - const result = createProfileStateStoreForStartup({ + const result = await createProfileStateStoreForStartup({ dataFile, databaseFile, profileId: 'orcad-capable-test', @@ -244,16 +277,16 @@ describe('profile-state startup authority boundary', () => { expect(result.backend).toBe('sqlite') expect(result.migrated).toBe(true) expect(result.store.getSettings().theme).toBe('dark') - result.store.freezeWrites() + await result.store.freezeWritesAsync() }) - it('keeps a runtime with SQLite but no native backup on JSON authority', () => { + it('keeps a runtime with SQLite but no native backup on JSON authority', async () => { const original = process.getBuiltinModule vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { return id === 'node:sqlite' ? { DatabaseSync: class {} } : original(id) }) expect(orcadProfileStateAuthorityMode()).toBe('legacy') - expect(() => + await expect( createProfileStateStoreForStartup({ dataFile: join(tmpdir(), 'missing-backup-orca-data.json'), databaseFile: join(tmpdir(), 'missing-backup-profile-state.db'), @@ -262,17 +295,17 @@ describe('profile-state startup authority boundary', () => { authorityMode: 'sqlite-candidate', storageAuthority: 'runtime' }) - ).toThrowError(ProfileStateStartupAuthorityError) + ).rejects.toThrowError(ProfileStateStartupAuthorityError) }) - it('keeps a JSON-only orcad profile on JSON when the runtime lacks SQLite', () => { + it('keeps a JSON-only orcad profile on JSON when the runtime lacks SQLite', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-orcad-node18-')) temporaryDirectories.push(directory) const dataFile = join(directory, 'orca-data.json') const databaseFile = profileStateDatabaseFile(directory) writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'dark' } })) - const result = createProfileStateStoreForStartup({ + const result = await createProfileStateStoreForStartup({ dataFile, databaseFile, profileId: 'orcad-node18-test', @@ -284,6 +317,6 @@ describe('profile-state startup authority boundary', () => { expect(result.backend).toBe('json') expect(result.migrated).toBe(false) expect(result.store.getSettings().theme).toBe('dark') - result.store.freezeWrites() + await result.store.freezeWritesAsync() }) }) diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.ts b/src/main/persistence/profile-state/profile-state-startup-authority.ts index b8abfcc5fae5..1da0d8117955 100644 --- a/src/main/persistence/profile-state/profile-state-startup-authority.ts +++ b/src/main/persistence/profile-state/profile-state-startup-authority.ts @@ -1,11 +1,11 @@ import type { AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' import { isProfileStateSqliteAvailable } from './profile-state-database' -import { - createProfileStateStore, - type ProfileStateStoreAuthorityMode, - type ProfileStateStoreFactoryOptions, - type ProfileStateStoreFactoryResult +import type { + ProfileStateStoreAuthorityMode, + ProfileStateStoreFactoryOptions, + ProfileStateStoreFactoryResult } from './profile-state-store-factory' +import { createLiveProfileStateStore } from './profile-state-live-store-factory' /** Runtime roots sharing the profile-state selection boundary. */ export type ProfileStateStartupRuntime = 'desktop' | 'orcad' @@ -48,9 +48,9 @@ export function orcadProfileStateAuthorityMode( } /** Construct both runtimes through the same validated authority boundary. */ -export function createProfileStateStoreForStartup( +export async function createProfileStateStoreForStartup( options: ProfileStateStartupAuthorityOptions -): ProfileStateStoreFactoryResult { +): Promise { if ( options.runtime === 'orcad' && options.authorityMode === 'sqlite-candidate' && @@ -58,5 +58,5 @@ export function createProfileStateStoreForStartup( ) { throw new ProfileStateStartupAuthorityError() } - return createProfileStateStore(options) + return createLiveProfileStateStore(options) } diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.test.ts b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts index cc0f73d6b573..8e7adec98841 100644 --- a/src/main/persistence/profile-state/profile-state-startup-failure.test.ts +++ b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts @@ -7,6 +7,8 @@ import { formatProfileStateStartupFailure, profileStateStartupFailureClass } from './profile-state-startup-failure' +import { ProfileStateWriterError } from './profile-state-writer-errors' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' describe('profile-state startup failure formatting', () => { it('prints recovery paths and the offline rollback command', () => { @@ -57,4 +59,23 @@ describe('profile-state startup failure formatting', () => { expect(formatProfileStateStartupFailure(new Error('unrelated startup failure'))).toBeUndefined() expect(profileStateStartupFailureClass(new Error('unrelated startup failure'))).toBeUndefined() }) + + it('reports a missing writer without exposing its cause or suggesting database rollback', () => { + const error = new ProfileStateWriterError( + 'profile-state-writer-unavailable', + 'Profile state writer could not start', + 'known-failure', + { cause: new Error('private runtime path') } + ) + expect(profileStateStartupFailureClass(error)).toBe('writer-unavailable') + expect(formatProfileStateStartupFailure(error)).toBe( + 'Orca could not start profile persistence. Restart Orca; if the problem continues, repair or reinstall this build.' + ) + }) + + it('reports an admission race as a conflicting writer', () => { + const error = new ProfileStateRevisionConflictError(2, 3) + expect(profileStateStartupFailureClass(error)).toBe('revision-conflict') + expect(formatProfileStateStartupFailure(error)).toContain('Close other Orca processes') + }) }) diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.ts b/src/main/persistence/profile-state/profile-state-startup-failure.ts index d0d5dcaabff2..47805310bad2 100644 --- a/src/main/persistence/profile-state/profile-state-startup-failure.ts +++ b/src/main/persistence/profile-state/profile-state-startup-failure.ts @@ -11,7 +11,11 @@ type ProfileStateAuthorityFailure = { message: string } -export type ProfileStateStartupFailureClass = 'recovery-required' | 'ambiguous-authority' +export type ProfileStateStartupFailureClass = + | 'recovery-required' + | 'ambiguous-authority' + | 'revision-conflict' + | 'writer-unavailable' /** Return the bounded failure class used by startup breadcrumbs and support diagnostics. */ export function profileStateStartupFailureClass( @@ -23,6 +27,14 @@ export function profileStateStartupFailureClass( if (isProfileStateAuthorityFailure(error)) { return 'ambiguous-authority' } + if (isRecord(error) && typeof error.code === 'string') { + if (error.code === 'profile-state-revision-conflict') { + return 'revision-conflict' + } + if (error.code.startsWith('profile-state-writer-')) { + return 'writer-unavailable' + } + } return undefined } @@ -54,6 +66,14 @@ export function formatProfileStateStartupFailure(error: unknown): string | undef return `Orca cannot safely choose a profile-state authority: ${error.message}` } + const failureClass = profileStateStartupFailureClass(error) + if (failureClass === 'revision-conflict') { + return 'The active profile changed while Orca was starting. Close other Orca processes using this profile, then restart Orca.' + } + if (failureClass === 'writer-unavailable') { + return 'Orca could not start profile persistence. Restart Orca; if the problem continues, repair or reinstall this build.' + } + return undefined } diff --git a/src/main/persistence/profile-state/profile-state-store-factory.ts b/src/main/persistence/profile-state/profile-state-store-factory.ts index adfcbf7055cc..7c8a41c9adc6 100644 --- a/src/main/persistence/profile-state/profile-state-store-factory.ts +++ b/src/main/persistence/profile-state/profile-state-store-factory.ts @@ -1,5 +1,6 @@ import type { AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' import type { ProfileStateAuthorityInitialState } from '../loading-store/profile-state-authority' +import type { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' import { Store } from '../loading-store/store' import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' import { @@ -43,6 +44,21 @@ export type ProfileStateStoreFactoryResult = { export function createProfileStateStore( options: ProfileStateStoreFactoryOptions ): ProfileStateStoreFactoryResult { + const { initialState, ...prepared } = prepareProfileStateStore(options) + return { + ...prepared, + store: initialState ? createSqliteStore(options, initialState) : createLegacyStore(options) + } +} + +type PreparedProfileStateStore = Omit & { + initialState?: ProfileStateAuthorityInitialState +} + +/** Admission is shared by live worker startup and synchronous offline operations. */ +export function prepareProfileStateStore( + options: ProfileStateStoreFactoryOptions +): PreparedProfileStateStore { const authorityMode = options.authorityMode ?? 'legacy' const classification = classifyProfileStateStorage(options.dataFile, options.databaseFile) if (classification === 'json-only' || classification === 'neither') { @@ -55,7 +71,6 @@ export function createProfileStateStore( ) } return { - store: createLegacyStore(options), backend: 'json', classification, migrated: false @@ -67,7 +82,6 @@ export function createProfileStateStore( (classification === 'neither' || classification === 'json-only') ) { return { - store: createLegacyStore(options), backend: 'json', classification, migrated: false @@ -81,7 +95,6 @@ export function createProfileStateStore( const authority = bootstrap.authority if (authority === undefined) { return { - store: createLegacyStore(options), backend: 'json', classification: bootstrap.classification, migrated: bootstrap.migrated @@ -89,7 +102,7 @@ export function createProfileStateStore( } return { - store: createSqliteStore(options, bootstrap.initialState), + initialState: bootstrap.initialState, backend: 'sqlite', classification: bootstrap.classification, migrated: bootstrap.migrated diff --git a/src/main/persistence/profile-state/profile-state-versioned-export.ts b/src/main/persistence/profile-state/profile-state-versioned-export.ts new file mode 100644 index 000000000000..827aa350652f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-versioned-export.ts @@ -0,0 +1,41 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import { renameDurableSync } from '../../durable-file-write' +import { profileStateJsonExportPath } from './profile-state-export-path' + +/** An existing revision must never be replaced with different content. */ +export function writeVersionedProfileStateExport( + dataFile: string, + writeExport: (targetPath: string) => number +): number | undefined { + const stagingPath = `${dataFile}.sqlite-export.pending.${process.pid}.${Date.now()}.tmp` + let published = false + try { + const revision = writeExport(stagingPath) + if (revision === 0) { + rmSync(stagingPath, { force: true }) + published = true + return undefined + } + const targetPath = profileStateJsonExportPath(dataFile, revision) + mkdirSync(dirname(targetPath), { recursive: true }) + if (existsSync(targetPath)) { + const staged = readFileSync(stagingPath) + const existing = readFileSync(targetPath) + if (!staged.equals(existing)) { + throw new Error( + `Profile state export revision ${revision} already exists with different content` + ) + } + rmSync(stagingPath, { force: true }) + } else { + renameDurableSync(stagingPath, targetPath) + } + published = true + return revision + } finally { + if (!published) { + rmSync(stagingPath, { force: true }) + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-worker-authority.ts b/src/main/persistence/profile-state/profile-state-worker-authority.ts new file mode 100644 index 000000000000..e5ccc60b8065 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-worker-authority.ts @@ -0,0 +1,158 @@ +import type { AutomationRun } from '../../../shared/automations-types' +import type { + AsyncProfileStateAuthority, + ProfileStateDomainReplacement, + ProfileStateMaintenance +} from '../loading-store/profile-state-authority' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { runProfileStateBackupWorker } from './profile-state-backup-worker' +import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' +import { + ProfileStateWriteWorkerClient, + type ProfileStateWriterInitialization +} from './profile-state-writer-worker-client' + +/** Main owns backup scheduling; the persistent worker owns every live SQL command. */ +export class ProfileStateWorkerAuthority implements AsyncProfileStateAuthority { + readonly asynchronous = true + private writer: ProfileStateWriteWorkerClient + private backups: ProfileStateBackupRotation + private closing: Promise | undefined + + constructor( + private readonly initialization: ProfileStateWriterInitialization, + private readonly options: { workerPath?: string; backupWorkerPath?: string } = {} + ) { + this.writer = new ProfileStateWriteWorkerClient(initialization, options) + this.backups = this.createBackups() + } + + get ready(): Promise { + return this.writer.ready + } + + readSerializedState(): never { + throw new Error('Live profile state requires its admitted startup snapshot') + } + + assertWritable(): void { + this.writer.assertWritable() + } + + abort(): Promise { + return this.writer.abort() + } + + assertCurrentRevision(): Promise { + return this.writer.assertCurrentRevision().then(() => {}) + } + + writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): Promise { + return this.writer.writeSerializedDomains(replacements).then(() => {}) + } + + writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ): Promise { + return this.writer.writeSerializedAutomationRuns(replacements, runs).then(() => {}) + } + + writeCompleteSerializedDomains( + replacements: readonly ProfileStateDomainReplacement[] + ): Promise { + return this.writer.writeCompleteSerializedDomains(replacements).then(() => {}) + } + + writeSerializedState(payload: Buffer): Promise { + return this.writer.writeSerializedState(payload).then(() => {}) + } + + writeJsonExport(targetPath: string): Promise { + return this.writer.writeJsonExport(targetPath) + } + + writeLatestJsonExport(dataFile: string): Promise { + return this.writer.writeLatestJsonExport(dataFile) + } + + writeJsonCompatibilityExport(targetPath: string): Promise { + return this.writer.writeJsonCompatibilityExportAsync(targetPath) + } + + writeJsonCompatibilityExportAsync(targetPath: string): Promise { + return this.writeJsonCompatibilityExport(targetPath) + } + + scheduleBackup(): void { + if (!this.closing) { + this.backups.schedule() + } + } + + drainBackups(): Promise { + return this.backups.drain() + } + + close(): Promise { + this.closing ??= this.finishClose() + return this.closing + } + + async pauseForMaintenance(): Promise { + this.assertWritable() + const writer = this.writer + await this.close() + const revision = writer.acknowledgedRevision + let consumed = false + return { + resume: async () => { + if (consumed || this.writer !== writer) { + throw new Error('Profile maintenance resume has already been consumed') + } + consumed = true + this.writer = new ProfileStateWriteWorkerClient( + { ...this.initialization, revision }, + this.options + ) + this.backups = this.createBackups() + this.closing = undefined + try { + await this.writer.ready + this.assertWritable() + } catch (error) { + await this.close() + throw error + } + } + } + } + + async quarantineDatabase(quarantineRoot?: string, reason?: string) { + await this.close() + return quarantineProfileStateDatabase( + this.initialization.databasePath, + this.initialization.profileId, + quarantineRoot, + reason + ) + } + + private async finishClose(): Promise { + try { + await this.backups.drain() + } finally { + this.backups.stop() + await this.writer.close() + } + } + + private createBackups(): ProfileStateBackupRotation { + return new ProfileStateBackupRotation( + this.initialization.databasePath, + this.initialization.profileId, + Date.now, + (job) => runProfileStateBackupWorker(job, { workerPath: this.options.backupWorkerPath }) + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-write-transaction.test.ts b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts index 7e23ecf75b61..97b515a8dc0b 100644 --- a/src/main/persistence/profile-state/profile-state-write-transaction.test.ts +++ b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import Database from '../../sqlite/sync-database' -import { withProfileStateWriteTransaction } from './profile-state-write-transaction' +import { + ProfileStateIndeterminateWriteError, + withProfileStateWriteTransaction +} from './profile-state-write-transaction' describe('profile state write transaction ownership', () => { it('rolls back a failed deferred commit and leaves the connection usable', () => { @@ -41,4 +44,39 @@ describe('profile state write transaction ownership', () => { db.close() } }) + it.each([false, true])( + 'reports failed rollback as indeterminate after commit=%s', + (commitFirst) => { + const db = new Database(':memory:') + db.exec('CREATE TABLE writes (id INTEGER)') + const exec = db.exec.bind(db) + const injected = vi.spyOn(db, 'exec').mockImplementation((sql) => { + if (sql === 'ROLLBACK') { + throw new Error('injected rollback failure') + } + if (sql === 'COMMIT') { + if (commitFirst) { + exec(sql) + } + throw new Error('injected commit failure') + } + exec(sql) + }) + try { + expect(() => + withProfileStateWriteTransaction(db, () => db.exec('INSERT INTO writes VALUES (1)')) + ).toThrow(ProfileStateIndeterminateWriteError) + expect(db.isTransaction).toBe(!commitFirst) + if (db.isTransaction) { + exec('ROLLBACK') + } + expect(db.prepare('SELECT COUNT(*) AS count FROM writes').get()).toMatchObject({ + count: commitFirst ? 1 : 0 + }) + } finally { + injected.mockRestore() + db.close() + } + } + ) }) diff --git a/src/main/persistence/profile-state/profile-state-write-transaction.ts b/src/main/persistence/profile-state/profile-state-write-transaction.ts index 8ed21ab36aaa..314d75ff5c81 100644 --- a/src/main/persistence/profile-state/profile-state-write-transaction.ts +++ b/src/main/persistence/profile-state/profile-state-write-transaction.ts @@ -1,5 +1,17 @@ import type Database from '../../sqlite/sync-database' +export class ProfileStateIndeterminateWriteError extends Error { + readonly code = 'profile-state-write-indeterminate' as const + + constructor( + cause: unknown, + readonly rollbackError: unknown + ) { + super('Profile state write failed without a confirmed rollback', { cause }) + this.name = 'ProfileStateIndeterminateWriteError' + } +} + /** Own the write transaction; joining a caller's transaction would weaken its revision fence. */ export function withProfileStateWriteTransaction(db: Database.Database, write: () => T): T { if (db.isTransaction) { @@ -13,8 +25,8 @@ export function withProfileStateWriteTransaction(db: Database.Database, write } catch (error) { try { db.exec('ROLLBACK') - } catch { - // Preserve the write failure if rollback is unavailable. + } catch (rollbackError) { + throw new ProfileStateIndeterminateWriteError(error, rollbackError) } throw error } diff --git a/src/main/persistence/profile-state/profile-state-writer-connection.ts b/src/main/persistence/profile-state/profile-state-writer-connection.ts new file mode 100644 index 000000000000..9a8c1ab4bca3 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-connection.ts @@ -0,0 +1,287 @@ +import { Worker } from 'node:worker_threads' +import { resolveProfileStateWriterWorkerPath } from './profile-state-writer-worker-path' +import { + createProfileStateWriterRequest, + isExpectedProfileStateWriterSuccess, + type PendingProfileStateWriterRequest, + type SuccessfulProfileStateWriterResponse +} from './profile-state-writer-request' +import { + decodeProfileStateWriterError, + ProfileStateWriterError +} from './profile-state-writer-errors' +import { + isProfileStateWriterResponse, + type ProfileStateWriterCommand, + type ProfileStateWriterInitialization +} from './profile-state-writer-protocol' + +const REQUEST_TIMEOUT_MS = 30_000 + +/** One materialized command; Store owns coalescing and never queues snapshots here. */ +export class ProfileStateWriterConnection { + readonly ready: Promise + private worker: Worker | undefined + private active: PendingProfileStateWriterRequest | undefined + private nextId = 1 + private failure: Error | undefined + private draining = false + private closePromise: Promise | undefined + private readonly exited: Promise + private markExited: () => void = () => {} + private didExit = false + private closeAcknowledged = false + private readonly timeoutMs: number + private readonly initialRevision: number + private latestRevision: number | undefined + + constructor( + initialization: ProfileStateWriterInitialization, + options: { workerPath?: string; timeoutMs?: number } = {} + ) { + this.initialRevision = initialization.revision + this.timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS + this.exited = new Promise((resolve) => { + this.markExited = resolve + }) + const pending = this.createPending(0, 'initialize') + this.active = pending + this.ready = pending.promise.then(() => {}) + // Initialization failures remain observable through ready without an unhandled rejection. + void this.ready.catch(() => {}) + try { + const worker = new Worker(options.workerPath ?? resolveProfileStateWriterWorkerPath(), { + workerData: initialization, + execArgv: [] + }) + this.worker = worker + worker.on('message', (response: unknown) => this.receive(response)) + worker.on('error', (cause: Error) => + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-exit', + 'Profile state writer failed', + this.dispatchedOutcome(), + { cause } + ) + ) + ) + worker.once('exit', (code) => { + this.didExit = true + this.markExited() + if (!this.closeAcknowledged || code !== 0) { + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-exit', + `Profile state writer exited without a completed close (${code})`, + this.dispatchedOutcome() + ) + ) + } + }) + } catch (cause) { + this.didExit = true + this.markExited() + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-unavailable', + 'Profile state writer could not start', + 'known-failure', + { cause } + ) + ) + } + } + + /** Abandoning an active wait cannot establish whether SQLite committed. */ + async abort(): Promise { + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-aborted', + 'Profile state writer was aborted', + this.dispatchedOutcome() + ) + ) + await this.exited + } + + close(): Promise { + this.draining = true + this.closePromise ??= this.finishClose() + return this.closePromise + } + + get acknowledgedRevision(): number { + if (this.failure) { + throw this.failure + } + if (this.latestRevision === undefined) { + throw new Error('Profile state writer has no acknowledged revision') + } + return this.latestRevision + } + + private async finishClose(): Promise { + await this.active?.promise.catch(() => {}) + if (!this.failure && !this.didExit) { + try { + await this.dispatch({ command: 'close' }) + } finally { + const timer = setTimeout( + () => + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-close-timeout', + 'Profile state writer did not exit after close', + 'indeterminate' + ) + ), + this.timeoutMs + ) + try { + await this.exited + } finally { + clearTimeout(timer) + } + } + if (this.failure) { + throw this.failure + } + } else { + await this.exited + } + } + + protected assertDispatchable(closing = false): void { + if (this.failure) { + throw this.failure + } + if (this.didExit || (this.draining && !closing)) { + throw new ProfileStateWriterError( + 'profile-state-writer-closed', + 'Profile state writer is closing', + 'known-failure' + ) + } + if (this.active) { + throw new ProfileStateWriterError( + 'profile-state-writer-busy', + 'Await the active profile state command before dispatching another snapshot', + 'known-failure' + ) + } + } + + protected dispatch( + command: ProfileStateWriterCommand + ): Promise { + try { + this.assertDispatchable(command.command === 'close') + } catch (error) { + return Promise.reject(error) + } + const pending = this.createPending(this.nextId++, command.command) + this.active = pending + try { + this.worker?.postMessage({ ...command, id: pending.id }) + } catch (cause) { + // postMessage did not dispatch a message when serialization fails. + this.settle( + undefined, + new ProfileStateWriterError( + 'profile-state-writer-message', + 'Profile state command could not be transferred', + 'known-failure', + { cause } + ) + ) + } + return pending.promise + } + + private createPending( + id: number, + command: PendingProfileStateWriterRequest['command'] + ): PendingProfileStateWriterRequest { + return createProfileStateWriterRequest(id, command, this.timeoutMs, () => + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-timeout', + 'Profile state writer command timed out', + this.dispatchedOutcome() + ) + ) + ) + } + + private receive(value: unknown): void { + if (this.failure) { + return + } + const pending = this.active + if (!isProfileStateWriterResponse(value) || !pending || value.id !== pending.id) { + this.invalidResponse() + return + } + if (!value.ok) { + const error = decodeProfileStateWriterError(value.error) + if (pending.command === 'initialize' || value.error.outcome === 'indeterminate') { + this.fault(error) + } else { + this.settle(undefined, error) + } + return + } + if ( + !isExpectedProfileStateWriterSuccess( + pending.command, + value, + this.latestRevision ?? this.initialRevision + ) + ) { + this.invalidResponse() + return + } + if (pending.command === 'close') { + this.closeAcknowledged = true + } + this.latestRevision = value.revision + this.settle(value) + } + + private settle(response?: SuccessfulProfileStateWriterResponse, error?: Error): void { + const pending = this.active + this.active = undefined + if (!pending) { + return + } + clearTimeout(pending.timer) + if (response) { + pending.resolve(response) + } else { + pending.reject(error ?? new Error('Profile state request failed')) + } + } + + private dispatchedOutcome(): 'known-failure' | 'indeterminate' { + return this.active?.command === 'initialize' ? 'known-failure' : 'indeterminate' + } + + private invalidResponse(): Error { + const error = new ProfileStateWriterError( + 'profile-state-writer-protocol', + 'Invalid profile state writer response', + this.dispatchedOutcome() + ) + this.fault(error) + return error + } + + private fault(error: Error): void { + this.failure ??= error + this.settle(undefined, this.failure) + if (!this.didExit) { + void this.worker?.terminate().catch(() => {}) + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-writer-errors.ts b/src/main/persistence/profile-state/profile-state-writer-errors.ts new file mode 100644 index 000000000000..effff79f3acf --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-errors.ts @@ -0,0 +1,89 @@ +import { + ProfileStateDocumentCorruptionError, + ProfileStateRevisionConflictError +} from './profile-state-document-validation' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' +import { ProfileStateIndeterminateWriteError } from './profile-state-write-transaction' +import type { + ProfileStateWriterErrorData, + ProfileStateWriterFailureOutcome +} from './profile-state-writer-protocol' + +export class ProfileStateWriterError extends Error { + constructor( + readonly code: string, + message: string, + readonly outcome: ProfileStateWriterFailureOutcome, + options?: ErrorOptions + ) { + super(message, options) + this.name = 'ProfileStateWriterError' + } +} + +export function profileStateWriterFailureOutcome(error: unknown): ProfileStateWriterFailureOutcome { + if (error instanceof ProfileStateIndeterminateWriteError) { + return 'indeterminate' + } + if (error instanceof ProfileStateWriterError) { + return error.outcome + } + if (error instanceof Error && error.cause !== undefined) { + return profileStateWriterFailureOutcome(error.cause) + } + return 'known-failure' +} + +export function encodeProfileStateWriterError( + error: unknown, + forceIndeterminate = false +): ProfileStateWriterErrorData { + const outcome = forceIndeterminate ? 'indeterminate' : profileStateWriterFailureOutcome(error) + if (error instanceof ProfileStateRevisionConflictError) { + return { + code: error.code, + message: error.message, + outcome, + expectedRevision: error.expectedRevision, + actualRevision: error.actualRevision + } + } + if (error instanceof ProfileStateDocumentCorruptionError) { + return { code: error.code, message: error.message, outcome, domain: error.domain } + } + if ( + error instanceof ProfileStateDatabaseOpenError || + error instanceof ProfileStateWriterError || + error instanceof ProfileStateIndeterminateWriteError + ) { + return { code: error.code, message: error.message, outcome } + } + return { + code: 'profile-state-write-failed', + message: 'Profile state persistence failed', + outcome + } +} + +export function decodeProfileStateWriterError(data: ProfileStateWriterErrorData): Error { + if (data.outcome === 'known-failure') { + if ( + data.code === 'profile-state-revision-conflict' && + data.expectedRevision !== undefined && + data.actualRevision !== undefined + ) { + return new ProfileStateRevisionConflictError(data.expectedRevision, data.actualRevision) + } + if (data.code === 'corrupt-document') { + return new ProfileStateDocumentCorruptionError(data.message, data.domain ?? null) + } + if ( + data.code === 'unreadable' || + data.code === 'identity-mismatch' || + data.code === 'invalid-profile-id' + ) { + return new ProfileStateDatabaseOpenError(data.code, data.message) + } + } + return new ProfileStateWriterError(data.code, data.message, data.outcome) +} diff --git a/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts b/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts new file mode 100644 index 000000000000..954ebd44d970 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' + +const clients: ProfileStateWriteWorkerClient[] = [] +const roots: string[] = [] +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function clientFor(response: string, initialization = '{ id: 0, ok: true, revision: 1 }') { + const root = mkdtempSync(join(tmpdir(), 'orca-writer-protocol-')) + roots.push(root) + const workerPath = join(root, 'writer.cjs') + writeFileSync( + workerPath, + ` + const { parentPort } = require('node:worker_threads') + parentPort.postMessage(${initialization}) + parentPort.on('message', (request) => { ${response} }) + ` + ) + const client = new ProfileStateWriteWorkerClient( + { databasePath: join(root, 'unused.db'), profileId: 'protocol-test', revision: 1 }, + { workerPath, timeoutMs: 1000 } + ) + clients.push(client) + return client +} + +describe('writer protocol refuses uncertain acknowledgements', () => { + it.each([ + '{ id: request.id + 1, ok: true, revision: 2 }', + '{ id: request.id, ok: true, revision: 0 }', + '{ id: request.id, ok: true, revision: 3 }', + '{ id: request.id, ok: true, revision: 2, exportedRevision: 2 }', + '{ id: request.id, ok: true, revision: "2" }' + ])('faults instead of acknowledging malformed write response %s', async (response) => { + const client = clientFor(`parentPort.postMessage(${response})`) + await client.ready + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toMatchObject({ code: 'profile-state-writer-protocol', outcome: 'indeterminate' }) + await expect(client.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-protocol' + }) + }) + + it.each(['undefined', 'null', '0', '2'])( + 'refuses a compatibility export with revision %s for an admitted revision of one', + async (exportedRevision) => { + const client = clientFor(`parentPort.postMessage({ + id: request.id, ok: true, revision: 1, exportedRevision: ${exportedRevision} + })`) + await client.ready + await expect(client.writeJsonCompatibilityExportAsync('unused.json')).rejects.toMatchObject({ + code: 'profile-state-writer-protocol', + outcome: 'indeterminate' + }) + } + ) + + it('refuses a revision jump at an unchanged-state fence', async () => { + const client = clientFor('parentPort.postMessage({ id: request.id, ok: true, revision: 2 })') + await client.ready + await expect(client.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-protocol', + outcome: 'indeterminate' + }) + }) + + it('refuses an initialization acknowledgement for a different revision', async () => { + const client = clientFor('', '{ id: 0, ok: true, revision: 2 }') + await expect(client.ready).rejects.toMatchObject({ + code: 'profile-state-writer-protocol', + outcome: 'known-failure' + }) + }) + + it('faults an unanswered request and rejects later work without retry', async () => { + const client = clientFor('') + await client.ready + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toMatchObject({ code: 'profile-state-writer-timeout', outcome: 'indeterminate' }) + await expect(client.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-timeout' + }) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-writer-protocol.ts b/src/main/persistence/profile-state/profile-state-writer-protocol.ts new file mode 100644 index 000000000000..1a7dc665abfc --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-protocol.ts @@ -0,0 +1,115 @@ +import type { ProfileStateDomainReplacement } from '../loading-store/profile-state-authority' +import { isRecord } from './profile-state-document-validation' + +export type ProfileStateWriterInitialization = { + databasePath: string + profileId: string + revision: number +} + +export type ProfileStateWriterCommand = + | { command: 'write-state'; payload: Uint8Array } + | { + command: 'write-complete' | 'write-domains' + replacements: readonly ProfileStateDomainReplacement[] + } + | { + command: 'write-automation' + replacements: readonly ProfileStateDomainReplacement[] + runPayloads: readonly string[] + } + | { command: 'assert-revision' | 'close' } + | { command: 'export-json' | 'export-latest' | 'export-compatibility'; targetPath: string } + +export type ProfileStateWriterRequest = ProfileStateWriterCommand & { id: number } +export type ProfileStateWriterFailureOutcome = 'known-failure' | 'indeterminate' +export type ProfileStateWriterErrorData = { + code: string + message: string + outcome: ProfileStateWriterFailureOutcome + expectedRevision?: number + actualRevision?: number + domain?: string | null +} +export type ProfileStateWriterResponse = + | { id: number; ok: true; revision: number; exportedRevision?: number | null } + | { id: number; ok: false; error: ProfileStateWriterErrorData } + +export function isProfileStateRevision(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} + +export function isProfileStateWriterInitialization( + value: unknown +): value is ProfileStateWriterInitialization { + return ( + isRecord(value) && + typeof value.databasePath === 'string' && + value.databasePath.length > 0 && + typeof value.profileId === 'string' && + value.profileId.length > 0 && + isProfileStateRevision(value.revision) + ) +} + +function isReplacement(value: unknown): value is ProfileStateDomainReplacement { + return ( + isRecord(value) && + typeof value.domain === 'string' && + value.domain.length > 0 && + (typeof value.payload === 'string' || value.payload === null) + ) +} + +export function isProfileStateWriterRequest(value: unknown): value is ProfileStateWriterRequest { + if (!isRecord(value) || !isProfileStateRevision(value.id) || value.id === 0) { + return false + } + switch (value.command) { + case 'write-state': + return value.payload instanceof Uint8Array + case 'assert-revision': + case 'close': + return true + case 'export-json': + case 'export-latest': + case 'export-compatibility': + return typeof value.targetPath === 'string' && value.targetPath.length > 0 + case 'write-complete': + case 'write-domains': + return Array.isArray(value.replacements) && value.replacements.every(isReplacement) + case 'write-automation': + return ( + Array.isArray(value.replacements) && + value.replacements.every(isReplacement) && + Array.isArray(value.runPayloads) && + value.runPayloads.every((payload: unknown) => typeof payload === 'string') + ) + default: + return false + } +} + +function isErrorData(value: unknown): value is ProfileStateWriterErrorData { + return ( + isRecord(value) && + typeof value.code === 'string' && + typeof value.message === 'string' && + (value.outcome === 'known-failure' || value.outcome === 'indeterminate') && + (value.expectedRevision === undefined || isProfileStateRevision(value.expectedRevision)) && + (value.actualRevision === undefined || isProfileStateRevision(value.actualRevision)) && + (value.domain === undefined || value.domain === null || typeof value.domain === 'string') + ) +} + +export function isProfileStateWriterResponse(value: unknown): value is ProfileStateWriterResponse { + if (!isRecord(value) || !isProfileStateRevision(value.id)) { + return false + } + return value.ok === true + ? isProfileStateRevision(value.revision) && + (value.exportedRevision === undefined || + value.exportedRevision === null || + isProfileStateRevision(value.exportedRevision)) + : value.ok === false && isErrorData(value.error) +} diff --git a/src/main/persistence/profile-state/profile-state-writer-request.ts b/src/main/persistence/profile-state/profile-state-writer-request.ts new file mode 100644 index 000000000000..2070c1da85d4 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-request.ts @@ -0,0 +1,60 @@ +import type { + ProfileStateWriterCommand, + ProfileStateWriterResponse +} from './profile-state-writer-protocol' + +export type SuccessfulProfileStateWriterResponse = Extract +export type PendingProfileStateWriterRequest = { + id: number + command: ProfileStateWriterCommand['command'] | 'initialize' + promise: Promise + resolve: (response: SuccessfulProfileStateWriterResponse) => void + reject: (error: Error) => void + timer: ReturnType +} + +export function isExpectedProfileStateWriterSuccess( + command: PendingProfileStateWriterRequest['command'], + response: SuccessfulProfileStateWriterResponse, + previousRevision: number +): boolean { + const mayWrite = command.startsWith('write-') + if ( + response.revision < previousRevision || + response.revision > previousRevision + (mayWrite ? 1 : 0) + ) { + return false + } + if ( + response.exportedRevision !== undefined && + response.exportedRevision !== null && + response.exportedRevision !== response.revision + ) { + return false + } + if (command === 'export-json') { + return response.exportedRevision !== undefined && response.exportedRevision !== null + } + if (command === 'export-compatibility' || command === 'export-latest') { + return ( + response.exportedRevision !== undefined && + (response.exportedRevision !== null || response.revision === 0) + ) + } + return response.exportedRevision === undefined +} + +export function createProfileStateWriterRequest( + id: number, + command: PendingProfileStateWriterRequest['command'], + timeoutMs: number, + onTimeout: () => void +): PendingProfileStateWriterRequest { + let resolve: PendingProfileStateWriterRequest['resolve'] = () => {} + let reject: PendingProfileStateWriterRequest['reject'] = () => {} + const promise = new Promise((accept, refuse) => { + resolve = accept + reject = refuse + }) + return { id, command, promise, resolve, reject, timer: setTimeout(onTimeout, timeoutMs) } +} diff --git a/src/main/persistence/profile-state/profile-state-writer-worker-client.ts b/src/main/persistence/profile-state/profile-state-writer-worker-client.ts new file mode 100644 index 000000000000..215af1f34990 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker-client.ts @@ -0,0 +1,75 @@ +import type { ProfileStateDomainReplacement } from '../loading-store/profile-state-authority' +import { ProfileStateWriterConnection } from './profile-state-writer-connection' +export { resolveProfileStateWriterWorkerPath } from './profile-state-writer-worker-path' +export { + ProfileStateWriterError, + profileStateWriterFailureOutcome +} from './profile-state-writer-errors' +export type { ProfileStateWriterInitialization } from './profile-state-writer-protocol' + +export class ProfileStateWriteWorkerClient extends ProfileStateWriterConnection { + assertWritable(): void { + this.assertDispatchable() + } + + writeSerializedState(payload: Buffer): Promise { + return this.dispatch({ command: 'write-state', payload }).then((response) => response.revision) + } + + writeCompleteSerializedDomains( + replacements: readonly ProfileStateDomainReplacement[] + ): Promise { + return this.dispatch({ command: 'write-complete', replacements }).then( + (response) => response.revision + ) + } + + writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): Promise { + return this.dispatch({ command: 'write-domains', replacements }).then( + (response) => response.revision + ) + } + + writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly unknown[] + ): Promise { + try { + this.assertDispatchable() + const runPayloads = runs.map((run) => { + const payload = JSON.stringify(run) + if (payload === undefined) { + throw new Error('Automation run is not serializable') + } + return payload + }) + return this.dispatch({ command: 'write-automation', replacements, runPayloads }).then( + (response) => response.revision + ) + } catch (error) { + return Promise.reject(error) + } + } + + assertCurrentRevision(): Promise { + return this.dispatch({ command: 'assert-revision' }).then((response) => response.revision) + } + + writeJsonExport(targetPath: string): Promise { + return this.dispatch({ command: 'export-json', targetPath }).then( + (response) => response.exportedRevision ?? response.revision + ) + } + + writeLatestJsonExport(dataFile: string): Promise { + return this.dispatch({ command: 'export-latest', targetPath: dataFile }).then( + (response) => response.exportedRevision ?? undefined + ) + } + + writeJsonCompatibilityExportAsync(targetPath: string): Promise { + return this.dispatch({ command: 'export-compatibility', targetPath }).then( + (response) => response.exportedRevision ?? undefined + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts b/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts new file mode 100644 index 000000000000..1e76982508da --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts @@ -0,0 +1,153 @@ +import { parentPort, workerData } from 'node:worker_threads' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { writeVersionedProfileStateExport } from './profile-state-versioned-export' +import { + encodeProfileStateWriterError, + ProfileStateWriterError +} from './profile-state-writer-errors' +import { + isProfileStateWriterInitialization, + isProfileStateWriterRequest, + type ProfileStateWriterRequest, + type ProfileStateWriterResponse +} from './profile-state-writer-protocol' + +if (!parentPort) { + throw new Error('Profile state writer requires a worker thread') +} +const port = parentPort +let authority: ProfileStateSqliteAuthority | undefined +let busy = false +let stopping = false +let previousId = 0 + +function reply(response: ProfileStateWriterResponse): void { + port.postMessage(response) +} + +function close(): void { + stopping = true + try { + authority?.close() + } finally { + port.close() + } +} + +async function execute(request: ProfileStateWriterRequest): Promise { + if (!authority) { + throw new Error('Profile state writer is not initialized') + } + let exportedRevision: number | null | undefined + switch (request.command) { + case 'write-state': + authority.writeSerializedState(Buffer.from(request.payload)) + break + case 'write-complete': + authority.writeCompleteSerializedDomains(request.replacements) + break + case 'write-domains': + authority.writeSerializedDomains(request.replacements) + break + case 'write-automation': + authority.writeSerializedAutomationRuns( + request.replacements, + request.runPayloads.map((payload): unknown => JSON.parse(payload)) + ) + break + case 'assert-revision': + authority.assertCurrentRevision() + break + case 'export-json': + authority.assertCurrentRevision() + exportedRevision = authority.writeJsonExport(request.targetPath) + break + case 'export-latest': + authority.assertCurrentRevision() + exportedRevision = + writeVersionedProfileStateExport( + request.targetPath, + authority.writeJsonExport.bind(authority) + ) ?? null + break + case 'export-compatibility': + authority.assertCurrentRevision() + exportedRevision = + (await authority.writeJsonCompatibilityExportAsync(request.targetPath)) ?? null + break + case 'close': + authority.close() + stopping = true + break + } + return { + id: request.id, + ok: true, + revision: authority.revision, + ...(exportedRevision === undefined ? {} : { exportedRevision }) + } +} + +async function accept(value: unknown): Promise { + if (stopping) { + return + } + if (!isProfileStateWriterRequest(value) || busy || value.id <= previousId) { + stopping = true + reply({ + id: 0, + ok: false, + error: encodeProfileStateWriterError( + new ProfileStateWriterError( + 'profile-state-writer-protocol', + 'Invalid profile state writer request', + 'indeterminate' + ) + ) + }) + if (!busy) { + close() + } + return + } + busy = true + previousId = value.id + try { + reply(await execute(value)) + } catch (error) { + const failure = encodeProfileStateWriterError( + error, + value.command === 'export-json' || + value.command === 'export-latest' || + value.command === 'export-compatibility' || + value.command === 'close' + ) + reply({ id: value.id, ok: false, error: failure }) + stopping ||= failure.outcome === 'indeterminate' + } finally { + busy = false + if (stopping) { + close() + } + } +} + +try { + const initialization: unknown = workerData + if (!isProfileStateWriterInitialization(initialization)) { + throw new ProfileStateWriterError( + 'profile-state-writer-initialization', + 'Invalid profile state writer initialization', + 'known-failure' + ) + } + authority = new ProfileStateSqliteAuthority(initialization.databasePath, initialization.profileId) + authority.initializeFromRevision(initialization.revision) + reply({ id: 0, ok: true, revision: authority.revision }) + port.on('message', (value: unknown) => { + void accept(value) + }) +} catch (error) { + reply({ id: 0, ok: false, error: encodeProfileStateWriterError(error) }) + close() +} diff --git a/src/main/persistence/profile-state/profile-state-writer-worker-path.ts b/src/main/persistence/profile-state/profile-state-writer-worker-path.ts new file mode 100644 index 000000000000..d1068eb26b0d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker-path.ts @@ -0,0 +1,17 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { + currentWorkerEntryLayout, + resolveWorkerThreadEntryPath +} from '../../worker-thread-entry-path' + +export function resolveProfileStateWriterWorkerPath(moduleDir = __dirname): string { + const entry = resolveWorkerThreadEntryPath( + currentWorkerEntryLayout(moduleDir), + 'profile-state-writer-worker-entry.js' + ) + return ( + [entry, join(dirname(entry), '..', 'profile-state-writer-worker-entry.js')].find(existsSync) ?? + entry + ) +} diff --git a/src/main/persistence/profile-state/profile-state-writer-worker.test.ts b/src/main/persistence/profile-state/profile-state-writer-worker.test.ts new file mode 100644 index 000000000000..6fa1efe9a6ef --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker.test.ts @@ -0,0 +1,341 @@ +import { build } from 'esbuild' +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' +import { openProfileStateDatabase } from './profile-state-database' +import { readProfileStateSnapshot } from './profile-state-documents' +import { + ProfileStateWriteWorkerClient, + profileStateWriterFailureOutcome, + resolveProfileStateWriterWorkerPath +} from './profile-state-writer-worker-client' + +let bundleRoot: string +let workerPath: string +const roots: string[] = [] +const clients: ProfileStateWriteWorkerClient[] = [] + +beforeAll(async () => { + bundleRoot = mkdtempSync(join(tmpdir(), 'orca-writer-bundle-')) + workerPath = join(bundleRoot, 'profile-state-writer-worker-entry.js') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts') + ], + outfile: workerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close().catch(() => {}))) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +afterAll(() => rmSync(bundleRoot, { recursive: true, force: true })) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-writer-')) + roots.push(root) + const path = join(root, 'profile-state.db') + const profileId = 'writer-test' + const db = openProfileStateDatabase(path, profileId) + db.db.close() + const authority = new ProfileStateSqliteAuthority(path, profileId) + authority.readInitialState().takeParsedState?.() + authority.writeCompleteSerializedDomains([{ domain: 'settings', payload: '{"theme":"dark"}' }]) + const initialization = authority.retireForWorker() + return { root, path, profileId, authority, initialization } +} + +function clientFor( + initialization: ReturnType['initialization'], + path = workerPath, + timeoutMs = 5000 +) { + const client = new ProfileStateWriteWorkerClient(initialization, { workerPath: path, timeoutMs }) + clients.push(client) + return client +} + +function readState(path: string, profileId: string): unknown { + const db = openProfileStateDatabase(path, profileId) + try { + return JSON.parse(readProfileStateSnapshot(db.db).json) + } finally { + db.db.close() + } +} + +function script(root: string, source: string): string { + const path = join(root, 'fault-worker.cjs') + writeFileSync(path, source) + return path +} + +describe('persistent profile state write worker', () => { + it('commits full, selective, automation and byte payloads, exports and releases the database', async () => { + const f = fixture() + const client = clientFor(f.initialization) + await client.ready + expect( + await client.writeSerializedDomains([{ domain: 'ui', payload: '{"note":"hi \\ud800"}' }]) + ).toBe(2) + const run = { + id: 'run-1', + output: 'first', + toJSON() { + return { id: this.id, output: this.output } + } + } + const write = client.writeSerializedAutomationRuns([], [run]) + run.output = 'changed after capture' + expect(await write).toBe(3) + const exported = join(f.root, 'state.json') + expect(await client.writeJsonExport(exported)).toBe(3) + expect(JSON.parse(readFileSync(exported, 'utf8'))).toEqual({ + settings: { theme: 'dark' }, + ui: { note: 'hi \ud800' }, + automationRuns: [{ id: 'run-1', output: 'first' }] + }) + const compatibility = join(f.root, 'compatibility.json') + expect(await client.writeJsonCompatibilityExportAsync(compatibility)).toBe(3) + expect(readFileSync(compatibility, 'utf8')).toBe(readFileSync(exported, 'utf8')) + expect(await client.writeSerializedState(Buffer.from('{"settings":{"theme":"light"}}'))).toBe(4) + expect(await client.assertCurrentRevision()).toBe(4) + await client.close() + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { theme: 'light' } }) + rmSync(f.root, { recursive: true }) + expect(existsSync(f.root)).toBe(false) + }) + + it('requires a present admitted database and refuses startup revision races', async () => { + const f = fixture() + const peer = new ProfileStateSqliteAuthority(f.path, f.profileId) + peer.readInitialState() + peer.writeSerializedDomains([{ domain: 'settings', payload: '{"peer":true}' }]) + peer.close() + const client = clientFor(f.initialization) + await expect(client.ready).rejects.toBeInstanceOf(ProfileStateRevisionConflictError) + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { peer: true } }) + const missing = clientFor({ ...f.initialization, databasePath: join(f.root, 'missing.db') }) + await expect(missing.ready).rejects.toMatchObject({ code: 'unreadable' }) + await missing.close() + expect(existsSync(join(f.root, 'missing.db'))).toBe(false) + }) + + it('permanently retires bootstrap authority and refuses subsequent calls', () => { + const f = fixture() + for (const call of [ + () => f.authority.readInitialState(), + () => f.authority.readAcceptedState('{}'), + () => f.authority.readSerializedState(), + () => f.authority.writeSerializedDomains([]), + () => f.authority.writeCompleteSerializedDomains([]), + () => f.authority.writeSerializedAutomationRuns([], []), + () => f.authority.writeSerializedState(Buffer.from('{}')), + () => f.authority.writeJsonExport(join(f.root, 'export.json')), + () => f.authority.scheduleBackup(), + () => f.authority.assertCurrentRevision(), + () => f.authority.initializeFromRevision(1), + () => f.authority.retireForWorker(), + () => f.authority.close() + ]) { + expect(call).toThrow(/retired/) + } + }) + + it('preserves known validation failures and rejects a peer at the no-op fence', async () => { + const f = fixture() + const client = clientFor(f.initialization) + await client.ready + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{' }]) + ).rejects.toMatchObject({ code: 'profile-state-write-failed', outcome: 'known-failure' }) + expect(await client.assertCurrentRevision()).toBe(1) + const peer = new ProfileStateSqliteAuthority(f.path, f.profileId) + peer.readInitialState() + peer.writeSerializedDomains([{ domain: 'ui', payload: '{"peer":true}' }]) + peer.close() + const failure = await client.assertCurrentRevision().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(ProfileStateRevisionConflictError) + expect(profileStateWriterFailureOutcome(failure)).toBe('known-failure') + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toBeInstanceOf(ProfileStateRevisionConflictError) + }) + + it('rejects a second materialized write and waits for the first before close', async () => { + const f = fixture() + const delayed = script( + f.root, + ` + const { MessagePort } = require('node:worker_threads') + const send = MessagePort.prototype.postMessage + MessagePort.prototype.postMessage = function(value, ...rest) { + if (value?.id === 1 && value.ok) { setTimeout(() => send.call(this, value, ...rest), 60); return } + return send.call(this, value, ...rest) + } + require(${JSON.stringify(workerPath)}) + ` + ) + const client = clientFor(f.initialization, delayed) + await client.ready + const first = client.writeSerializedDomains([{ domain: 'settings', payload: '{"first":true}' }]) + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{"second":true}' }]) + ).rejects.toMatchObject({ code: 'profile-state-writer-busy' }) + const closing = client.close() + expect(await first).toBe(2) + await closing + expect(readState(f.path, f.profileId)).toEqual({ settings: { first: true } }) + await expect(client.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-closed' + }) + }) + + it('waits for actual worker exit after receiving its close acknowledgement', async () => { + const f = fixture() + const marker = join(f.root, 'worker-exited.txt') + const delayedExit = script( + f.root, + ` + const { MessagePort } = require('node:worker_threads') + const send = MessagePort.prototype.postMessage + MessagePort.prototype.postMessage = function(value, ...rest) { + if (value?.id === 1 && value.ok) { + setTimeout(() => require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'released'), 60) + } + return send.call(this, value, ...rest) + } + require(${JSON.stringify(workerPath)}) + ` + ) + const client = clientFor(f.initialization, delayedExit) + await client.ready + await client.close() + expect(readFileSync(marker, 'utf8')).toBe('released') + }) + + it('reports post-commit worker death as indeterminate without replaying the committed write', async () => { + const f = fixture() + const crashAfterCommit = script( + f.root, + ` + const { MessagePort } = require('node:worker_threads') + const send = MessagePort.prototype.postMessage + MessagePort.prototype.postMessage = function(value, ...rest) { + if (value?.id === 1 && value.ok) process.exit(13) + return send.call(this, value, ...rest) + } + require(${JSON.stringify(workerPath)}) + ` + ) + const client = clientFor(f.initialization, crashAfterCommit) + await client.ready + const failure = await client + .writeSerializedDomains([{ domain: 'settings', payload: '{"committed":true}' }]) + .catch((error: unknown) => error) + expect(profileStateWriterFailureOutcome(failure)).toBe('indeterminate') + await expect(client.assertCurrentRevision()).rejects.toBe(failure) + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { committed: true } }) + }) + + it.each(['mismatch', 'timeout', 'exit'])( + 'fails closed after a dispatched %s and awaits actual exit', + async (mode) => { + const f = fixture() + const broken = script( + f.root, + ` + const { parentPort } = require('node:worker_threads') + parentPort.postMessage({ id: 0, ok: true, revision: 1 }) + parentPort.on('message', request => { + if (${JSON.stringify(mode)} === 'exit') process.exit(0) + if (${JSON.stringify(mode)} === 'mismatch') parentPort.postMessage({ id: request.id + 1, ok: true, revision: 2 }) + }) + ` + ) + const client = clientFor(f.initialization, broken, 150) + await client.ready + const failure = await client + .writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + .catch((error: unknown) => error) + expect(profileStateWriterFailureOutcome(failure)).toBe('indeterminate') + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { theme: 'dark' } }) + rmSync(f.root, { recursive: true }) + } + ) + + it('recovers the prior committed revision after the worker exits inside a transaction', async () => { + const f = fixture() + const interruptedPath = join(f.root, 'interrupted-worker.cjs') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts') + ], + outfile: interruptedPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent', + plugins: [ + { + name: 'interrupt-transaction', + setup(builder) { + builder.onLoad({ filter: /profile-state-write-transaction\.ts$/ }, (args) => ({ + contents: readFileSync(args.path, 'utf8').replace( + "db.exec('COMMIT')", + 'process.exit(17)' + ), + loader: 'ts' + })) + } + } + ] + }) + const client = clientFor(f.initialization, interruptedPath) + await client.ready + const failure = await client + .writeSerializedDomains([{ domain: 'settings', payload: '{"uncommitted":true}' }]) + .catch((error: unknown) => error) + expect(profileStateWriterFailureOutcome(failure)).toBe('indeterminate') + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { theme: 'dark' } }) + }) + + it('aborts an active request without treating the pending write as rolled back', async () => { + const f = fixture() + const hung = script( + f.root, + ` + const { parentPort } = require('node:worker_threads') + parentPort.postMessage({ id: 0, ok: true, revision: 1 }) + parentPort.on('message', () => {}) + ` + ) + const client = clientFor(f.initialization, hung) + await client.ready + const write = client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + const failed = expect(write).rejects.toMatchObject({ outcome: 'indeterminate' }) + await client.abort() + await failed + await client.close() + }) + + it('resolves flat and shared-chunk entry layouts', () => { + expect(resolveProfileStateWriterWorkerPath(bundleRoot)).toBe(workerPath) + expect(resolveProfileStateWriterWorkerPath(join(bundleRoot, 'chunks'))).toBe(workerPath) + }) +}) diff --git a/src/main/persistence/restoring-sessions/workspace-session-write-rollback.ts b/src/main/persistence/restoring-sessions/workspace-session-write-rollback.ts new file mode 100644 index 000000000000..bdca21f7bbe1 --- /dev/null +++ b/src/main/persistence/restoring-sessions/workspace-session-write-rollback.ts @@ -0,0 +1,132 @@ +import { isDeepStrictEqual } from 'node:util' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +const MISSING = Symbol('missing') + +/** A JSON-shaped slot of persisted session state, or the absent-key sentinel. */ +type RollbackSlot = + | string + | number + | boolean + | null + | undefined + | typeof MISSING + | readonly RollbackSlot[] + | RollbackRecord + +type RollbackRecord = { readonly [key: string]: RollbackSlot } + +function isRecord(value: RollbackSlot): value is RollbackRecord { + return ( + value !== MISSING && + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ) +} + +type IdentifiedRecord = RollbackRecord & { readonly id: string } + +function identifiedRows(value: RollbackSlot): readonly IdentifiedRecord[] | null { + if (value === MISSING) { + return [] + } + if ( + !Array.isArray(value) || + !value.every((row): row is IdentifiedRecord => isRecord(row) && typeof row.id === 'string') + ) { + return null + } + return new Set(value.map((row) => row.id)).size === value.length ? value : null +} + +function rollbackIdentifiedRows( + original: RollbackSlot, + staged: RollbackSlot, + current: RollbackSlot +): readonly RollbackSlot[] | null { + if (!Array.isArray(original) && !Array.isArray(staged) && !Array.isArray(current)) { + return null + } + const before = identifiedRows(original) + const written = identifiedRows(staged) + const latest = identifiedRows(current) + if (!before || !written || !latest) { + return null + } + const beforeById = new Map(before.map((row) => [row.id, row])) + const writtenById = new Map(written.map((row) => [row.id, row])) + const latestById = new Map(latest.map((row) => [row.id, row])) + const restored = new Map() + for (const id of new Set([...beforeById.keys(), ...writtenById.keys(), ...latestById.keys()])) { + const value = rollbackValue( + beforeById.get(id) ?? MISSING, + writtenById.get(id) ?? MISSING, + latestById.get(id) ?? MISSING + ) + if (value !== MISSING) { + restored.set(id, value) + } + } + const order = latest.map((row) => row.id).filter((id) => restored.has(id)) + for (const [index, row] of before.entries()) { + if (restored.has(row.id) && !order.includes(row.id)) { + order.splice(Math.min(index, order.length), 0, row.id) + } + } + return order.map((id) => restored.get(id)) +} + +function rollbackValue( + original: RollbackSlot, + staged: RollbackSlot, + current: RollbackSlot +): RollbackSlot { + if (isDeepStrictEqual(original, staged)) { + return current + } + if (isDeepStrictEqual(current, staged)) { + return original + } + // Terminal and unified tab rows have stable ids; unrelated row edits must survive rollback. + const rows = rollbackIdentifiedRows(original, staged, current) + if (rows) { + return rows + } + if (!isRecord(original) || !isRecord(staged) || !isRecord(current)) { + return current + } + let changed = false + const next: Record = { ...current } + for (const key of new Set([ + ...Object.keys(original), + ...Object.keys(staged), + ...Object.keys(current) + ])) { + const value = rollbackValue( + Object.hasOwn(original, key) ? original[key] : MISSING, + Object.hasOwn(staged, key) ? staged[key] : MISSING, + Object.hasOwn(current, key) ? current[key] : MISSING + ) + if (value === MISSING) { + if (Object.hasOwn(next, key)) { + delete next[key] + changed = true + } + } else if (!Object.hasOwn(current, key) || !isDeepStrictEqual(current[key], value)) { + next[key] = value + changed = true + } + } + return changed ? next : current +} + +export function rollbackWorkspaceSessionAfterFailedAsyncWrite( + original: WorkspaceSessionState, + staged: WorkspaceSessionState, + current: WorkspaceSessionState +): WorkspaceSessionState { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Each restored field retains a value from the same field of a typed session. + return rollbackValue(original, staged, current) as WorkspaceSessionState +} diff --git a/src/main/protected-secret-persistence-concurrency.test.ts b/src/main/protected-secret-persistence-concurrency.test.ts new file mode 100644 index 000000000000..1b429cdaa8b3 --- /dev/null +++ b/src/main/protected-secret-persistence-concurrency.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setSecretStore } from '../shared/secret-store' +import { + ProtectedSecretPersistence, + type ProtectedSecretRetentionUpdate +} from './protected-secret-persistence' + +const cipherState = { available: true, fails: false } +const ciphertext = (plaintext: string): string => + Buffer.from(`encrypted:${plaintext}`).toString('base64') + +function prepare( + secrets: ProtectedSecretPersistence, + slot: string, + plaintext: string +): ProtectedSecretRetentionUpdate { + const { retentionUpdate } = secrets.encrypt(slot, plaintext) + if (!retentionUpdate) { + throw new Error('Expected a prepared retention update') + } + return retentionUpdate +} + +describe('protected secret acknowledgements', () => { + beforeEach(() => { + cipherState.available = true + cipherState.fails = false + setSecretStore({ + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext) => { + if (cipherState.fails) { + throw new Error('Keyring encryption failed') + } + return Buffer.from(`encrypted:${plaintext}`) + }, + decryptString: (encrypted) => encrypted.toString().slice('encrypted:'.length), + describeProtectionGap: () => null + }) + }) + + afterEach(() => vi.restoreAllMocks()) + + it.each(['remove', 'empty encryption', 'empty decryption'])( + 'does not revive a secret cleared by %s while its save was in flight', + (clear) => { + const secrets = new ProtectedSecretPersistence() + const update = prepare(secrets, 'slot', 'first') + if (clear === 'remove') { + secrets.removeRetainedBlob('slot') + } else if (clear === 'empty encryption') { + secrets.encrypt('slot', '') + } else { + secrets.decrypt('slot', '') + } + + secrets.commitRetentionUpdates([update]) + + cipherState.available = false + expect(secrets.encrypt('slot', 'replacement').blob).toBe('') + } + ) + + it.each(['replacement', ''])('preserves a reloaded sealed slot after an old %j save', (value) => { + const secrets = new ProtectedSecretPersistence() + secrets.decrypt('slot', ciphertext('original')) + const update = prepare(secrets, 'slot', value) + const reloaded = ciphertext('reloaded') + cipherState.available = false + secrets.decrypt('slot', reloaded) + + secrets.commitRetentionUpdates([update]) + + expect(secrets.isSealed('slot', reloaded)).toBe(true) + expect(secrets.encrypt('slot', '').blob).toBe(reloaded) + expect(secrets.hasPendingEncryption()).toBe(false) + }) + + it('preserves a successfully decrypted replacement after an older save acknowledges', () => { + const secrets = new ProtectedSecretPersistence() + const update = prepare(secrets, 'slot', 'first') + const reloaded = ciphertext('reloaded') + expect(secrets.decrypt('slot', reloaded)).toBe('reloaded') + + secrets.commitRetentionUpdates([update]) + + cipherState.available = false + expect(secrets.encrypt('slot', 'reloaded').blob).toBe(reloaded) + }) + + it.each(['unavailable', 'throws'])( + 'keeps a newer %s encryption pending after an old ack', + (failure) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const secrets = new ProtectedSecretPersistence() + const original = ciphertext('original') + secrets.decrypt('slot', original) + const update = prepare(secrets, 'slot', 'first') + cipherState.available = failure !== 'unavailable' + cipherState.fails = failure === 'throws' + expect(secrets.encrypt('slot', 'newer')).toEqual({ blob: original, degraded: true }) + + secrets.commitRetentionUpdates([update]) + + expect(secrets.hasPendingEncryption()).toBe(true) + cipherState.available = false + expect(secrets.encrypt('slot', 'newer').blob).toBe(original) + cipherState.available = true + cipherState.fails = false + const retry = prepare(secrets, 'slot', 'newer') + secrets.commitRetentionUpdates([retry]) + expect(secrets.hasPendingEncryption()).toBe(false) + cipherState.available = false + expect(secrets.encrypt('slot', 'newer').blob).toBe(ciphertext('newer')) + } + ) + + it.each([false, true])( + 'retains only the latest prepared ciphertext, reverse ack order: %s', + (reverse) => { + const secrets = new ProtectedSecretPersistence() + const older = prepare(secrets, 'slot', 'older') + const newer = prepare(secrets, 'slot', 'newer') + for (const update of reverse ? [newer, older] : [older, newer]) { + secrets.commitRetentionUpdates([update]) + } + + cipherState.available = false + expect(secrets.encrypt('slot', 'replacement').blob).toBe(ciphertext('newer')) + } + ) + + it('retains the last same-slot value in a single acknowledged preparation batch', () => { + const secrets = new ProtectedSecretPersistence() + const updates = [prepare(secrets, 'slot', 'older'), prepare(secrets, 'slot', 'newer')] + + secrets.commitRetentionUpdates(updates) + + cipherState.available = false + expect(secrets.encrypt('slot', 'replacement').blob).toBe(ciphertext('newer')) + }) + + it('does not reuse an old acknowledgement when a removed dynamic slot is recreated', () => { + const secrets = new ProtectedSecretPersistence() + const removed = prepare(secrets, 'dynamic-slot', 'removed') + secrets.removeRetainedBlob('dynamic-slot') + const recreated = prepare(secrets, 'dynamic-slot', 'recreated') + + secrets.commitRetentionUpdates([removed, recreated, removed]) + + cipherState.available = false + expect(secrets.encrypt('dynamic-slot', 'replacement').blob).toBe(ciphertext('recreated')) + }) + + it('invalidates only the changed slot in an acknowledged batch', () => { + const secrets = new ProtectedSecretPersistence() + const updates = [prepare(secrets, 'keep', 'keep'), prepare(secrets, 'remove', 'remove')] + secrets.removeRetainedBlob('remove') + + secrets.commitRetentionUpdates(updates) + + cipherState.available = false + expect(secrets.encrypt('keep', 'replacement').blob).toBe(ciphertext('keep')) + expect(secrets.encrypt('remove', 'replacement').blob).toBe('') + }) + + it('does not accept a prepared update from a replaced persistence instance', () => { + const previous = new ProtectedSecretPersistence() + const current = new ProtectedSecretPersistence() + const older = prepare(previous, 'slot', 'older') + const newer = prepare(current, 'slot', 'newer') + + current.commitRetentionUpdates([older, newer]) + + cipherState.available = false + expect(current.encrypt('slot', 'replacement').blob).toBe(ciphertext('newer')) + }) + + it('keeps the previous ciphertext and pending retry when a prepared save has no confirmed ack', () => { + const secrets = new ProtectedSecretPersistence() + const original = ciphertext('original') + secrets.decrypt('slot', original) + cipherState.available = false + secrets.encrypt('slot', 'replacement') + cipherState.available = true + + prepare(secrets, 'slot', 'replacement') + + expect(secrets.hasPendingEncryption()).toBe(true) + cipherState.available = false + expect(secrets.encrypt('slot', 'replacement').blob).toBe(original) + }) +}) diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts index a07818bc160a..c72af0966197 100644 --- a/src/main/protected-secret-persistence.ts +++ b/src/main/protected-secret-persistence.ts @@ -19,6 +19,7 @@ export type ProtectedSecretDecryption = { export type ProtectedSecretRetentionUpdate = { slot: string blob: string | null + epoch: symbol } export type LegacyPlaintextValidator = (value: string) => boolean @@ -35,12 +36,14 @@ export class ProtectedSecretPersistence { private readonly retainedBlobs = new Map() private readonly sealedSlots = new Set() private readonly pendingEncryption = new Set() + private readonly retentionEpochs = new Map() hasPendingEncryption(): boolean { return this.pendingEncryption.size > 0 } removeRetainedBlob(slot: string): void { + this.retentionEpochs.delete(slot) this.retainedBlobs.delete(slot) this.sealedSlots.delete(slot) this.pendingEncryption.delete(slot) @@ -52,6 +55,11 @@ export class ProtectedSecretPersistence { commitRetentionUpdates(updates: readonly ProtectedSecretRetentionUpdate[]): void { for (const update of updates) { + // A delayed save must not overwrite a newer secret decision. + if (this.retentionEpochs.get(update.slot) !== update.epoch) { + continue + } + this.retentionEpochs.delete(update.slot) this.pendingEncryption.delete(update.slot) if (update.blob === null) { this.removeRetainedBlob(update.slot) @@ -63,12 +71,15 @@ export class ProtectedSecretPersistence { } encrypt(slot: string, plaintext: string): ProtectedSecretEncryption { + this.retentionEpochs.delete(slot) const retained = this.retainedBlobs.get(slot) ?? '' if (!plaintext && !retained) { return { blob: '', degraded: false, - ...(this.pendingEncryption.has(slot) ? { retentionUpdate: { slot, blob: null } } : {}) + ...(this.pendingEncryption.has(slot) + ? { retentionUpdate: this.prepareRetentionUpdate(slot, null) } + : {}) } } if (!this.encryptionAvailable()) { @@ -88,7 +99,7 @@ export class ProtectedSecretPersistence { return { blob: '', degraded: false, - retentionUpdate: { slot, blob: null } + retentionUpdate: this.prepareRetentionUpdate(slot, null) } } try { @@ -96,7 +107,7 @@ export class ProtectedSecretPersistence { return { blob, degraded: false, - retentionUpdate: { slot, blob } + retentionUpdate: this.prepareRetentionUpdate(slot, blob) } } catch (err) { this.pendingEncryption.add(slot) @@ -114,6 +125,7 @@ export class ProtectedSecretPersistence { ciphertext: string, isLegacyPlaintext?: LegacyPlaintextValidator ): ProtectedSecretDecryption { + this.retentionEpochs.delete(slot) if (!ciphertext) { this.removeRetainedBlob(slot) return { plaintext: '', status: 'decrypted' } @@ -144,6 +156,15 @@ export class ProtectedSecretPersistence { } } + private prepareRetentionUpdate( + slot: string, + blob: string | null + ): ProtectedSecretRetentionUpdate { + const epoch = Symbol() + this.retentionEpochs.set(slot, epoch) + return { slot, blob, epoch } + } + private encryptionAvailable(): boolean { // Why getSecretStore() sits outside the try: an uninstalled store is a startup bug, // not a keyring failure. Swallowing it would degrade to an empty blob and report diff --git a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts index 945284816224..4e2ebdf60269 100644 --- a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts +++ b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { vi, type Mock } from 'vitest' import { makePaneKey } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' @@ -101,7 +102,7 @@ function createHarness( badgeColor: '#000000', addedAt: 1 } - const store = { + const store = withDurableRuntimeStore({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getAllWorktreeMeta: () => ({}), @@ -117,7 +118,7 @@ function createHarness( throw flushError } }) - } + }) const acknowledged = makeDeferred() let closeTerminalTabError: Error | null = null let closeTerminalTabAction: (() => void | Promise) | null = null @@ -201,7 +202,7 @@ function createHarness( } } graph.syncFixtureGraph() - return { + return withDurableRuntimeStore({ runtime, acknowledged, closeTerminal, @@ -264,7 +265,7 @@ function createHarness( } } } - } + }) } function createPtyBackedPublishedSurfaceHarness(): CloseContinuityHarness { diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts index 2173d080dcb8..51cd8b4cd8bf 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts @@ -5,6 +5,8 @@ import { vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { DelayedAuthority } from '../persistence/loading-store/profile-state-delayed-authority-fixture' import { Store } from '../persistence/loading-store/store' import { OrcaRuntimeService } from './orca-runtime' import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' @@ -28,7 +30,13 @@ function deferred(): { promise: Promise; resolve: () => void } { export function createAcknowledgedTabRetirementFixture(bound = false) { const directory = mkdtempSync(join(tmpdir(), 'orca-close-ack-')) - const store = new Store({ dataFile: join(directory, 'orca-data.json') }) + const authority = new DelayedAuthority( + new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'ack-retirement') + ) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) store.addRepo({ id: 'repo1', path: '/tmp/worktree', @@ -150,7 +158,7 @@ export function createAcknowledgedTabRetirementFixture(bound = false) { ACK_TAB ) store.setWorkspaceSession({ ...closed.session, terminalTopologyRevisionByRepoId: undefined }) - store.flushOrThrow() + await store.flushPendingOrThrowAsync() entered.resolve() await acknowledgement.promise }) @@ -171,6 +179,7 @@ export function createAcknowledgedTabRetirementFixture(bound = false) { return { runtime, store, + authority, entered, acknowledgement, closeTerminalTab, @@ -182,9 +191,8 @@ export function createAcknowledgedTabRetirementFixture(bound = false) { dispose: async () => { runtime.setNotifier(null) runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) - store.flush() - store.freezeWrites() - await store.waitForPendingWrite() + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() setRuntimeDesktopSurface(null) rmSync(directory, { recursive: true, force: true }) } diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts index 91c3cb1b1d63..545b8bf53201 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts @@ -78,7 +78,7 @@ it.each([false, true])( } const pending = f.close() await f.entered.promise - f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) + await f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) expect(f.store.getWorkspaceSession().terminalLayoutsByTabId[ACK_TAB].ptyIdsByLeafId).toEqual({ [ACK_SECOND_LEAF]: 'pty-b' }) @@ -103,7 +103,7 @@ it('protects a persisted incarnation replacement on the same leaf and raw PTY ID const f = fixture(true) const pending = f.close() await f.entered.promise - f.store.persistPtyBinding({ + await f.store.persistPtyBinding({ worktreeId: ACK_WORKTREE, tabId: ACK_TAB, leafId: ACK_LEAF, @@ -139,7 +139,7 @@ it('preserves dormant SSH kill IDs when the acknowledged tab becomes headless', const f = fixture() const visible = 'ssh:target@@visible' const dormant = 'ssh:target@@persisted-only' - f.store.persistPtyBinding({ + await f.store.persistPtyBinding({ worktreeId: ACK_WORKTREE, tabId: ACK_TAB, leafId: ACK_LEAF, diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 6c2555e9c762..f1cb437725fd 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -349,6 +349,7 @@ describe('a structured Claude session over agentSession.*', () => { }) it('leaves unlisted shell exports out when inheritance is off', async () => { + vi.stubEnv('CODEX_LB_API_KEY', undefined) shellEnv = { ...shellEnv, CODEX_LB_API_KEY: 'shell-exported', LISTED_ONLY: 'yes' } shellEnvironmentPolicy = { inheritAll: false, names: ['LISTED_ONLY'] } diff --git a/src/main/runtime/folder-workspace-pty-identity.test.ts b/src/main/runtime/folder-workspace-pty-identity.test.ts index db14f74bad80..43eb15f66b32 100644 --- a/src/main/runtime/folder-workspace-pty-identity.test.ts +++ b/src/main/runtime/folder-workspace-pty-identity.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' @@ -66,7 +67,8 @@ function createRuntimeInternals( [WORKSPACE_A]: { hostId: 'local' }, [WORKSPACE_B]: { hostId: 'local' } } - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [REPO], getRepo: (id: string) => (id === REPO_ID ? REPO : undefined), getAllWorktreeMeta: () => meta, @@ -78,7 +80,7 @@ function createRuntimeInternals( getWorkspaceSession: () => options.session ?? getDefaultWorkspaceSession(), setWorkspaceSession: () => {}, flushOrThrow: () => {} - } as never + }) as never const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ write: () => true, diff --git a/src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts b/src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts index e91f18e8c6d8..a824191bb90a 100644 --- a/src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts +++ b/src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' /** * shouldPreserveHeadlessMobileSessionTab excludes the daemon ptyId form * @@ from its runtime-owned checks, so a host-created terminal @@ -51,7 +52,7 @@ function createHarness() { // Starts empty: only the create path may put this terminal in the session. let session: WorkspaceSessionState = { ...getDefaultWorkspaceSession() } const repo = makeRepo() - const store = { + const store = withDurableRuntimeStore({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getAllWorktreeMeta: () => ({}), @@ -63,7 +64,7 @@ function createHarness() { session = next }, flushOrThrow: () => {} - } + }) const runtime = new OrcaRuntimeService(store as never) runtime.setNotifier({ closeTerminal: vi.fn(), diff --git a/src/main/runtime/headless-close-keeps-publication-epoch.test.ts b/src/main/runtime/headless-close-keeps-publication-epoch.test.ts index b33579b783c1..1a0344f393b7 100644 --- a/src/main/runtime/headless-close-keeps-publication-epoch.test.ts +++ b/src/main/runtime/headless-close-keeps-publication-epoch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state' import { OrcaRuntimeService } from './orca-runtime' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { @@ -25,7 +26,7 @@ function makeStore() { return { getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn(), - flushOrThrow: vi.fn(), + runDurableMutation: async (mutate: () => DurableProfileStateMutation) => mutate().value, getRepos: vi.fn(() => [ { id: 'repo-1', @@ -68,7 +69,7 @@ function storedSnapshot(tabs: RuntimeMobileSessionTerminalTab[]): RuntimeMobileS } } -function closeOneTab(): RuntimeMobileSessionTabsSnapshot { +async function closeOneTab(): Promise { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: makeStore covers the reads this suite drives. const runtime = new OrcaRuntimeService(makeStore() as never) const closedTab = terminalTab('tab-a', LEAF_ID) @@ -80,11 +81,11 @@ function closeOneTab(): RuntimeMobileSessionTabsSnapshot { snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab, options?: Record - ) => void + ) => Promise mobileSessionTabsByWorktree: Map } internals.mobileSessionTabsByWorktree.set(WORKTREE_ID, snapshot) - internals.closeHeadlessMobileTerminalTab(WORKTREE_ID, snapshot, closedTab, { + await internals.closeHeadlessMobileTerminalTab(WORKTREE_ID, snapshot, closedTab, { allowMissingPersistedTab: true, killPtys: false }) @@ -96,12 +97,12 @@ function closeOneTab(): RuntimeMobileSessionTabsSnapshot { } describe('closing a headless mobile terminal tab', () => { - it('keeps the worktree under the epoch that was already publishing it', () => { - expect(closeOneTab().publicationEpoch).toBe(LIVE_EPOCH) + it('keeps the worktree under the epoch that was already publishing it', async () => { + expect((await closeOneTab()).publicationEpoch).toBe(LIVE_EPOCH) }) - it('still advances the version so clients accept the frame', () => { - const published = closeOneTab() + it('still advances the version so clients accept the frame', async () => { + const published = await closeOneTab() expect(published.snapshotVersion).toBe(5) expect(published.tabs.map((tab) => tab.id)).toEqual([`tab-b::${LEAF_ID}`]) }) diff --git a/src/main/runtime/host-terminal-close-persistence-durability.test.ts b/src/main/runtime/host-terminal-close-persistence-durability.test.ts index 66a34f16ea6d..642edd6cf17b 100644 --- a/src/main/runtime/host-terminal-close-persistence-durability.test.ts +++ b/src/main/runtime/host-terminal-close-persistence-durability.test.ts @@ -62,7 +62,7 @@ describe('host-created terminal close durability', () => { it('a host-created terminal does NOT push a fresh repo into host-authoritative membership', async () => { const store = await makeStore() - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) const session = store.getWorkspaceSession() expect(session.tabsByWorktree?.[WT]?.map((t) => t.id)).toContain(TAB) // advanceTopologyFence (store.ts:3284) deliberately declines to arm the @@ -73,14 +73,14 @@ describe('host-created terminal close durability', () => { it('a renderer close write durably removes the row it persisted', async () => { const store = await makeStore() - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) expect(store.getWorkspaceSession().tabsByWorktree?.[WT] ?? []).toEqual([]) }) it('stays removed with the PTY still connected and no exit ever delivered', async () => { const store = await makeStore() - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) // Kill-failure shape: no retirement, no exit, just more renderer writes. for (let i = 0; i < 3; i += 1) { @@ -96,7 +96,7 @@ describe('host-created terminal close durability', () => { ...store.getWorkspaceSession(), terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } }) - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) // Documents PRE-EXISTING behavior: with the fence already armed, a renderer // write that omits a row is treated as a stale replay and the row survives @@ -125,7 +125,7 @@ describe('topology fence census', () => { { startupCwd: '/tmp/wt-cli' } ].entries()) { const store = await makeStore() - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WT, tabId: `${TAB}-${index}`, leafId: LEAF, @@ -138,8 +138,8 @@ describe('topology fence census', () => { it('a second pane in the same tab still does not arm the fence', async () => { const store = await makeStore() - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: '22222222-2222-4222-8222-222222222222', @@ -154,7 +154,7 @@ describe('topology fence census', () => { ...store.getWorkspaceSession(), terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } }) - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) expect( store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0 ).toBeGreaterThan(1) diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 5aa6e46cc589..b17b536191f6 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -11,7 +11,9 @@ import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-sessi import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' import type { PtyControllerInventory } from './runtime-pty-controller-contract' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { captureAcknowledgedTerminalTabRetirement } from './workspace-session-terminal-tab-retirement-identity' +import { cloneWorkspaceSessionState } from '../persistence/restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithPersistTerminalSurfaceRetirements { // Why: headless serve backs browser panes with offscreen WebContents that live @@ -78,44 +80,74 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu return tab ? { color: tab.color, isPinned: tab.isPinned } : null } - protected commitHeadlessTerminalTabRetirement( + protected captureTerminalTabRetirement(worktreeId: string, tabId: string) { + const originalHostId = this.getWorkspaceSessionHostIdForWorktree(worktreeId) + return captureAcknowledgedTerminalTabRetirement(worktreeId, tabId, () => { + const resolvedHostId = this.getWorkspaceSessionHostIdForWorktree(worktreeId) + const resolvedSession = this.store?.getWorkspaceSession?.(resolvedHostId) + // Emptying the last tab may reroute the worktree to its catalog host. + const hostId = resolvedSession?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) + ? resolvedHostId + : originalHostId + return { + hostId, + session: this.store?.getWorkspaceSession?.(hostId) ?? null, + snapshot: this.mobileSessionTabsByWorktree.get(worktreeId), + incarnationOf: (ptyId) => this.ptysById.get(ptyId)?.incarnationId + } + }) + } + + protected async commitHeadlessTerminalTabRetirement( worktreeId: string, parentTabId: string, options: { allowMissing?: boolean; force?: boolean } = {} - ): string[] { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session || !this.store?.setWorkspaceSession || !this.store.flushOrThrow) { + ): Promise { + if (!this.store?.setWorkspaceSession || !this.store.runDurableMutation) { throw new Error('workspace_session_unavailable') } - const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId, { - force: options.force - }) - if (result.pinned) { - throw new Error('terminal_tab_pinned') - } - if (!result.closed) { - if (!options.allowMissing) { + const acknowledgeRetirement = this.captureTerminalTabRetirement(worktreeId, parentTabId) + return this.store.runDurableMutation(() => { + if (!acknowledgeRetirement().matches) { + throw new Error('terminal_pane_owner_changed') + } + const hostId = this.getWorkspaceSessionHostIdForWorktree(worktreeId) + const currentSession = this.store.getWorkspaceSession(hostId) + if (!currentSession) { + throw new Error('workspace_session_unavailable') + } + const session = cloneWorkspaceSessionState(currentSession) + const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId, { + force: options.force + }) + if (result.pinned) { + throw new Error('terminal_tab_pinned') + } + if (!result.closed && !options.allowMissing) { throw new Error('tab_not_found') } - } - const persisted = result.closed - ? advanceTerminalTopologyRevision(result.session, worktreeId) - : session - this.setWorkspaceSessionForWorktree(worktreeId, persisted) - const staged = this.getWorkspaceSessionForWorktree(worktreeId) - try { - this.store.flushOrThrow() - } catch (error) { - const current = this.getWorkspaceSessionForWorktree(worktreeId) - if (staged && current) { - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) - if (rolledBack !== current) { - this.setWorkspaceSessionForWorktree(worktreeId, rolledBack) + const persisted = result.closed + ? advanceTerminalTopologyRevision(result.session, worktreeId) + : session + this.store.setWorkspaceSession(persisted, hostId) + const staged = cloneWorkspaceSessionState(this.store.getWorkspaceSession(hostId)) + return { + value: result.ptyIdsToKill, + rollback: () => { + const current = this.store.getWorkspaceSession(hostId) + if (current) { + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( + session, + staged, + current + ) + if (rolledBack !== current) { + this.store.setWorkspaceSession(rolledBack, hostId) + } + } } } - throw error - } - return result.ptyIdsToKill + }) } protected persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { diff --git a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts index b326d00ec6c3..e3d796381b01 100644 --- a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts +++ b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts @@ -13,7 +13,7 @@ import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWithCloseStructuredAgentSessionTab { - protected closeHeadlessMobileTerminalTab( + protected async closeHeadlessMobileTerminalTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab, @@ -23,7 +23,7 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi authorizedPty?: RuntimePtyWorktreeRecord force?: boolean } = {} - ): void { + ): Promise { const closedParentTabId = tab.parentTabId const retirementProofs = snapshot.tabs.flatMap((candidate) => { if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { @@ -36,11 +36,17 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi ) return proof ? [proof] : [] }) - const projectedPtyIds = this.commitHeadlessTerminalTabRetirement( + const acknowledgeRetirement = this.captureTerminalTabRetirement(worktreeId, closedParentTabId) + const projectedPtyIds = await this.commitHeadlessTerminalTabRetirement( worktreeId, closedParentTabId, { allowMissing: options.allowMissingPersistedTab, force: options.force } ) + if (!acknowledgeRetirement().matches) { + throw new Error('terminal_pane_owner_changed') + } + // Renderer frames may add other tabs while the durable close is in flight. + snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) ?? snapshot this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId) if (options.authorizedPty) { options.authorizedPty.runtimeSessionOwned = false diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index 2ef5b77069d6..fa624d6f3ce3 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -20,7 +20,6 @@ import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close' -import { captureAcknowledgedTerminalTabRetirement } from './workspace-session-terminal-tab-retirement-identity' import { rendererPublicationThrottle } from '../window/renderer-publication-throttle' export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose { @@ -167,7 +166,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU // the relay when no renderer owns the parent: an adopted tab needs the // renderer's live pin guard and durable close transaction. if (closingWholeParent && !this.tabs.has(tab.parentTabId)) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { + await this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { allowMissingPersistedTab: Boolean(ptyCloseAuthority), force: options.force, killPtys: @@ -180,16 +179,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU } if (closingWholeParent && this.notifier?.closeTerminalTab) { // The renderer flush can rebase its omission; the host commits the acknowledged identity. - const acknowledgeRetirement = captureAcknowledgedTerminalTabRetirement( - worktreeId, - tab.parentTabId, - () => ({ - hostId: this.getWorkspaceSessionHostIdForWorktree(worktreeId), - session: this.getWorkspaceSessionForWorktree(worktreeId), - snapshot: this.mobileSessionTabsByWorktree.get(worktreeId), - incarnationOf: (ptyId) => this.ptysById.get(ptyId)?.incarnationId - }) - ) + const acknowledgeRetirement = this.captureTerminalTabRetirement(worktreeId, tab.parentTabId) // Wait for the renderer's pin guard, retirement and forced session flush. const win = this.getAvailableAuthoritativeWindow() if (win?.webContents.isDestroyed?.()) { @@ -230,7 +220,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU ? this.resolvePtyTabCloseSurfaceAuthority(options.expectedPtyCloseAuthority) : null // Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface. - this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, { + await this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, { // Why: the renderer may already have durably removed the tab before acknowledging. allowMissingPersistedTab: true, force: options.force, @@ -238,17 +228,21 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU }) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) } else if (retirement.hasPersistedTab) { - this.commitHeadlessTerminalTabRetirement(worktreeId, tab.parentTabId, { + await this.commitHeadlessTerminalTabRetirement(worktreeId, tab.parentTabId, { force: options.force }) } + if (!acknowledgeRetirement().matches) { + this.republishMobileSessionTabsSnapshot(worktreeId) + return refusedMobileSessionTabClose('stale-terminal', { snapshotRepublished: true }) + } this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId) return finishCommittedClose() } // Why: notifier implementations without the acknowledged relay may expose // only raw pane close. Runtime-owned parents still need de-persist + kill. if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { + await this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { force: options.force, ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) }) @@ -256,7 +250,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU return finishCommittedClose() } if (!this.notifier?.closeTerminal) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { + await this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { force: options.force, ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) }) diff --git a/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts b/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts index 5780b3539444..4260c8213efd 100644 --- a/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts +++ b/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' /** * STA-4593 incident: closing paired-remote tabs "worked briefly" but the tabs * returned seconds later and after workspace switches, on a host whose PTYs @@ -103,7 +104,7 @@ function createHarness() { badgeColor: '#000000', addedAt: 1 } - const store = { + const store = withDurableRuntimeStore({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getAllWorktreeMeta: () => ({}), @@ -115,7 +116,7 @@ function createHarness() { session = next }, flushOrThrow: () => {} - } + }) const relayAck = makeDeferred() const closeTerminal = vi.fn() const closeTerminalTab = vi.fn(() => relayAck.promise) @@ -277,7 +278,7 @@ function createSplitHarness() { badgeColor: '#000000', addedAt: 1 } - const store = { + const store = withDurableRuntimeStore({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getAllWorktreeMeta: () => ({}), @@ -289,7 +290,7 @@ function createSplitHarness() { session = next }, flushOrThrow: () => {} - } + }) const runtime = new OrcaRuntimeService(store as never) runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab: vi.fn(async () => {}) } as never) runtime.setPtyController({ diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 588a834d6e64..b457f76a1384 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -24,7 +24,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte * as -1, so the numeric code alone cannot tell a dead process from a failed stop. */ providerExitObserved?: boolean } = {} - ): void { + ): void | Promise { const pty = this.ptysById.get(ptyId) if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) { return @@ -216,13 +216,20 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.resolvePtyExitWaiters(pty, ptyId) this.pruneDisconnectedPtyTranscript(pty) } + let retirement: Promise | undefined if (preservesIntentionalHandlelessSurface || preservesAbnormalSshSurface) { // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) } else { // Why: permanent process exit is absence, not a starting/sleeping tab. // Retire before publishing so paired clients never persist a ghost. - this.retireMobileSessionSurfacesForPty(ptyId, incarnationId, exactSurfaces) + retirement = this.retireMobileSessionSurfacesForPty( + ptyId, + incarnationId, + exactSurfaces + ).catch((error) => { + console.error('[runtime] failed to publish terminal retirement:', error) + }) } const exitedSurfaces: { handle: string; paneKey: string | null }[] = [] @@ -253,6 +260,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte } } this.pruneDisconnectedPtyRecords() + return retirement } private notifyPtyExitListeners(ptyId: string): void { diff --git a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts index 6b9887d2cf85..d90aa1b33441 100644 --- a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts +++ b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts @@ -8,7 +8,8 @@ import type { WorkspaceSessionState } from '../../shared/workspace-session-state import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement' import { attachRetirementProofsToSnapshot } from './mobile-session-terminal-retirement-proof' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { cloneWorkspaceSessionState } from '../persistence/restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntimeWithTouchMobileSessionTabsForWorktree { @@ -18,92 +19,77 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim * against the local partition strands the real ghost and bumps a foreign host's epoch. * Returns null when nothing may be published because persistence is unavailable or failed. */ - protected persistTerminalSurfaceRetirements( + protected async persistTerminalSurfaceRetirements( retiredSurfaces: readonly RetiredTerminalSurface[] - ): { accepted: RetiredTerminalSurface[]; unpersisted: RetiredTerminalSurface[] } | null { - const surfacesByHostId = new Map() - for (const surface of retiredSurfaces) { - const hostId = - this.tryGetWorkspaceSessionHostIdForWorktree(surface.worktreeId) ?? LOCAL_EXECUTION_HOST_ID - const bucket = surfacesByHostId.get(hostId) - if (bucket) { - bucket.push(surface) - } else { - surfacesByHostId.set(hostId, [surface]) - } - } - const accepted: RetiredTerminalSurface[] = [] - const unpersisted: RetiredTerminalSurface[] = [] - const pendingWrites: { hostId: ExecutionHostId; session: WorkspaceSessionState }[] = [] - const originalSessions = new Map() - const stagedSessions = new Map() - for (const [hostId, surfaces] of surfacesByHostId) { - const session = this.store?.getWorkspaceSession?.(hostId) - if (!session) { - unpersisted.push(...surfaces) - continue - } - // Why: publishing absence before its host membership fence is durable lets a crash or - // stale renderer write resurrect the retired surface. - if (!this.store?.setWorkspaceSession || !this.store.flushOrThrow) { - return null - } - originalSessions.set(hostId, session) - let nextSession = session - const acceptedForHost: RetiredTerminalSurface[] = [] - for (const surface of surfaces) { - const candidate = retireTerminalSurfaceFromPersistence(nextSession, surface) - if (candidate !== nextSession) { - acceptedForHost.push(surface) - nextSession = candidate - } - } - if (acceptedForHost.length === 0) { - continue - } - accepted.push(...acceptedForHost) - pendingWrites.push({ hostId, session: nextSession }) + ): Promise<{ accepted: RetiredTerminalSurface[]; unpersisted: RetiredTerminalSurface[] } | null> { + if (!this.store?.runDurableMutation) { + const hasPersistedSession = retiredSurfaces.some((surface) => + this.store?.getWorkspaceSession?.( + this.tryGetWorkspaceSessionHostIdForWorktree(surface.worktreeId) ?? + LOCAL_EXECUTION_HOST_ID + ) + ) + return hasPersistedSession ? null : { accepted: [], unpersisted: [...retiredSurfaces] } } - if (pendingWrites.length > 0) { - try { - for (const write of pendingWrites) { - this.store?.setWorkspaceSession?.(write.session, write.hostId) - const staged = this.store?.getWorkspaceSession?.(write.hostId) - if (staged) { - stagedSessions.set(write.hostId, staged) - } - } - this.store?.flushOrThrow?.() - } catch (error) { - // setWorkspaceSession mutates the in-memory partition before the flush. Restore only - // fields still equal to our staged write so concurrent renderer updates survive. - for (const [hostId, original] of originalSessions) { - const staged = stagedSessions.get(hostId) - const current = this.store?.getWorkspaceSession?.(hostId) - if (!staged || !current) { + try { + return await this.store.runDurableMutation(() => { + const accepted: RetiredTerminalSurface[] = [] + const unpersisted: RetiredTerminalSurface[] = [] + const originals = new Map() + const staged = new Map() + for (const surface of retiredSurfaces) { + const hostId = + this.tryGetWorkspaceSessionHostIdForWorktree(surface.worktreeId) ?? + LOCAL_EXECUTION_HOST_ID + const current = this.store.getWorkspaceSession?.(hostId) + if (!current) { + unpersisted.push(surface) continue } - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( - original, - staged, - current - ) - if (rolledBack !== current) { - this.store?.setWorkspaceSession?.(rolledBack, hostId) + if (!this.store.setWorkspaceSession) { + throw new Error('workspace_session_unavailable') + } + if (!originals.has(hostId)) { + originals.set(hostId, cloneWorkspaceSessionState(current)) + } + const next = retireTerminalSurfaceFromPersistence(current, surface) + if (next !== current) { + this.store.setWorkspaceSession(next, hostId) + staged.set(hostId, cloneWorkspaceSessionState(this.store.getWorkspaceSession(hostId))) + accepted.push(surface) } } - console.error('[runtime] failed to persist terminal retirement:', error) - return null - } + return { + value: { accepted, unpersisted }, + persist: staged.size > 0, + rollback: () => { + for (const [hostId, stagedSession] of staged) { + const current = this.store.getWorkspaceSession(hostId) + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( + originals.get(hostId), + stagedSession, + current + ) + if (rolledBack !== current) { + this.store.setWorkspaceSession(rolledBack, hostId) + } + } + } + } + }) + } catch (error) { + console.error('[runtime] failed to persist terminal retirement:', error) + return null } - return { accepted, unpersisted } } - protected retireMobileSessionSurfacesForPty( + protected async retireMobileSessionSurfacesForPty( ptyId: string, incarnationId: string, exactSurfaces: readonly Pick[] - ): void { + ): Promise { + // Physical cleanup already removed this generation; retirement must not recreate it. + const exitGeneration = this.ptyLifecycleGenerationById.get(ptyId) const terminalHandle = this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId) ?? undefined const retiredSurfaceByKey = new Map() @@ -135,8 +121,13 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim if (retiredSurfaces.length === 0) { return } - const persisted = this.persistTerminalSurfaceRetirements(retiredSurfaces) - if (!persisted) { + const persisted = await this.persistTerminalSurfaceRetirements(retiredSurfaces) + const currentIncarnation = this.ptysById.get(ptyId)?.incarnationId + if ( + !persisted || + this.ptyLifecycleGenerationById.get(ptyId) !== exitGeneration || + (currentIncarnation && currentIncarnation !== incarnationId) + ) { return } for (const surface of persisted.unpersisted) { diff --git a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts index 768448e2ac99..422d2d893d73 100644 --- a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts @@ -11,7 +11,8 @@ import type { } from '../../shared/runtime-types' import type { WorktreeTerminalMutationKind } from './worktree-terminal-mutation-lock' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { cloneWorkspaceSessionState } from '../persistence/restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' import { getWorktreeExecutionHostId, parseExecutionHostId, @@ -88,7 +89,7 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso } closed += 1 } - this.clearWorktreeTerminalResumeRecords(worktree.id, sessionHostId, parentTabIds) + await this.clearWorktreeTerminalResumeRecords(worktree.id, sessionHostId, parentTabIds) const { stopped } = await this.stopTerminalsForWorktree(`id:${worktree.id}`, { resolvedWorktreeId: worktree.id, ...hostFence @@ -109,61 +110,63 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso }) } - private clearWorktreeTerminalResumeRecords( + private async clearWorktreeTerminalResumeRecords( worktreeId: string, hostId: ExecutionHostId, closedTabIds: readonly string[] - ): void { + ): Promise { if ( !this.store?.getWorkspaceSession || !this.store.setWorkspaceSession || - !this.store.flushOrThrow + !this.store.runDurableMutation ) { throw new Error('workspace_session_unavailable') } - const session = this.store.getWorkspaceSession(hostId) - const sleepingAgentSessionsByPaneKey = Object.fromEntries( - Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter( - ([, record]) => record.worktreeId !== worktreeId + await this.store.runDurableMutation(() => { + const session = cloneWorkspaceSessionState(this.store.getWorkspaceSession(hostId)) + const sleepingAgentSessionsByPaneKey = Object.fromEntries( + Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter( + ([, record]) => record.worktreeId !== worktreeId + ) ) - ) - const terminalPtyIncarnationsByPaneKey = Object.fromEntries( - Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter( - ([paneKey]) => !closedTabIds.some((tabId) => paneKey.startsWith(`${tabId}:`)) + const terminalPtyIncarnationsByPaneKey = Object.fromEntries( + Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter( + ([paneKey]) => !closedTabIds.some((tabId) => paneKey.startsWith(`${tabId}:`)) + ) ) - ) - const remainingTerminalRows = session.tabsByWorktree[worktreeId] ?? [] - const remainingUnifiedTerminalTabs = (session.unifiedTabs?.[worktreeId] ?? []).filter( - (tab) => tab.contentType === 'terminal' - ) - if (remainingTerminalRows.length > 0 || remainingUnifiedTerminalTabs.length > 0) { - throw new Error('terminal_close_incomplete') - } - const hasChanges = - Object.keys(sleepingAgentSessionsByPaneKey).length !== - Object.keys(session.sleepingAgentSessionsByPaneKey ?? {}).length || - Object.keys(terminalPtyIncarnationsByPaneKey).length !== - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length - if (!hasChanges) { - return - } - const next: WorkspaceSessionState = { - ...session, - sleepingAgentSessionsByPaneKey, - terminalPtyIncarnationsByPaneKey - } - this.store.setWorkspaceSession(next, hostId) - const staged = this.store.getWorkspaceSession(hostId) - try { - this.store.flushOrThrow() - } catch (error) { - const current = this.store.getWorkspaceSession(hostId) - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) - if (rolledBack !== current) { - this.store.setWorkspaceSession(rolledBack, hostId) + const remainingTerminalRows = session.tabsByWorktree[worktreeId] ?? [] + const remainingUnifiedTerminalTabs = (session.unifiedTabs?.[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'terminal' + ) + if (remainingTerminalRows.length > 0 || remainingUnifiedTerminalTabs.length > 0) { + throw new Error('terminal_close_incomplete') } - throw error - } + const hasChanges = + Object.keys(sleepingAgentSessionsByPaneKey).length !== + Object.keys(session.sleepingAgentSessionsByPaneKey ?? {}).length || + Object.keys(terminalPtyIncarnationsByPaneKey).length !== + Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length + if (!hasChanges) { + return { value: undefined, persist: false } + } + const next: WorkspaceSessionState = { + ...session, + sleepingAgentSessionsByPaneKey, + terminalPtyIncarnationsByPaneKey + } + this.store.setWorkspaceSession(next, hostId) + const staged = cloneWorkspaceSessionState(this.store.getWorkspaceSession(hostId)) + return { + value: undefined, + rollback: () => { + const current = this.store.getWorkspaceSession(hostId) + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) + if (rolledBack !== current) { + this.store.setWorkspaceSession(rolledBack, hostId) + } + } + } + }) } async stopTerminalsForWorktree( diff --git a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts index 3079ce1f4dae..063866c83c5a 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' @@ -123,7 +124,8 @@ function partitionedStore(): PartitionedStoreHarness { ]) const writes: { hostId: ExecutionHostId | undefined; session: WorkspaceSessionState }[] = [] const reads: (ExecutionHostId | undefined)[] = [] - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [SSH_REPO], getRepo: (id: string) => (id === SSH_REPO_ID ? SSH_REPO : undefined), getWorkspaceSessionHostIds: () => [...sessions.keys()], @@ -136,7 +138,7 @@ function partitionedStore(): PartitionedStoreHarness { sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session) }, flushOrThrow: vi.fn() - } as never + }) as never return { store, sessions, writes, reads } } @@ -210,7 +212,8 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', } ] ]) - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [{ ...SSH_REPO, executionHostId: staleHostId }], getRepo: () => ({ ...SSH_REPO, executionHostId: staleHostId }), getWorktreeMeta: () => undefined, @@ -221,7 +224,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', setWorkspaceSession: (session: WorkspaceSessionState, hostId?: ExecutionHostId) => sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session), flushOrThrow: vi.fn() - } as never + }) as never const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ write: () => true, @@ -285,7 +288,8 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', // The SSH copy of the same `repoId::path` currently has no terminals. [SSH_HOST_ID, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }] ]) - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [SSH_REPO], getRepo: (id: string) => (id === SSH_REPO_ID ? SSH_REPO : undefined), getWorktreeMeta: () => ({ hostId: SSH_HOST_ID }), @@ -298,7 +302,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session), flushOrThrow: vi.fn(), persistPtyBinding: vi.fn() - } as never + }) as never const runtime = new OrcaRuntimeService(store) const stopAndWait = vi.fn(async () => true) runtime.setPtyController({ @@ -334,7 +338,8 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', ], [staleHostId, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }] ]) - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [{ ...SSH_REPO, executionHostId: staleHostId }], getRepo: () => ({ ...SSH_REPO, executionHostId: staleHostId }), getWorktreeMeta: () => ({}), @@ -347,7 +352,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session), flushOrThrow: vi.fn(), persistPtyBinding: vi.fn() - } as never + }) as never const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ write: () => true, @@ -491,7 +496,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', incarnationId: 'incarnation-a' }) - runtime.onPtyExit(SSH_PTY_LEFT, 0, 'incarnation-a') + await runtime.onPtyExit(SSH_PTY_LEFT, 0, 'incarnation-a') // The durable retirement must land in the pane's own host partition. expect(harness.writes.map((write) => write.hostId)).toEqual([SSH_HOST_ID]) @@ -624,7 +629,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', incarnationId: 'incarnation-a' }) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect(harness.writes.map((write) => write.hostId ?? LOCAL_EXECUTION_HOST_ID)).toEqual([ LOCAL_EXECUTION_HOST_ID diff --git a/src/main/runtime/orca-runtime-terminal-retirement.test.ts b/src/main/runtime/orca-runtime-terminal-retirement.test.ts index e1a2c68242cd..4422dc2a1e64 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -148,7 +149,7 @@ function makePersistedSplitSession(): WorkspaceSessionState { } describe('OrcaRuntimeService terminal surface retirement', () => { - it('releases each early-exit fence after its matching registration is rejected', () => { + it('releases each early-exit fence after its matching registration is rejected', async () => { const runtime = new OrcaRuntimeService() const internals = runtime as unknown as { earlyExitedPtyIncarnations: Map @@ -158,7 +159,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const ptyId = `pty-early-${index}` const incarnationId = `incarnation-${index}` runtime.beginPtyRegistration(ptyId, incarnationId) - runtime.onPtyExit(ptyId, 0, incarnationId) + await runtime.onPtyExit(ptyId, 0, incarnationId) expect(() => runtime.assertPtyRegistrationAllowed(ptyId, incarnationId)).toThrow( 'agent_session_exited_during_start' ) @@ -168,7 +169,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { expect(internals.earlyExitedPtyIncarnations.size).toBe(0) }) - it('does not retain fences for completed surface-less lifecycles', () => { + it('does not retain fences for completed surface-less lifecycles', async () => { const runtime = new OrcaRuntimeService() const internals = runtime as unknown as { earlyExitedPtyIncarnations: Map @@ -179,14 +180,14 @@ describe('OrcaRuntimeService terminal surface retirement', () => { runtime.onPtySpawned(`pty-headless-${index}`, `incarnation-${index}`, { awaitsRegistration: false }) - runtime.onPtyExit(`pty-headless-${index}`, 0, `incarnation-${index}`) + await runtime.onPtyExit(`pty-headless-${index}`, 0, `incarnation-${index}`) } expect(internals.earlyExitedPtyIncarnations.size).toBe(0) expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0) }) - it('fences an early-exited replacement even when its pane already exists', () => { + it('fences an early-exited replacement even when its pane already exists', async () => { const runtime = new OrcaRuntimeService() runtime.attachWindow(1) syncSplit(runtime) @@ -197,7 +198,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { }) runtime.onPtySpawned('pty-left', 'incarnation-replacement') - runtime.onPtyExit('pty-left', 0, 'incarnation-replacement') + await runtime.onPtyExit('pty-left', 0, 'incarnation-replacement') expect(() => runtime.assertPtyRegistrationAllowed('pty-left', 'incarnation-replacement') @@ -224,7 +225,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { ? leftBeforeExit.terminal : null - runtime.onPtyExit('pty-left', 0) + await runtime.onPtyExit('pty-left', 0) expect(await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).toMatchObject({ activeTabId: 'tab::right', @@ -467,16 +468,18 @@ describe('OrcaRuntimeService terminal surface retirement', () => { sleepingAgentSessionsByPaneKey: { 'tab:left': {} as never } } const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession: vi.fn(), - flushOrThrow: vi.fn() - }) + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession: vi.fn(), + flushOrThrow: vi.fn() + }) + ) ) runtime.attachWindow(1) syncSplit(runtime) - runtime.onPtyExit('pty-left', 0) + await runtime.onPtyExit('pty-left', 0) const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) expect(result.tabs.find((tab) => tab.id === 'tab::left')).toBeUndefined() @@ -506,7 +509,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { incarnationId: 'incarnation-b' }) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ expect.objectContaining({ id: 'tab::left', status: 'ready' }), @@ -519,11 +522,13 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const session = makePersistedSplitSession() const setWorkspaceSession = vi.fn() const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession, - flushOrThrow: vi.fn() - }) + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow: vi.fn() + }) + ) ) runtime.attachWindow(1) syncSplit(runtime) @@ -534,7 +539,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { }) runtime.acceptPtyIncarnationForExit('pty-left', 'incarnation-after-reconnect') - runtime.onPtyExit('pty-left', 0, 'incarnation-after-reconnect') + await runtime.onPtyExit('pty-left', 0, 'incarnation-after-reconnect') expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ expect.objectContaining({ id: 'tab::right', status: 'ready' }) @@ -567,11 +572,13 @@ describe('OrcaRuntimeService terminal surface retirement', () => { session = next }) const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession, - flushOrThrow: vi.fn() - }) + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow: vi.fn() + }) + ) ) runtime.attachWindow(1) const snapshot = makeSplitSnapshot() @@ -601,7 +608,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const published: RuntimeMobileSessionTabsResult[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((event) => published.push(event)) - runtime.onPtyExit('pty-shared', 0, 'incarnation-exiting') + await runtime.onPtyExit('pty-shared', 0, 'incarnation-exiting') expect(session.terminalLayoutsByTabId.tab).toMatchObject({ root: { type: 'leaf', leafId: 'right' }, @@ -626,16 +633,18 @@ describe('OrcaRuntimeService terminal surface retirement', () => { unsubscribe() }) - it('de-persists an exact surface even when there is no mobile snapshot', () => { + it('de-persists an exact surface even when there is no mobile snapshot', async () => { const session = makePersistedSplitSession() const setWorkspaceSession = vi.fn() const flushOrThrow = vi.fn() const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession, - flushOrThrow - }) + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow + }) + ) ) runtime.attachWindow(1) runtime.syncWindowGraph(1, { @@ -664,7 +673,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { incarnationId: 'incarnation-a' }) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect(setWorkspaceSession).toHaveBeenCalledWith( expect.objectContaining({ @@ -686,13 +695,15 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const session = makePersistedSplitSession() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession: vi.fn(), - flushOrThrow: vi.fn(() => { - throw new Error('disk unavailable') + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession: vi.fn(), + flushOrThrow: vi.fn(() => { + throw new Error('disk unavailable') + }) }) - }) + ) ) runtime.attachWindow(1) syncSplit(runtime) @@ -704,7 +715,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const events: unknown[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((event) => events.push(event)) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ expect.objectContaining({ id: 'tab::left' }), @@ -726,13 +737,15 @@ describe('OrcaRuntimeService terminal surface retirement', () => { session = next }) const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession, - flushOrThrow: vi.fn(() => { - throw new Error('disk unavailable') + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow: vi.fn(() => { + throw new Error('disk unavailable') + }) }) - }) + ) ) runtime.attachWindow(1) syncSplit(runtime) @@ -742,7 +755,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { incarnationId: 'incarnation-a' }) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect(session).toEqual(original) expect(setWorkspaceSession).toHaveBeenLastCalledWith(original, LOCAL_EXECUTION_HOST_ID) diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index c72bde039b9f..78a6089e3ed3 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { expect, vi } from 'vitest' import { createHash } from 'node:crypto' import { HeadlessEmulator } from '../daemon/headless-emulator' @@ -25,6 +26,7 @@ import type { import { InMemoryOrchestrationMessages } from './orca-runtime-test-orchestration-messages.spec' import type { OrchestrationDb } from './orchestration/db' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import type { Store } from '../persistence' type RuntimeService = InstanceType type HeadlessTerminal = InstanceType @@ -558,9 +560,9 @@ function makeRuntimeStoreWithWorkspaceSession( ): { runtimeStore: typeof store & { getWorkspaceSession: (hostId?: string) => WorkspaceSessionState - setWorkspaceSession: ReturnType - flushOrThrow: ReturnType - persistPtyBinding: ReturnType + setWorkspaceSession: ReturnType> + flushOrThrow: ReturnType void>> + persistPtyBinding: ReturnType> } getSession: () => WorkspaceSessionState setSession: (next: WorkspaceSessionState) => void @@ -569,7 +571,7 @@ function makeRuntimeStoreWithWorkspaceSession( const setSession = (next: WorkspaceSessionState): void => { session = next } - const runtimeStore = { + const runtimeStore = withDurableRuntimeStore({ ...store, getWorkspaceSession: (hostId?: string) => hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(), @@ -577,36 +579,38 @@ function makeRuntimeStoreWithWorkspaceSession( // Headless close is a durable transaction; keep the in-memory fixture's // persistence contract equivalent to the production store. flushOrThrow: vi.fn(), - persistPtyBinding: vi.fn( - (args: { worktreeId: string; tabId: string; leafId: string; ptyId: string }) => { - const tabs = session.tabsByWorktree[args.worktreeId] ?? [] - session = { - ...session, - tabsByWorktree: { - ...session.tabsByWorktree, - [args.worktreeId]: tabs.map((tab) => - tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab - ) - }, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - ...(session.terminalLayoutsByTabId[args.tabId] ?? { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null - }), - ptyIdsByLeafId: { - ...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } + persistPtyBinding: vi.fn(async (input) => { + const args = typeof input === 'function' ? input() : input + if (!args) { + return false + } + const tabs = session.tabsByWorktree[args.worktreeId] ?? [] + session = { + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [args.worktreeId]: tabs.map((tab) => + tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab + ) + }, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + ...(session.terminalLayoutsByTabId[args.tabId] ?? { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null + }), + ptyIdsByLeafId: { + ...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId, + [args.leafId]: args.ptyId } } } - return true } - ) - } + return true + }) + }) return { runtimeStore, getSession: () => session, setSession } } diff --git a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts index 1f87d901c817..63dc6d8c94e2 100644 --- a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts +++ b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import * as mocks from './orca-runtime-test-mocks.spec' import type { Mock } from 'vitest' @@ -211,7 +212,7 @@ function makePostRevealWorkerRecoveryHarness( } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-04.spec.ts index 6f5410e82a88..8b6806aec2b7 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-04.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -177,13 +178,15 @@ describe('OrcaRuntimeService', () => { const getWorkspaceSession = vi.fn((hostId?: string | null) => hostId === 'ssh:ssh-1' ? sshSession : localSession ) - const runtime = new OrcaRuntimeService({ - ...store, - flushOrThrow: vi.fn(), - getRepos: () => [remoteRepo], - getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), - getWorkspaceSession - } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...store, + flushOrThrow: vi.fn(), + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getWorkspaceSession + }) + ) runtime.setPtyController({ write: () => true, kill: () => true, @@ -229,15 +232,17 @@ describe('OrcaRuntimeService', () => { sshSession = session }) const kill = vi.fn(() => true) - const runtime = new OrcaRuntimeService({ - ...store, - getRepos: () => [remoteRepo], - getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), - getWorkspaceSession: (hostId?: string | null) => - hostId === 'ssh:ssh-1' ? sshSession : localSession, - setWorkspaceSession, - flushOrThrow: vi.fn() - } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getWorkspaceSession: (hostId?: string | null) => + hostId === 'ssh:ssh-1' ? sshSession : localSession, + setWorkspaceSession, + flushOrThrow: vi.fn() + }) + ) runtime.setPtyController({ write: () => true, kill, diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts index eb2af5c0a1b9..d2861c214003 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec' import type { RuntimeMobileSessionTabsResult } from '../orca-runtime-test-mocks.spec' @@ -181,7 +182,7 @@ describe('OrcaRuntimeService', () => { }) events.length = 0 - runtime.onPtyExit('laptop-created-pty', 0) + await runtime.onPtyExit('laptop-created-pty', 0) expect(events).toEqual([ expect.objectContaining({ @@ -374,7 +375,9 @@ describe('OrcaRuntimeService', () => { const acknowledged = makeDeferred() const closeTerminalTab = vi.fn(() => acknowledged.promise) const kill = vi.fn(() => true) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab } as never) runtime.setPtyController({ write: () => true, @@ -510,7 +513,9 @@ describe('OrcaRuntimeService', () => { .mockResolvedValueOnce({ id: 'headless-left' }) .mockResolvedValueOnce({ id: 'headless-right' }) const kill = vi.fn(() => true) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow }) + ) runtime.setPtyController({ spawn, write: () => true, diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts index 518d6805c5b5..4ac0faf4d586 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec' @@ -442,7 +443,9 @@ describe('OrcaRuntimeService', () => { }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const closeTerminalTab = vi.fn(async () => {}) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow }) + ) runtime.setPtyController({ write: () => true, kill, diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-11.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-11.spec.ts index 81d78d4e3088..88c988929f77 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-11.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-11.spec.ts @@ -409,7 +409,7 @@ describe('OrcaRuntimeService', () => { // republish would re-add the dead leaf on the echoing client and feed a // refuse→republish→re-echo loop. const { runtime, getSession, kill, closeTerminal } = makeSplitLeafRuntime() - runtime.onPtyExit('serve-right', 0) + await runtime.onPtyExit('serve-right', 0) const events: { worktree: string }[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts index 591f4e7ca977..2aba44299f2f 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts @@ -333,7 +333,7 @@ describe('OrcaRuntimeService', () => { await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) setSession(getDefaultWorkspaceSession()) - runtimeStore.persistPtyBinding.mockReturnValue(false) + runtimeStore.persistPtyBinding.mockResolvedValue(false) resolveSpawn({ id: 'rejected-split-pty' }) await expect(split).rejects.toThrow('terminal_split_source_not_found') diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts index e85aeb461c68..65757797f70f 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -20,7 +21,9 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) let process = { id: 'reused-pty-id', incarnationId: 'inc-old', @@ -66,7 +69,9 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.syncWindowGraph(1, { tabs: [ { @@ -156,7 +161,9 @@ describe('OrcaRuntimeService', () => { [`duplicate-b:${HEADLESS_SECOND_LEAF_ID}`]: 'inc-duplicate' } }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setPtyController({ write: () => true, kill: () => true, diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index ecf2ab53678e..1ebf15cbc24f 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -76,7 +77,9 @@ describe('OrcaRuntimeService', () => { ['pty-setup', 'inc-setup', 'term_setup', 'Setup'], ['pty-shell', 'inc-shell', 'term_shell', 'Shell'] ] as const - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) const listProcesses = vi.fn(async () => processes.map(([id, incarnationId, terminalHandle, title]) => ({ id, @@ -247,13 +250,15 @@ describe('OrcaRuntimeService', () => { const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session) const durableWrite = deferred() const durableWriteStarted = deferred() - const runtime = new OrcaRuntimeService({ - ...runtimeStore, - flushPendingOrThrowAsync: vi.fn(() => { - durableWriteStarted.resolve() - return durableWrite.promise + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...runtimeStore, + flushPendingOrThrowAsync: vi.fn(() => { + durableWriteStarted.resolve() + return durableWrite.promise + }) }) - } as never) + ) runtime.setPtyController({ write: vi.fn(() => true), kill: vi.fn(() => true), @@ -332,10 +337,12 @@ describe('OrcaRuntimeService', () => { wslDistro: null } ]) - const runtime = new OrcaRuntimeService({ - ...runtimeStore, - flushPendingOrThrowAsync - } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...runtimeStore, + flushPendingOrThrowAsync + }) + ) runtime.setPtyController({ write: vi.fn(() => true), kill: vi.fn(() => true), @@ -447,13 +454,17 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const flushOrThrow = vi.fn() - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never, undefined, { - canRecoverPersistentLocalPtys: () => true, - attestAgentHookCompatibilityAuthority: ({ paneKey, launchTokenHash }) => - paneKey === workerPaneKey && launchTokenHash === RESTORED_AUTHORITY_TOKEN_HASH - ? { paneKey, source: 'hydrated_commitment' } - : null - }) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow }), + undefined, + { + canRecoverPersistentLocalPtys: () => true, + attestAgentHookCompatibilityAuthority: ({ paneKey, launchTokenHash }) => + paneKey === workerPaneKey && launchTokenHash === RESTORED_AUTHORITY_TOKEN_HASH + ? { paneKey, source: 'hydrated_commitment' } + : null + } + ) runtime.setOrchestrationDb({ getActiveDispatchForTerminal: () => undefined, listLegacyWorkerTerminalRecoveryRows: () => [ diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts index 00a083108775..51edcad7373c 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, getDefaultWorkspaceSession } from '../orca-runtime-test-mocks.spec' import type { OrchestrationDb } from '../orchestration/db' @@ -40,7 +41,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -451,7 +452,7 @@ describe('OrcaRuntimeService', () => { return durableWrite.promise }) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushPendingOrThrowAsync } as never, + withDurableRuntimeStore({ ...runtimeStore, flushPendingOrThrowAsync }), undefined, { canRecoverPersistentLocalPtys: () => true } ) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts index ea70b730388f..6c4390893963 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -50,7 +51,7 @@ describe('OrcaRuntimeService', () => { throw new Error('synchronous persistence must not run') }) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow, flushPendingOrThrowAsync } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow, flushPendingOrThrowAsync }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -160,7 +161,7 @@ describe('OrcaRuntimeService', () => { return retryDurableWrite.promise }) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushPendingOrThrowAsync } as never, + withDurableRuntimeStore({ ...runtimeStore, flushPendingOrThrowAsync }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -272,7 +273,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -383,7 +384,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts index 389c70d4f42f..756f87611f62 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -74,7 +75,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -187,12 +188,12 @@ describe('OrcaRuntimeService', () => { const folderWorkspace = makeFolderWorkspace({ folderPath }) const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) const runtime = new OrcaRuntimeService( - { + withDurableRuntimeStore({ ...runtimeStore, getFolderWorkspaces: () => [folderWorkspace], getProjectGroups: () => [projectGroup], flushOrThrow: vi.fn() - } as never, + }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -313,14 +314,14 @@ describe('OrcaRuntimeService', () => { const folderWorkspace = makeFolderWorkspace({ folderPath, connectionId }) const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) const runtime = new OrcaRuntimeService( - { + withDurableRuntimeStore({ ...runtimeStore, getFolderWorkspaces: () => [folderWorkspace], getProjectGroups: () => [projectGroup], getWorkspaceSession, setWorkspaceSession, flushOrThrow: vi.fn() - } as never, + }), undefined, { canRecoverPersistentLocalPtys: () => true } ) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts index 1907b2bb4500..2100c708043a 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -83,14 +84,14 @@ describe('OrcaRuntimeService', () => { rows: 24 }) const runtime = new OrcaRuntimeService( - { + withDurableRuntimeStore({ ...runtimeStore, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), getWorkspaceSession, setWorkspaceSession, flushOrThrow: vi.fn() - } as never, + }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -220,7 +221,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { + withDurableRuntimeStore({ ...runtimeStore, getProjects: () => [ { @@ -228,17 +229,17 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', sourceRepoIds: [TEST_REPO_ID], - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + localWindowsRuntimePreference: { kind: 'wsl' as const, distro: 'Ubuntu' }, createdAt: 0, updatedAt: 0 } ], getSettings: () => ({ ...store.getSettings(), - localWindowsRuntimeDefault: { kind: 'windows-host' } + localWindowsRuntimeDefault: { kind: 'windows-host' as const } }), flushOrThrow: vi.fn() - } as never, + }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -353,7 +354,9 @@ describe('OrcaRuntimeService', () => { } } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setPtyController({ write: () => true, kill: () => true, @@ -524,7 +527,9 @@ describe('OrcaRuntimeService', () => { } } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setPtyController({ write: () => true, kill: () => true, diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts index 79bdf13909e1..8980dc53f854 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, getDefaultWorkspaceSession } from '../orca-runtime-test-mocks.spec' import { @@ -108,7 +109,9 @@ describe('OrcaRuntimeService', () => { terminalTopologyRevisionByRepoId: { [TEST_REPO_ID]: 7 } } const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setPtyController({ write: () => true, kill: () => true, @@ -148,7 +151,9 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) const writes: [string, string][] = [] runtime.setPtyController({ write: (ptyId: string, data: string) => { @@ -190,7 +195,9 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - return new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + return new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) } const ownerMismatch = makeRuntime() ownerMismatch.registerPty('pty-wrong-owner', TEST_WORKTREE_ID, 'ssh-other-host') @@ -282,25 +289,27 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - const wsl = new OrcaRuntimeService({ - ...runtimeStore, - flushOrThrow: vi.fn(), - getProjects: () => [ - { - id: 'project-wsl', - displayName: 'WSL', - badgeColor: 'blue', - sourceRepoIds: [TEST_REPO_ID], - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, - createdAt: 1, - updatedAt: 1 - } - ], - getSettings: () => ({ - ...store.getSettings(), - localWindowsRuntimeDefault: { kind: 'windows-host' } + const wsl = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...runtimeStore, + flushOrThrow: vi.fn(), + getProjects: () => [ + { + id: 'project-wsl', + displayName: 'WSL', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl' as const, distro: 'Ubuntu' }, + createdAt: 1, + updatedAt: 1 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' as const } + }) }) - } as never) + ) wsl.registerPty('pty-wsl', TEST_WORKTREE_ID, null, undefined, true) wsl.onPtySpawned('pty-wsl', 'inc-wsl', { awaitsRegistration: false }) wsl.setPtyController({ @@ -356,7 +365,9 @@ describe('OrcaRuntimeService', () => { tabsByWorktree: { [TEST_WORKTREE_ID]: [] } } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) const processes = [ ['pty-left', 'inc-left', 'term_left'], ['pty-right', 'inc-right', 'term_right'], diff --git a/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts b/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts index 640c54a753c6..35fb16d55ead 100644 --- a/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts +++ b/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { @@ -88,14 +89,17 @@ function createHost(): { } { let session = makePersistedSession() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the store stub carries the four members this publication-order suite drives; the rest of Store is unreached. - const runtime = new OrcaRuntimeService({ - getRepos: () => [LIVE_REPO], - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - session = next - }, - flushOrThrow: vi.fn() - } as never) + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + withDurableRuntimeStore({ + getRepos: () => [LIVE_REPO], + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + flushOrThrow: vi.fn() + }) as never + ) runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [ @@ -159,7 +163,7 @@ describe('retirement proof publication vs. renderer republication order', () => it('publishes the proof when the exit lands before the renderer drops the surface', async () => { const { runtime, handle } = createHost() - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') republishWithoutTheSurface(runtime) const published = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) @@ -174,7 +178,7 @@ describe('retirement proof publication vs. renderer republication order', () => retirePersistedSurface() republishWithoutTheSurface(runtime) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') const published = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) expect(published.tabs).toEqual([]) @@ -185,7 +189,7 @@ describe('retirement proof publication vs. renderer republication order', () => // Why a subscriber and not just the stored snapshot: a mirror only ever sees frames. A proof // that lands in state without a frame to carry it is the same silence from the client's side. - it('fans the proof out to a paired subscriber, not just into stored state', () => { + it('fans the proof out to a paired subscriber, not just into stored state', async () => { const { runtime, handle, retirePersistedSurface } = createHost() const frames: RuntimeMobileSessionTabsResult[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged( @@ -196,7 +200,7 @@ describe('retirement proof publication vs. renderer republication order', () => try { retirePersistedSurface() republishWithoutTheSurface(runtime) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') } finally { unsubscribe() } diff --git a/src/main/runtime/pty-inventory-partial-relay-liveness.test.ts b/src/main/runtime/pty-inventory-partial-relay-liveness.test.ts index 22dc70e42c01..cf5618ab2ff5 100644 --- a/src/main/runtime/pty-inventory-partial-relay-liveness.test.ts +++ b/src/main/runtime/pty-inventory-partial-relay-liveness.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import { makePaneKey } from '../../shared/stable-pane-id' @@ -50,7 +51,8 @@ function createRuntime(options: { sessions?: unknown[]; vouchesForRetainedPty?: calls: ListCall[] } { const meta: Record> = { [WORKSPACE]: { hostId: 'local' } } - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [REPO], getRepo: (id: string) => (id === REPO_ID ? REPO : undefined), getAllWorktreeMeta: () => meta, @@ -62,7 +64,7 @@ function createRuntime(options: { sessions?: unknown[]; vouchesForRetainedPty?: getWorkspaceSession: () => getDefaultWorkspaceSession(), setWorkspaceSession: () => {}, flushOrThrow: () => {} - } as never + }) as never const calls: ListCall[] = [] const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ diff --git a/src/main/runtime/runtime-durable-store-fixture.ts b/src/main/runtime/runtime-durable-store-fixture.ts new file mode 100644 index 000000000000..a5f2dc82793a --- /dev/null +++ b/src/main/runtime/runtime-durable-store-fixture.ts @@ -0,0 +1,40 @@ +import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state' +import { profileStateWriterFailureOutcome } from '../persistence/profile-state/profile-state-writer-errors' + +/** Keep runtime fakes on the same reserved, durable-before-ack contract as Store. */ +export function withDurableRuntimeStore< + T extends { + flushOrThrow?: () => void + flushPendingOrThrowAsync?: (options?: { drainToStableGeneration?: boolean }) => Promise + } +>(store: T) { + let pending = Promise.resolve() + return Object.assign(store, { + runDurableMutation(mutate: () => DurableProfileStateMutation): Promise { + const write = pending.then(async () => { + const mutation = mutate() + if (mutation.persist === false) { + return mutation.value + } + try { + if (store.flushPendingOrThrowAsync) { + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + } else { + store.flushOrThrow?.() + } + } catch (error) { + if (profileStateWriterFailureOutcome(error) !== 'indeterminate') { + mutation.rollback?.() + } + throw error + } + return mutation.value + }) + pending = write.then( + () => {}, + () => {} + ) + return write + } + }) +} diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts index df7945677372..345e2c08377a 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts @@ -12,7 +12,8 @@ import type { LegacyWorkerRecoveryResolution } from './runtime-legacy-worker-terminal-recovery-types' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { cloneWorkspaceSessionState } from '../persistence/restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' export class RuntimeLegacyWorkerTerminalRecoveryPersistence { constructor( @@ -29,66 +30,68 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { resolutions: readonly LegacyWorkerRecoveryResolution[] ): Promise> { const store = this.getStore() - if ( - !store?.getWorkspaceSession || - !store.setWorkspaceSession || - (!store.flushPendingOrThrowAsync && !store.flushOrThrow) - ) { + if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.runDurableMutation) { return new Set() } + const getWorkspaceSession = store.getWorkspaceSession.bind(store) + const setWorkspaceSession = store.setWorkspaceSession.bind(store) const originals = new Map() const staged = new Map() const dispatchIds = new Set() try { - for (const { candidate, resolution } of resolutions) { - const hostId = this.getHostId(candidate.worktreeId) - const session = hostId ? store.getWorkspaceSession(hostId) : null - if (!hostId || !session) { - continue + return await store.runDurableMutation(() => { + for (const { candidate, resolution } of resolutions) { + const hostId = this.getHostId(candidate.worktreeId) + const session = hostId ? getWorkspaceSession(hostId) : null + if (!hostId || !session) { + continue + } + originals.set(hostId, originals.get(hostId) ?? cloneWorkspaceSessionState(session)) + let next = + resolution === 'exited' + ? retireTerminalSurfaceFromPersistence(session, { + worktreeId: candidate.worktreeId, + parentTabId: candidate.tabId, + leafId: candidate.leafId, + ptyId: candidate.ptyId, + incarnationId: candidate.incarnationId + }) + : session + const record = next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] + if (record && runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { + const sleeping = { ...next.sleepingAgentSessionsByPaneKey } + delete sleeping[candidate.paneKey] + next = { ...next, sleepingAgentSessionsByPaneKey: sleeping } + } + if (next !== session) { + setWorkspaceSession(next, hostId) + } + staged.set(hostId, cloneWorkspaceSessionState(getWorkspaceSession(hostId))) + dispatchIds.add(candidate.dispatchId) } - originals.set(hostId, originals.get(hostId) ?? session) - let next = - resolution === 'exited' - ? retireTerminalSurfaceFromPersistence(session, { - worktreeId: candidate.worktreeId, - parentTabId: candidate.tabId, - leafId: candidate.leafId, - ptyId: candidate.ptyId, - incarnationId: candidate.incarnationId - }) - : session - const record = next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] - if (record && runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { - const sleeping = { ...next.sleepingAgentSessionsByPaneKey } - delete sleeping[candidate.paneKey] - next = { ...next, sleepingAgentSessionsByPaneKey: sleeping } + return { + value: dispatchIds, + persist: dispatchIds.size > 0, + rollback: () => { + for (const [hostId, original] of originals) { + const stagedSession = staged.get(hostId) + const current = getWorkspaceSession(hostId) + if (!stagedSession || !current) { + continue + } + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( + original, + stagedSession, + current + ) + if (rolledBack !== current) { + setWorkspaceSession(rolledBack, hostId) + } + } + } } - if (next !== session) { - store.setWorkspaceSession(next, hostId) - } - staged.set(hostId, store.getWorkspaceSession(hostId)) - dispatchIds.add(candidate.dispatchId) - } - if (dispatchIds.size > 0) { - await this.flush(store) - } - return dispatchIds + }) } catch (error) { - for (const [hostId, original] of originals) { - const stagedSession = staged.get(hostId) - const current = store.getWorkspaceSession(hostId) - if (!stagedSession || !current) { - continue - } - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( - original, - stagedSession, - current - ) - if (rolledBack !== current) { - store.setWorkspaceSession(rolledBack, hostId) - } - } console.warn('[orchestration] failed to persist legacy worker recovery batch', { dispatchIds: [...dispatchIds], error @@ -124,16 +127,4 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { return null } } - - private async flush(store: RuntimeStore): Promise { - if (store.flushPendingOrThrowAsync) { - await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - return - } - if (store.flushOrThrow) { - store.flushOrThrow() - return - } - throw new Error('workspace_session_persistence_unavailable') - } } diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 6c1436395f16..86af6d3e15f8 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -48,6 +48,7 @@ export type RuntimeStore = { getWorkspaceSession?: Store['getWorkspaceSession'] getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds'] setWorkspaceSession?: Store['setWorkspaceSession'] + runDurableMutation?: Store['runDurableMutation'] flushOrThrow?: Store['flushOrThrow'] flushPendingOrThrowAsync?: Store['flushPendingOrThrowAsync'] persistPtyBinding?: Store['persistPtyBinding'] diff --git a/src/main/runtime/runtime-terminal-orphan-adoption.ts b/src/main/runtime/runtime-terminal-orphan-adoption.ts index 59fbf76b857d..24df1cc1cbfb 100644 --- a/src/main/runtime/runtime-terminal-orphan-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-adoption.ts @@ -13,7 +13,7 @@ import { buildRuntimeTerminalOrphanSession } from './runtime-terminal-orphan-ses import { validateRuntimeTerminalOrphanTopology } from './runtime-terminal-orphan-topology-validation' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' type RuntimeTerminalOrphanAdoptionPorts = { getPty: (handle: string) => RuntimePtyWorktreeRecord | null diff --git a/src/main/runtime/terminal-retirement-async-durability.test.ts b/src/main/runtime/terminal-retirement-async-durability.test.ts new file mode 100644 index 000000000000..038832782d7b --- /dev/null +++ b/src/main/runtime/terminal-retirement-async-durability.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { + ACK_INCARNATION, + ACK_LEAF, + ACK_TAB, + createAcknowledgedTabRetirementFixture +} from './acknowledged-terminal-tab-retirement-fixture' + +const fixtures: ReturnType[] = [] +afterEach(async () => { + for (const fixture of fixtures.splice(0)) { + await fixture.dispose() + } +}) +function fixture() { + const result = createAcknowledgedTabRetirementFixture(true) + fixtures.push(result) + return result +} + +it('withholds the acknowledged close until its host retirement is durable', async () => { + const f = fixture() + let acknowledged = false + const closing = f.close().then((result) => { + acknowledged = true + return result + }) + await f.entered.promise + const gate = f.authority.pause() + f.acknowledgement.resolve() + await gate.started.promise + expect(acknowledged).toBe(false) + gate.finish.resolve() + await expect(closing).resolves.toEqual({ closed: true }) + expect(f.hasTab()).toBe(false) +}) + +it('publishes physical-exit retirement only after durability', async () => { + const f = fixture() + await f.store.flushPendingOrThrowAsync() + const published = vi.fn() + const unsubscribe = f.runtime.onMobileSessionTabsChanged(published) + const gate = f.authority.pause() + const exiting = f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) + await gate.started.promise + expect(published).not.toHaveBeenCalled() + gate.finish.resolve() + await exiting + expect(published).toHaveBeenCalled() + expect( + f.store.getWorkspaceSession().terminalLayoutsByTabId[ACK_TAB].ptyIdsByLeafId?.[ACK_LEAF] + ).toBeUndefined() + unsubscribe() +}) + +it('does not publish a delayed exit over a newly admitted incarnation', async () => { + const f = fixture() + await f.store.flushPendingOrThrowAsync() + const published = vi.fn() + const unsubscribe = f.runtime.onMobileSessionTabsChanged(published) + const gate = f.authority.pause() + const exiting = f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) + await gate.started.promise + f.runtime.onPtySpawned('pty-a', 'new-incarnation') + published.mockClear() + gate.finish.resolve() + await exiting + expect(published).not.toHaveBeenCalled() + unsubscribe() +}) diff --git a/src/main/runtime/workspace-session-failed-write-rollback.ts b/src/main/runtime/workspace-session-failed-write-rollback.ts deleted file mode 100644 index 7234446cb9de..000000000000 --- a/src/main/runtime/workspace-session-failed-write-rollback.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { isDeepStrictEqual } from 'node:util' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' - -const MISSING = Symbol('missing') - -/** A JSON-shaped slot of persisted session state, or the absent-key sentinel. */ -type RollbackSlot = - | string - | number - | boolean - | null - | undefined - | typeof MISSING - | readonly RollbackSlot[] - | RollbackRecord - -type RollbackRecord = { readonly [key: string]: RollbackSlot } - -function isRecord(value: RollbackSlot): value is RollbackRecord { - return ( - value !== MISSING && - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - Object.getPrototypeOf(value) === Object.prototype - ) -} - -function rollbackValue( - original: RollbackSlot, - staged: RollbackSlot, - current: RollbackSlot -): RollbackSlot { - if (isDeepStrictEqual(original, staged)) { - return current - } - if (isDeepStrictEqual(current, staged)) { - return original - } - if (!isRecord(original) || !isRecord(staged) || !isRecord(current)) { - return current - } - let changed = false - const next: Record = { ...current } - for (const key of new Set([ - ...Object.keys(original), - ...Object.keys(staged), - ...Object.keys(current) - ])) { - const value = rollbackValue( - Object.hasOwn(original, key) ? original[key] : MISSING, - Object.hasOwn(staged, key) ? staged[key] : MISSING, - Object.hasOwn(current, key) ? current[key] : MISSING - ) - if (value === MISSING) { - if (Object.hasOwn(next, key)) { - delete next[key] - changed = true - } - } else if (!Object.hasOwn(current, key) || !isDeepStrictEqual(current[key], value)) { - next[key] = value - changed = true - } - } - return changed ? next : current -} - -export function rollbackWorkspaceSessionAfterFailedAsyncWrite( - original: WorkspaceSessionState, - staged: WorkspaceSessionState, - current: WorkspaceSessionState -): WorkspaceSessionState { - return rollbackValue(original, staged, current) as WorkspaceSessionState -} diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index d71797033605..889f792abd4d 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -66,11 +66,11 @@ function sessionAfterClose() { } /** What the relay's reattach bind does per PTY — see `restoreReattachedPtyRuntime`. */ -function relayReattachBinds( +async function relayReattachBinds( store: ReturnType, args: { tabId: string; leafId: string; ptyId: string; incarnationId?: string } -): boolean | null { - return store.persistPtyBinding({ +): Promise { + return await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: args.tabId, leafId: args.leafId, @@ -95,10 +95,10 @@ function relayReattachBinds( * production uses; a raw session write is reconciled back to the attached lease's PTY by binding * recovery, which would make the fixture disagree with the real flow. */ -function paneSpawnCommits( +async function paneSpawnCommits( store: ReturnType, args: { tabId: string; leafId: string; ptyId: string; leaseTabId?: string } -): void { +): Promise { store.upsertSshRemotePtyLease({ targetId: TARGET, ptyId: args.ptyId, @@ -107,7 +107,7 @@ function paneSpawnCommits( leafId: args.leafId, state: 'attached' }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: args.tabId, leafId: args.leafId, @@ -163,7 +163,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () // The user closes the tab; the remote kill never lands, so the lease survives untouched. store.setWorkspaceSession(sessionAfterClose()) - const bound = relayReattachBinds(store, { + const bound = await relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1', @@ -189,7 +189,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () const resolvedTabId = findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1) expect(resolvedTabId).toBe(OTHER_TAB) - const bound = relayReattachBinds(store, { + const bound = await relayReattachBinds(store, { tabId: resolvedTabId!, leafId: TEST_LEAF_1, ptyId: 'pty-2', @@ -208,7 +208,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () it('still binds when the session is not yet authoritative for the worktree', async () => { const store = await createStore() - const bound = store.persistPtyBinding({ + const bound = await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1, @@ -250,7 +250,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () expect(store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey]).toBeDefined() expect( - relayReattachBinds(store, { + await relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1', @@ -267,7 +267,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () const store = await createStore() store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' })) - const bound = relayReattachBinds(store, { + const bound = await relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_2, ptyId: 'pty-2', @@ -292,9 +292,9 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () const session = store.getWorkspaceSession() expect(session.terminalTopologyRevisionByRepoId?.repo1).toBeGreaterThan(0) expect(session.terminalSurfaceTombstonesByPaneKey ?? {}).toEqual({}) - expect(relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' })).toBe( - false - ) + expect( + await relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' }) + ).toBe(false) }) }) @@ -314,7 +314,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) expect(liveLeasePtyIds(store)).toEqual(['pty-2']) }) @@ -327,7 +327,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor?.state).toBe('expired') @@ -339,7 +339,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-0' })) for (let reconnect = 0; reconnect < 10; reconnect++) { - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) } expect(liveLeasePtyIds(store)).toEqual(['pty-9']) @@ -362,7 +362,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { // The successor's lease names the tab the pane sits in NOW; the predecessor's still names the // one it was written in. Only the leaf is common, so keying on the tab would stop the two // competing and leave both live — the cardinality growth. - paneSpawnCommits(store, { + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2', @@ -401,7 +401,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) expect(liveLeasePtyIds(store).sort()).toEqual(['pty-2', 'sibling-pty']) }) @@ -461,7 +461,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () it('never bulk-reattaches a superseded sibling', async () => { const store = await storeWithPane('pty-1') - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor).toMatchObject({ state: 'expired', supersededBy: 'pty-2' }) @@ -474,7 +474,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-0' })) for (let reconnect = 0; reconnect < 10; reconnect++) { - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) } expect(bulkReattachPtyIds(store)).toEqual(['pty-9']) @@ -500,7 +500,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () store.markSshRemotePtyLease(TARGET, 'pty-1', 'expired') const orphanUpdatedAt = store.getSshRemotePtyLeases(TARGET)[0].updatedAt - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor).toMatchObject({ state: 'expired', supersededBy: 'pty-2' }) @@ -513,7 +513,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () // belongs to the lease that lost, never to whatever claims the id next. it('clears the supersession mark when the id is re-upserted as a live lease', async () => { const store = await storeWithPane('pty-1') - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) // A restarted relay hands `pty-1` to a new shell for a different pane. store.upsertSshRemotePtyLease({ diff --git a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts index afb365c075c9..7a503f518677 100644 --- a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts +++ b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts @@ -214,7 +214,7 @@ describe('sweepOrphanedRelayPtys', () => { clearBindingsForTarget: () => {}, clearBindingsForLeases: () => false, flush: () => {}, - flushDurableStateOrThrowAsync: async () => {} + runDurableMutation: async (mutate) => mutate().value } // The same pane re-leases under a new relay id; pty-1 is expired, never terminated. upsertSshRemotePtyLease(operations, { diff --git a/src/main/ssh/ssh-pty-consumer-recovery.ts b/src/main/ssh/ssh-pty-consumer-recovery.ts index db49a45c399c..727f293b5728 100644 --- a/src/main/ssh/ssh-pty-consumer-recovery.ts +++ b/src/main/ssh/ssh-pty-consumer-recovery.ts @@ -96,10 +96,7 @@ export async function removeSshPtyConsumerOwnerRecovery( clientInstanceId: string, store: Store ): Promise { - const persisted = store.getSshPtyConsumerRecovery(targetId) - if (persisted?.clientInstanceId === clientInstanceId) { - await store.removeSshPtyConsumerRecovery(targetId) - } + await store.removeSshPtyConsumerRecovery(targetId, clientInstanceId) } export function detachSshPtyConsumerRecovery(targetId: string, clientInstanceId: string): void { diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts index e5683c0949ab..da528f7f95fb 100644 --- a/src/main/ssh/ssh-relay-session-data-delivery.test.ts +++ b/src/main/ssh/ssh-relay-session-data-delivery.test.ts @@ -344,7 +344,8 @@ describe('SshRelaySession data delivery', () => { }) ) session.dispose() - expect(mockStore.removeSshPtyConsumerRecovery).toHaveBeenCalledWith(targetId) + const removeRecovery = mockStore.removeSshPtyConsumerRecovery + expect(removeRecovery).toHaveBeenCalledWith(targetId, 'persisted-client') }) it('voids checkpoints for a fresh claim without a second owner request', async () => { diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index 6760a27821c7..3c405cd5dff9 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -3,7 +3,11 @@ import { randomUUID } from 'node:crypto' import type * as NodeCrypto from 'node:crypto' import { SshRelaySession } from './ssh-relay-session' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' +import { + createMockDeps, + mockDeploySuccess, + recordedPtyBindings +} from './ssh-relay-session-test-fixtures' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { SshRemotePtyLease } from '../../shared/ssh-types' @@ -439,7 +443,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { incarnationId }) expect(runtime.onPtySpawned).not.toHaveBeenCalled() - expect(mockStore.persistPtyBinding).toHaveBeenCalledWith({ + expect(recordedPtyBindings(mockStore)).toContainEqual({ worktreeId: 'worktree-1', tabId: 'tab-1', leafId: INCARNATION_LEAF_ID, @@ -643,7 +647,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { leafId: INCARNATION_LEAF_ID, incarnationId }) - expect(mockStore.persistPtyBinding).toHaveBeenCalledWith( + expect(recordedPtyBindings(mockStore)).toContainEqual( expect.objectContaining({ tabId: movedTabId, ptyId: APP_PTY_ID, incarnationId }) ) expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( @@ -763,7 +767,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { incarnationId: currentIncarnationId }) expect(setPtyOwnership).toHaveBeenCalledWith(APP_PTY_ID, 'target-1') - expect(mockStore.persistPtyBinding).toHaveBeenCalledWith( + expect(recordedPtyBindings(mockStore)).toContainEqual( expect.objectContaining({ ptyId: APP_PTY_ID, incarnationId: currentIncarnationId }) ) expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index ba0782b3a204..c186e922e931 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -1,6 +1,7 @@ import { vi, type Mock } from 'vitest' import type { BrowserWindow } from 'electron' import type { SshConnection } from './ssh-connection' +import type { PersistPtyBindingArgs } from '../persistence/loading-store/pty-binding-persistence' import type { Store } from '../persistence' import type { SshPortForwardManager } from './ssh-port-forward' import { deployAndLaunchRelay } from './ssh-relay-deploy' @@ -13,8 +14,16 @@ type SshRelaySessionTestDeps = { mockWindow: BrowserWindow } +const persistedBindings = new WeakMap() + +export function recordedPtyBindings(store: Store): readonly PersistPtyBindingArgs[] { + return persistedBindings.get(store) ?? [] +} + export function createMockDeps(): SshRelaySessionTestDeps { + const bindings: PersistPtyBindingArgs[] = [] const mockConn = {} as SshConnection + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The relay fixture implements the Store methods exercised by session establishment and teardown. const mockStore = { getRepos: vi.fn().mockReturnValue([]), getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), @@ -33,8 +42,16 @@ export function createMockDeps(): SshRelaySessionTestDeps { recordSshRemotePtyKillIntent: vi.fn(), clearSshRemotePtyKillIntent: vi.fn(), noteSshRemotePtyKillReplayAttempt: vi.fn(), - persistPtyBinding: vi.fn() + persistPtyBinding: vi.fn(async (input: Parameters[0]) => { + const binding = typeof input === 'function' ? input() : input + if (!binding) { + return false + } + bindings.push(binding) + return true + }) } as unknown as Store + persistedBindings.set(mockStore, bindings) const mockPortForward = { removeAllForwards: vi.fn() } as unknown as SshPortForwardManager diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 19e102b08ede..3ad48113ffce 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -2575,11 +2575,15 @@ export class SshRelaySession { return } if (attachResult.incarnationId) { - const restoreResult = this.restoreReattachedPtyRuntime( + const restoreResult = await this.restoreReattachedPtyRuntime( appPtyId, attachResult.incarnationId, - activeLease + activeLease, + () => shouldContinue() && this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach) ) + if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach)) { + return + } if (restoreResult !== 'restored') { clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) @@ -2742,45 +2746,56 @@ export class SshRelaySession { deletePtyOwnership(appPtyId) } - private restoreReattachedPtyRuntime( + private async restoreReattachedPtyRuntime( appPtyId: string, incarnationId: string, - lease: SshPtyLease | undefined - ): ReattachedPtyRuntimeRestore { + lease: SshPtyLease | undefined, + shouldContinue: () => boolean + ): Promise { if (lease?.worktreeId && lease.tabId && lease.leafId) { - const session = this.store.getWorkspaceSession?.() - // The lease froze its tabId at write time; `detachTerminalPaneToTab` moves a live pane, so - // trusting it would fence this reattach to the tab the pane LEFT and refuse a pane that - // merely moved. Leaf is the identity, the tab is only where it currently sits. - // SSH spawns bind panes into `ssh:` while this reattach binds into `local`, so a - // fence that consulted only one partition would read "no pane" for a pane the other holds. - const hostSession = this.store.getWorkspaceSession?.(toSshExecutionHostId(this.targetId)) - const tabId = - findTerminalTabIdForLeaf(session, lease.leafId) ?? - findTerminalTabIdForLeaf(hostSession, lease.leafId) ?? - lease.tabId - // Absence of the pane only means "the user closed it" once the persisted membership - // speaks for this worktree. Before that it means the renderer has not published its - // layout yet, and refusing there drops a tab the user still has — the regression that - // reverted this fix twice. Losing a tab is worse than keeping a duplicate, so an - // unauthoritative session still gets the creating write. - // Authority is read from `local` because that is the partition this write lands in — it - // is local's absence we would be interpreting. But a pane the other partition still holds - // is not gone, so it keeps its creating write: refusing there would strand a live pane - // behind a binding reattach can no longer reach. - const mayCreate = - !hasHostAuthoritativeTerminalMembership(session, lease.worktreeId) || - findTerminalTabIdForLeaf(hostSession, lease.leafId) !== undefined - const bound = this.store.persistPtyBinding({ - worktreeId: lease.worktreeId, - tabId, - leafId: lease.leafId, - ptyId: appPtyId, - incarnationId, - ...(mayCreate ? {} : { mayCreate: false }), - mayReviveRetiredSurface: false, - origin: 'relay_reattach' + const { worktreeId, leafId, tabId: leaseTabId } = lease + let tabId = lease.tabId + const bound = await this.store.persistPtyBinding(() => { + if (!shouldContinue()) { + return null + } + const session = this.store.getWorkspaceSession?.() + // The lease froze its tabId at write time; `detachTerminalPaneToTab` moves a live pane, so + // trusting it would fence this reattach to the tab the pane LEFT and refuse a pane that + // merely moved. Leaf is the identity, the tab is only where it currently sits. + // SSH spawns bind panes into `ssh:` while this reattach binds into `local`, so a + // fence that consulted only one partition would read "no pane" for a pane the other holds. + const hostSession = this.store.getWorkspaceSession?.(toSshExecutionHostId(this.targetId)) + tabId = + findTerminalTabIdForLeaf(session, leafId) ?? + findTerminalTabIdForLeaf(hostSession, leafId) ?? + leaseTabId + // Absence of the pane only means "the user closed it" once the persisted membership + // speaks for this worktree. Before that it means the renderer has not published its + // layout yet, and refusing there drops a tab the user still has — the regression that + // reverted this fix twice. Losing a tab is worse than keeping a duplicate, so an + // unauthoritative session still gets the creating write. + // Authority is read from `local` because that is the partition this write lands in — it + // is local's absence we would be interpreting. But a pane the other partition still holds + // is not gone, so it keeps its creating write: refusing there would strand a live pane + // behind a binding reattach can no longer reach. + const mayCreate = + !hasHostAuthoritativeTerminalMembership(session, worktreeId) || + findTerminalTabIdForLeaf(hostSession, leafId) !== undefined + return { + worktreeId: worktreeId, + tabId, + leafId: leafId, + ptyId: appPtyId, + incarnationId, + ...(mayCreate ? {} : { mayCreate: false }), + mayReviveRetiredSurface: false, + origin: 'relay_reattach' as const + } }) + if (!shouldContinue()) { + return 'missing-surface' + } if (bound === false) { // Topology absence alone is not authority to kill a process, but neither refusal may // publish or replay into a missing pane. diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index f518cdf0fe11..97fda41a31f6 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -139,7 +139,7 @@ export async function initializeReadyFoundation(): Promise { // read has to have happened by then or the renderer and the browser-host lease disagree. initializeBrowserClientHostId(profile.profileDirectory) const profileStateAuthorityMode = desktopProfileStateAuthorityMode() - const profileState = createProfileStateStoreForStartup({ + const profileState = await createProfileStateStoreForStartup({ dataFile: profile.dataFile, databaseFile: profile.stateDatabaseFile, profileId: profile.profile.id, diff --git a/src/main/startup/main-process-ready-persistence-cleanup.test.ts b/src/main/startup/main-process-ready-persistence-cleanup.test.ts new file mode 100644 index 000000000000..7fc2f63d16e1 --- /dev/null +++ b/src/main/startup/main-process-ready-persistence-cleanup.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { state, foundation, runtime, i18n, launch } = vi.hoisted(() => ({ + state: { + store: { freezeWritesAsync: vi.fn(async () => {}) }, + mainProcessI18nReady: Promise.resolve() + }, + foundation: vi.fn(async () => {}), + runtime: vi.fn(async () => {}), + i18n: vi.fn(async () => {}), + launch: vi.fn(async () => {}) +})) + +vi.mock('./main-process-state', () => ({ mainProcessState: state })) +vi.mock('./main-process-ready-foundation', () => ({ initializeReadyFoundation: foundation })) +vi.mock('./main-process-ready-runtime', () => ({ initializeReadyRuntimeServices: runtime })) +vi.mock('./main-process-i18n-menu', () => ({ initializeMainProcessI18nAndMenu: i18n })) +vi.mock('./main-process-runtime-launch', () => ({ initializeMainProcessRuntimeLaunch: launch })) + +import { initializeMainProcessReady } from './main-process-ready' + +const options = { + openMainWindow: (): never => { + throw new Error('Unexpected window creation in startup cleanup test') + }, + handleMacAppActivation: () => {} +} + +beforeEach(() => vi.clearAllMocks()) + +describe('startup persistence lifetime', () => { + it('awaits writer release after a later startup phase fails', async () => { + const failure = new Error('runtime startup failed') + runtime.mockRejectedValueOnce(failure) + let release = () => {} + state.store.freezeWritesAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const ready = initializeMainProcessReady(options) + const rejected = expect(ready).rejects.toBe(failure) + await vi.waitFor(() => expect(state.store.freezeWritesAsync).toHaveBeenCalledOnce()) + release() + await rejected + expect(launch).not.toHaveBeenCalled() + }) + + it('joins concurrent startup branches before closing their Store', async () => { + const failure = new Error('translations failed') + i18n.mockRejectedValueOnce(failure) + let release = () => {} + launch.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const ready = initializeMainProcessReady(options) + const rejected = expect(ready).rejects.toBe(failure) + await vi.waitFor(() => expect(launch).toHaveBeenCalledOnce()) + expect(state.store.freezeWritesAsync).not.toHaveBeenCalled() + release() + await rejected + expect(state.store.freezeWritesAsync).toHaveBeenCalledOnce() + }) + + it('keeps the original startup failure when cleanup also fails', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('foundation failed') + foundation.mockRejectedValueOnce(failure) + state.store.freezeWritesAsync.mockRejectedValueOnce(new Error('close failed')) + try { + await expect(initializeMainProcessReady(options)).rejects.toBe(failure) + expect(log).toHaveBeenCalledOnce() + } finally { + log.mockRestore() + } + }) +}) diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts index 835c1f1dd134..ec9191b12249 100644 --- a/src/main/startup/main-process-ready.ts +++ b/src/main/startup/main-process-ready.ts @@ -11,12 +11,31 @@ import { export async function initializeMainProcessReady( options: MainProcessRuntimeLaunchOptions ): Promise { - await initializeReadyFoundation() - await initializeReadyRuntimeServices() - // Why concurrent: window creation reads no translated string and no menu item, and both the - // native menu and the tray only become reachable once the window shows — so serializing them - // ahead of openMainWindow only delayed the renderer (8 ms in English, more for a lazy locale). - const i18nAndMenuReady = initializeMainProcessI18nAndMenu() - state.mainProcessI18nReady = i18nAndMenuReady.catch(() => {}) - await Promise.all([i18nAndMenuReady, initializeMainProcessRuntimeLaunch(options)]) + try { + await initializeReadyFoundation() + await initializeReadyRuntimeServices() + // Window creation can proceed while translations and the native menu initialize. + const i18nAndMenuReady = initializeMainProcessI18nAndMenu() + state.mainProcessI18nReady = i18nAndMenuReady.catch(() => {}) + // Join both branches before cleanup can close the profile writer. + const results = await Promise.allSettled([ + i18nAndMenuReady, + initializeMainProcessRuntimeLaunch(options) + ]) + for (const result of results) { + if (result.status === 'rejected') { + throw result.reason + } + } + } catch (error) { + try { + await state.store?.freezeWritesAsync() + } catch (closeError) { + console.error( + '[persistence] Failed to close profile persistence after startup failure:', + closeError + ) + } + throw error + } } diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index c19446aec6cd..28dd6bbbb20d 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -128,8 +128,8 @@ export function attachMainWindowCoreServices( onPtyExit: handlePtyExit, onBeforeUpdateQuit: async () => { await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) - store.writeLatestProfileStateJsonExport() - store.writeLatestProfileStateJsonCompatibilityExport() + await store.writeLatestProfileStateJsonExportAsync() + await store.writeLatestProfileStateJsonCompatibilityExportAsync() }, onBeforeUpdateQuitFailure: 'abort', updateInstallMode: resolveUpdateInstallMode(state.isServeMode), diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts index da58b2d9defa..201b6be97991 100644 --- a/src/preload/api/orca-profiles-bridge.ts +++ b/src/preload/api/orca-profiles-bridge.ts @@ -1,6 +1,13 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' -import { ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL } from '../../shared/orca-profiles' +import { + ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL, + type OrcaProfileListResult, + type SwitchOrcaProfileResult, + type TransferOrcaProfileProjectResult +} from '../../shared/orca-profiles' +import { prepareAndInvokeAppRestart } from '../renderer-restart-wiring' +import { awaitBeforeUnloadCheckpoint } from '../preload-runtime-support' export const orcaProfilesApi = { list: () => ipcRenderer.invoke('orcaProfiles:list'), @@ -12,8 +19,30 @@ export const orcaProfilesApi = { }, createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), - switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), - transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args), + switchProfile: (args) => + prepareAndInvokeAppRestart( + window, + (): Promise => ipcRenderer.invoke('orcaProfiles:switch', args), + awaitBeforeUnloadCheckpoint, + (result) => result.status === 'relaunching' + ), + transferProject: async (args) => { + const invoke = (): Promise => + ipcRenderer.invoke('orcaProfiles:transferProject', args) + if (args.mode !== 'move') { + return invoke() + } + const current: OrcaProfileListResult = await ipcRenderer.invoke('orcaProfiles:list') + if (args.sourceProfileId !== current.activeProfileId) { + return invoke() + } + return prepareAndInvokeAppRestart( + window, + invoke, + awaitBeforeUnloadCheckpoint, + (result) => result.status === 'transferred' && result.willRelaunch === true + ) + }, findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts index b68f55989f58..2c44b9a5b7fe 100644 --- a/src/preload/app-restart-checkpoint-routing.test.ts +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -124,4 +124,99 @@ describe('native preload destructive app actions', () => { expect(onKeyboardLayoutChanged).toHaveBeenCalledExactlyOnceWith(payload) expect(removeListener).toHaveBeenCalledWith(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) }) + + it('awaits renderer durability before profile maintenance and preserves its result', async () => { + const api = await loadApi() + const started = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, started) + let finishCheckpoint = (_result: { ok: boolean }): void => {} + const checkpoint = new Promise((resolve) => { + finishCheckpoint = resolve + }) + const result = { status: 'relaunching' } + invoke.mockImplementation((channel: string) => + channel === 'app:await-before-unload-checkpoint' ? checkpoint : Promise.resolve(result) + ) + + const switching = api.orcaProfiles.switchProfile({ profileId: 'target' }) + await vi.waitFor(() => + expect(invoke).toHaveBeenCalledWith('app:await-before-unload-checkpoint') + ) + expect(started).toHaveBeenCalledOnce() + expect(invoke).not.toHaveBeenCalledWith('orcaProfiles:switch', expect.anything()) + finishCheckpoint({ ok: true }) + await expect(switching).resolves.toBe(result) + expect(invoke).toHaveBeenLastCalledWith('orcaProfiles:switch', { profileId: 'target' }) + }) + + it.each(['checkpoint-failed', 'switch-failed', 'already-active'])( + 'resets restart preparation when profile switching returns %s', + async (outcome) => { + const api = await loadApi() + const aborted = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + invoke.mockImplementation(async (channel: string) => { + if (channel === 'app:await-before-unload-checkpoint') { + return { ok: outcome !== 'checkpoint-failed' } + } + if (outcome === 'switch-failed') { + throw new Error('switch failed') + } + return { status: 'already-active' } + }) + const switching = api.orcaProfiles.switchProfile({ profileId: 'target' }) + await (outcome === 'already-active' + ? expect(switching).resolves.toEqual({ status: 'already-active' }) + : expect(switching).rejects.toThrow()) + expect(aborted).toHaveBeenCalledOnce() + if (outcome === 'checkpoint-failed') { + expect(invoke).not.toHaveBeenCalledWith('orcaProfiles:switch', expect.anything()) + } + } + ) + + it.each(['move', 'copy', 'inactive', 'duplicate', 'recovery'] as const)( + 'prepares only a potentially relaunching project transfer: %s', + async (outcome) => { + const api = await loadApi() + const started = vi.fn() + const aborted = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, started) + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + const args = { + sourceProfileId: outcome === 'inactive' ? 'inactive' : 'active', + targetProfileId: 'target', + repoId: 'repo', + mode: outcome === 'copy' ? ('copy' as const) : ('move' as const) + } + const result = + outcome === 'duplicate' + ? { status: 'duplicate-target' } + : { status: 'transferred', willRelaunch: outcome === 'move' } + invoke.mockImplementation(async (channel: string) => { + if (channel === 'orcaProfiles:list') { + return { activeProfileId: 'active' } + } + if (channel === 'app:await-before-unload-checkpoint') { + return { ok: true } + } + if (outcome === 'recovery') { + const listener = on.mock.calls.find(([name]) => name === 'app:restart-committed')?.[1] + expect(listener).toBeTypeOf('function') + listener() + throw new Error('move requires recovery') + } + return result + }) + + const transfer = api.orcaProfiles.transferProject(args) + await (outcome === 'recovery' + ? expect(transfer).rejects.toThrow('move requires recovery') + : expect(transfer).resolves.toBe(result)) + const needsPreparation = outcome !== 'copy' && outcome !== 'inactive' + expect(started).toHaveBeenCalledTimes(needsPreparation ? 1 : 0) + expect(aborted).toHaveBeenCalledTimes(outcome === 'duplicate' ? 1 : 0) + expect(invoke).toHaveBeenLastCalledWith('orcaProfiles:transferProject', args) + } + ) }) diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts index bd896109b578..e1c36b499500 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events' import { ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_COMMITTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' import { @@ -14,6 +15,7 @@ describe('renderer restart wiring', () => { const eventTarget = new EventTarget() const unloadPrevented = vi.fn() const restartAborted = vi.fn() + const restartCommitted = vi.fn() const handleStatus = vi.fn() const abort = vi.fn() const listeners = new Map void>() @@ -25,18 +27,21 @@ describe('renderer restart wiring', () => { } as unknown as Parameters[0] eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, unloadPrevented) eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, restartAborted) + eventTarget.addEventListener(ORCA_APP_RESTART_COMMITTED_EVENT, restartCommitted) registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus, abort }) listeners.get('updater:status')?.({}, { state: 'error', message: 'install failed' }) // Why: main abandons an install without any status when its verdict outlived the cycle. listeners.get('updater:quitAndInstallAborted')?.({}) listeners.get('window:unload-prevented')?.({}) + listeners.get('app:restart-committed')?.({}) - expect(ipcRenderer.on).toHaveBeenCalledTimes(3) + expect(ipcRenderer.on).toHaveBeenCalledTimes(4) expect(handleStatus).toHaveBeenCalledWith({ state: 'error', message: 'install failed' }) expect(abort).toHaveBeenCalledTimes(1) expect(unloadPrevented).toHaveBeenCalledTimes(1) expect(restartAborted).toHaveBeenCalledTimes(1) + expect(restartCommitted).toHaveBeenCalledTimes(1) }) it('marks preparation before invoking main and aborts on IPC failure', async () => { diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index 2dd0ad316aa9..ea2208da17ed 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -7,6 +7,7 @@ import { import type { UpdateStatus } from '../shared/update-status-types' import { ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_COMMITTED_EVENT, ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT @@ -24,6 +25,9 @@ export function registerRendererRestartIpcRelays( ipcRenderer.on('updater:quitAndInstallAborted', () => { relay.abort() }) + ipcRenderer.on('app:restart-committed', () => { + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_COMMITTED_EVENT)) + }) ipcRenderer.on('window:unload-prevented', () => { eventTarget.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) @@ -50,20 +54,35 @@ export async function prepareAndInvokeUpdaterInstall( } } -export async function prepareAndInvokeAppRestart( +export async function prepareAndInvokeAppRestart( eventTarget: EventTarget, - invoke: () => Promise, - awaitCheckpoint: () => Promise -): Promise { + invoke: () => Promise, + awaitCheckpoint: () => Promise, + willRestart: (result: T) => boolean = () => true +): Promise { await prepareRendererForAppRestart(eventTarget, { startedEventName: ORCA_APP_RESTART_STARTED_EVENT, abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, awaitCheckpoint }) + let committed = false + const markCommitted = (): void => { + committed = true + } + eventTarget.addEventListener(ORCA_APP_RESTART_COMMITTED_EVENT, markCommitted) try { - await invoke() + const result = await invoke() + if (!committed && !willRestart(result)) { + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + } + return result } catch (error) { - eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + // A failed profile move can require recovery after its writer has already closed. + if (!committed) { + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + } throw error + } finally { + eventTarget.removeEventListener(ORCA_APP_RESTART_COMMITTED_EVENT, markCommitted) } } diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 9a2598caefbf..aa737ea4aed8 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -49,6 +49,8 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [ { filename: 'parcel-watcher-process-entry.js' }, // Forked so PTYs outlive the runtime process; its absence makes every restart destructive. { filename: 'daemon-entry.js' }, + { filename: 'profile-state-writer-worker-entry.js' }, + { filename: 'profile-state-backup-worker-entry.js' }, ...ORCAD_RIPGREP_ARTIFACTS.map((filename) => ({ filename })), ...ORCAD_RIPGREP_LICENSE_ARTIFACTS.map((filename) => ({ filename })) ] diff --git a/src/shared/updater-renderer-events.ts b/src/shared/updater-renderer-events.ts index 49097bad6ae8..baafab5da226 100644 --- a/src/shared/updater-renderer-events.ts +++ b/src/shared/updater-renderer-events.ts @@ -2,3 +2,4 @@ export const ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT = 'orca:updater-quit-an export const ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT = 'orca:updater-quit-and-install-aborted' export const ORCA_APP_RESTART_STARTED_EVENT = 'orca:app-restart-started' export const ORCA_APP_RESTART_ABORTED_EVENT = 'orca:app-restart-aborted' +export const ORCA_APP_RESTART_COMMITTED_EVENT = 'orca:app-restart-committed' From 64e8b86abc09b2a8783d9e60762f497c5fe6f1ef Mon Sep 17 00:00:00 2001 From: m4air Date: Wed, 23 Sep 2026 21:57:55 -0700 Subject: [PATCH 06/43] Harden profile restart and protected-setting persistence --- src/main/ipc/orca-profiles.test.ts | 27 ++++ src/main/ipc/orca-profiles.ts | 15 +- ...file-state-worker-secret-retention.test.ts | 150 +++++++++++------- src/preload/renderer-restart-wiring.test.ts | 118 ++++++++++++++ src/preload/renderer-restart-wiring.ts | 58 +++++-- 5 files changed, 288 insertions(+), 80 deletions(-) diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index e7bd01cfbcb8..3ae540275015 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -358,6 +358,33 @@ describe('registerOrcaProfileHandlers', () => { expect(appExitMock).not.toHaveBeenCalled() }) + it('relaunches the closed source when a completed move cannot update the profile index', async () => { + const store = makeStoreMock() + getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'personal', profiles: [] }) + transferOrcaProfileProjectMock.mockReturnValue({ status: 'transferred', mode: 'move' }) + setActiveOrcaProfileMock.mockImplementationOnce(() => { + throw new Error('profile index disk full') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. + registerOrcaProfileHandlers(store as never) + + await expect( + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }) + ).rejects.toThrow('profile index disk full') + + expect(store.freezeWrites).toHaveBeenCalledOnce() + expect(store.resumeMaintenance).not.toHaveBeenCalled() + expect(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed') + await vi.advanceTimersByTimeAsync(150) + expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer') + expect(appQuitMock).toHaveBeenCalledOnce() + }) + it('rejects transfers that would mutate the active target profile offline', async () => { getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'work', diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index 83e4e88a8296..18d4310dc4bd 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -139,10 +139,9 @@ async function runBeforeProfileRelaunch( } } -function scheduleProfileRelaunch( - reason: Extract, - sender: WebContents -): void { +type ProfileRelaunchReason = Extract + +function scheduleProfileRelaunch(reason: ProfileRelaunchReason, sender: WebContents): void { if (!sender.isDestroyed()) { sender.send('app:restart-committed') } @@ -245,8 +244,12 @@ export function registerOrcaProfileHandlers( ) if (result.status === 'transferred') { await runBeforeProfileRelaunch(options.onBeforeRelaunch) - setActiveOrcaProfile(args.targetProfileId) - scheduleProfileRelaunch('profile-transfer', event.sender) + try { + setActiveOrcaProfile(args.targetProfileId) + } finally { + // The source has already changed and its writer cannot resume. + scheduleProfileRelaunch('profile-transfer', event.sender) + } return { ...result, willRelaunch: true } } return result diff --git a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts index 09e004bde1e7..4d72ce144c62 100644 --- a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts +++ b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts @@ -27,64 +27,98 @@ beforeEach(() => { }) afterEach(() => setSecretStore(previousSecretStore)) -describe('Store secret retention across worker acknowledgements', () => { - it('does not restore ciphertext cleared while its commit acknowledgement was pending', async () => { - const { store, authority, readState } = await fixture() - store.updateSettings({ opencodeSessionCookie: 'durable' }) - await store.flushPendingOrThrowAsync() - const gate = authority.pause() - store.updateSettings({ opencodeSessionCookie: 'in-flight' }) - const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - store.updateSettings({ opencodeSessionCookie: '' }) - encryptionAvailable = false - gate.finish.resolve() - await write - await store.flushPendingOrThrowAsync() - expect(readState().settings.opencodeSessionCookie).toBe('') - expect(store.getSettings().opencodeSessionCookie).toBe('') - }) +describe.each(['opencodeSessionCookie', 'opencodeGoApiKey'] as const)( + 'Store %s retention across worker acknowledgements', + (setting) => { + it('does not restore ciphertext cleared while its commit acknowledgement was pending', async () => { + const { store, authority, readState } = await fixture() + store.updateSettings({ [setting]: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ [setting]: 'in-flight' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ [setting]: '' }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe('') + expect(store.getSettings()[setting]).toBe('') + }) - it('retains confirmed ciphertext until a newer secret can be encrypted', async () => { - const { store, authority, readState } = await fixture() - store.updateSettings({ opencodeSessionCookie: 'durable' }) - await store.flushPendingOrThrowAsync() - const gate = authority.pause() - store.updateSettings({ opencodeSessionCookie: 'in-flight' }) - const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - store.updateSettings({ opencodeSessionCookie: 'newer' }) - encryptionAvailable = false - gate.finish.resolve() - await write - await store.flushPendingOrThrowAsync() - expect(readState().settings.opencodeSessionCookie).toBe(ciphertext('in-flight')) - expect(store.getSettings().opencodeSessionCookie).toBe('newer') - encryptionAvailable = true - store.updateSettings({ theme: 'dark' }) - await store.flushPendingOrThrowAsync() - expect(readState().settings.opencodeSessionCookie).toBe(ciphertext('newer')) - }) + it('retains confirmed ciphertext until a newer secret can be encrypted', async () => { + const { store, authority, readState } = await fixture() + store.updateSettings({ [setting]: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ [setting]: 'in-flight' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ [setting]: 'newer' }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('in-flight')) + expect(store.getSettings()[setting]).toBe('newer') + encryptionAvailable = true + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('newer')) + }) - it('retains the earlier ciphertext after a failed write and retries newer plaintext', async () => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - store.updateSettings({ opencodeSessionCookie: 'durable' }) - await store.flushPendingOrThrowAsync() - const gate = authority.pause() - store.updateSettings({ opencodeSessionCookie: 'failed' }) - const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - const failure = expect(write).rejects.toThrow('disk refused') - await gate.started.promise - store.updateSettings({ opencodeSessionCookie: 'newer' }) - encryptionAvailable = false - gate.finish.reject(new Error('disk refused')) - await failure - await store.flushPendingOrThrowAsync() - expect(readState().settings.opencodeSessionCookie).toBe(ciphertext('durable')) - encryptionAvailable = true - store.updateSettings({ theme: 'dark' }) - await store.flushPendingOrThrowAsync() - expect(readState().settings.opencodeSessionCookie).toBe(ciphertext('newer')) - }) + it('retains the earlier ciphertext after a failed write and retries newer plaintext', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.updateSettings({ [setting]: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ [setting]: 'failed' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const failure = expect(write).rejects.toThrow('disk refused') + await gate.started.promise + store.updateSettings({ [setting]: 'newer' }) + encryptionAvailable = false + gate.finish.reject(new Error('disk refused')) + await failure + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('durable')) + encryptionAvailable = true + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('newer')) + }) + } +) + +describe('worker protected settings serialization', () => { + it.each(['selective', 'complete'] as const)( + 'encrypts both protected credentials in a %s write to SQLite', + async (mode) => { + const { store, authority, readState } = await fixture() + const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') + const completeWrite = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const secrets = { + opencodeSessionCookie: 'cookie-only-plaintext', + opencodeGoApiKey: 'api-key-only-plaintext' + } + store.updateSettings(secrets) + if (mode === 'complete') { + store.updateOnboarding({ outcome: 'completed' }) + } + await store.flushPendingOrThrowAsync() + expect(selectiveWrite).toHaveBeenCalledTimes(mode === 'selective' ? 1 : 0) + expect(completeWrite).toHaveBeenCalledTimes(mode === 'complete' ? 1 : 0) + const persisted = readState() + expect(persisted.settings).toMatchObject({ + opencodeSessionCookie: ciphertext(secrets.opencodeSessionCookie), + opencodeGoApiKey: ciphertext(secrets.opencodeGoApiKey) + }) + for (const plaintext of Object.values(secrets)) { + expect(JSON.stringify(persisted)).not.toContain(plaintext) + } + expect(store.getSettings()).toMatchObject(secrets) + } + ) }) diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts index e1c36b499500..c9d5b8c6592d 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -1,16 +1,134 @@ import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events' import { ORCA_APP_RESTART_ABORTED_EVENT, ORCA_APP_RESTART_COMMITTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' import { + prepareAndInvokeAppRestart, prepareAndInvokeUpdaterInstall, registerRendererRestartIpcRelays } from './renderer-restart-wiring' describe('renderer restart wiring', () => { + it.each(['no-op', 'failure'] as const)( + 'keeps a committed restart prepared after a later %s', + async (outcome) => { + const eventTarget = new EventTarget() + const aborted = vi.fn() + const started = vi.fn() + const checkpoint = vi.fn(async () => {}) + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, started) + await prepareAndInvokeAppRestart( + eventTarget, + async () => { + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_COMMITTED_EVENT)) + return true + }, + checkpoint, + Boolean + ) + const subsequent = prepareAndInvokeAppRestart( + eventTarget, + async () => { + if (outcome === 'failure') { + throw new Error('already finalized') + } + return false + }, + checkpoint, + Boolean + ) + await (outcome === 'failure' + ? expect(subsequent).rejects.toThrow('already finalized') + : expect(subsequent).resolves.toBe(false)) + expect(checkpoint).toHaveBeenCalledOnce() + expect(started).toHaveBeenCalledOnce() + expect(aborted).not.toHaveBeenCalled() + } + ) + + it('refuses overlapping preparation without abandoning the accepted restart', async () => { + const eventTarget = new EventTarget() + const aborted = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + const checkpoint = Promise.withResolvers() + const invoke = vi.fn(async () => true) + const first = prepareAndInvokeAppRestart(eventTarget, invoke, () => checkpoint.promise) + const refused = vi.fn(async () => false) + await expect( + prepareAndInvokeAppRestart(eventTarget, refused, async () => {}, Boolean) + ).rejects.toThrow('already in progress') + expect(refused).not.toHaveBeenCalled() + expect(aborted).not.toHaveBeenCalled() + checkpoint.resolve() + await expect(first).resolves.toBe(true) + expect(invoke).toHaveBeenCalledOnce() + }) + + it('retains late commitment across an unrelated unload veto', async () => { + const eventTarget = new EventTarget() + const abandoned = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, abandoned) + eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, abandoned) + const ipcRenderer = { + on: vi.fn[0]['on']>() + } + registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { + handleStatus: vi.fn(), + abort: vi.fn() + }) + const checkpoint = vi.fn(async () => {}) + await prepareAndInvokeAppRestart(eventTarget, async () => true, checkpoint, Boolean) + const sender = Object.assign(new EventEmitter(), { + invoke: vi.fn(async () => {}), + postMessage: vi.fn(), + send: vi.fn(), + sendSync: vi.fn(), + sendToHost: vi.fn() + }) + const emit = (channel: string) => { + const listener = ipcRenderer.on.mock.calls.find(([name]) => name === channel)?.[1] + expect(listener).toBeTypeOf('function') + listener?.({ ports: [], sender, defaultPrevented: false, preventDefault: vi.fn() }) + } + emit('app:restart-committed') + await prepareAndInvokeAppRestart(eventTarget, async () => false, checkpoint, Boolean) + expect(checkpoint).toHaveBeenCalledOnce() + emit('window:unload-prevented') + await prepareAndInvokeAppRestart(eventTarget, async () => true, checkpoint, Boolean) + expect(checkpoint).toHaveBeenCalledOnce() + expect(abandoned).not.toHaveBeenCalled() + }) + + it('releases preparation ownership and its listener after checkpoint failure', async () => { + const eventTarget = new EventTarget() + const add = vi.spyOn(eventTarget, 'addEventListener') + const remove = vi.spyOn(eventTarget, 'removeEventListener') + await expect( + prepareAndInvokeAppRestart( + eventTarget, + async () => {}, + async () => { + throw new Error('checkpoint failed') + } + ) + ).rejects.toThrow('checkpoint failed') + await prepareAndInvokeAppRestart( + eventTarget, + async () => {}, + async () => {} + ) + const added = add.mock.calls.filter(([name]) => name === ORCA_APP_RESTART_COMMITTED_EVENT) + const removed = remove.mock.calls.filter(([name]) => name === ORCA_APP_RESTART_COMMITTED_EVENT) + expect(added).toHaveLength(2) + expect(removed).toEqual(added) + }) + it('relays updater status, aborted installs, and prevented unload events', () => { const eventTarget = new EventTarget() const unloadPrevented = vi.fn() diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index ea2208da17ed..ee2584900edb 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -13,6 +13,18 @@ import { ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' +type AppRestartState = { committed: boolean; pending: boolean } +const appRestartStates = new WeakMap() + +function appRestartState(eventTarget: EventTarget): AppRestartState { + let state = appRestartStates.get(eventTarget) + if (!state) { + state = { committed: false, pending: false } + appRestartStates.set(eventTarget, state) + } + return state +} + export function registerRendererRestartIpcRelays( ipcRenderer: Pick, eventTarget: EventTarget, @@ -26,9 +38,14 @@ export function registerRendererRestartIpcRelays( relay.abort() }) ipcRenderer.on('app:restart-committed', () => { + appRestartState(eventTarget).committed = true eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_COMMITTED_EVENT)) }) ipcRenderer.on('window:unload-prevented', () => { + // A quit veto cannot reopen a profile whose maintenance has already committed. + if (appRestartState(eventTarget).committed) { + return + } eventTarget.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) }) @@ -60,29 +77,38 @@ export async function prepareAndInvokeAppRestart( awaitCheckpoint: () => Promise, willRestart: (result: T) => boolean = () => true ): Promise { - await prepareRendererForAppRestart(eventTarget, { - startedEventName: ORCA_APP_RESTART_STARTED_EVENT, - abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, - awaitCheckpoint - }) - let committed = false + const state = appRestartState(eventTarget) + if (state.pending) { + throw new Error('App restart preparation is already in progress') + } + state.pending = true const markCommitted = (): void => { - committed = true + state.committed = true } eventTarget.addEventListener(ORCA_APP_RESTART_COMMITTED_EVENT, markCommitted) try { - const result = await invoke() - if (!committed && !willRestart(result)) { - eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + if (!state.committed) { + await prepareRendererForAppRestart(eventTarget, { + startedEventName: ORCA_APP_RESTART_STARTED_EVENT, + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, + awaitCheckpoint + }) } - return result - } catch (error) { - // A failed profile move can require recovery after its writer has already closed. - if (!committed) { - eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + try { + const result = await invoke() + if (!state.committed && !willRestart(result)) { + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + } + return result + } catch (error) { + // A failed profile move can require recovery after its writer has already closed. + if (!state.committed) { + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + } + throw error } - throw error } finally { + state.pending = false eventTarget.removeEventListener(ORCA_APP_RESTART_COMMITTED_EVENT, markCommitted) } } From 5a0445b66a4a18afd3493977e6ae7731330dff28 Mon Sep 17 00:00:00 2001 From: m4air Date: Wed, 23 Sep 2026 23:32:43 -0700 Subject: [PATCH 07/43] Harden profile migration and rollback while simplifying persistence lifetimes Preserve surviving legacy backups during admission, publish immutable exports with the actual snapshot revision, and reject malformed inactive JSON roots. Keep import-only Stores isolated from live alias/listener ownership and freeze them before normalization can schedule work. Fence worker admission when close starts. Roll back failed PTY bindings through unrelated edits while retaining valid surfaces and newer sibling ownership. Remove duplicate restart notification, parser and deferred resolver plumbing. Add regression coverage for each recovered race and lifetime boundary. --- config/tsconfig.cli.json | 1 + .../profile-project-state-file.ts | 7 +- ...profile-project-transfer-migration.test.ts | 36 +++++ .../loading-store/backup-recovery-rotation.ts | 24 +--- .../loading-store/profile-state-authority.ts | 4 + .../profile-state-checkpoints.test.ts | 8 ++ .../profile-state-import-lifetime.test.ts | 117 +++++++++++++++ .../pty-binding-async-durability.test.ts | 97 ++++++++++++- ...y-binding-created-surface-rollback.test.ts | 135 ++++++++++++++++++ .../loading-store/pty-binding-persistence.ts | 28 ++-- .../pty-binding-write-rollback.ts | 94 ++++++++++++ .../loading-store/store-domain-composition.ts | 17 +++ src/main/persistence/loading-store/store.ts | 105 ++++++-------- .../loading-store/write-flush-barriers.ts | 23 ++- .../loading-store/write-scheduling.ts | 43 +++--- .../profile-state-authority-bootstrap.test.ts | 25 +++- .../profile-state-authority-bootstrap.ts | 43 ++++-- ...e-state-bootstrap-publication-race.test.ts | 39 +++++ .../profile-state-legacy-backup-path.ts | 16 +++ .../profile-state-live-store-factory.test.ts | 64 ++++++++- .../profile-state-live-store-factory.ts | 5 +- .../profile-state/profile-state-migration.ts | 11 +- .../profile-state-offline-settings.test.ts | 12 ++ .../profile-state-offline-settings.ts | 30 +--- .../profile-state-recovery-required.ts | 21 +++ .../profile-state-store-factory.test.ts | 47 ++++++ .../profile-state-versioned-export.test.ts | 81 +++++++++++ .../profile-state-versioned-export.ts | 22 ++- .../profile-state-worker-authority.test.ts | 59 ++++++++ .../profile-state-worker-authority.ts | 1 + .../profile-state-writer-connection.ts | 25 ++-- .../profile-state-writer-request.ts | 13 +- .../pane-identity-migration.ts | 45 +++--- .../workspace-pane-normalization.ts | 34 +++-- src/preload/renderer-restart-wiring.test.ts | 71 ++++----- src/preload/renderer-restart-wiring.ts | 7 - src/shared/updater-renderer-events.ts | 1 - 37 files changed, 1116 insertions(+), 295 deletions(-) create mode 100644 src/main/persistence/loading-store/profile-state-import-lifetime.test.ts create mode 100644 src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts create mode 100644 src/main/persistence/loading-store/pty-binding-write-rollback.ts create mode 100644 src/main/persistence/profile-state/profile-state-legacy-backup-path.ts create mode 100644 src/main/persistence/profile-state/profile-state-versioned-export.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-worker-authority.test.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 182d82056974..10b35b9c7079 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -51,6 +51,7 @@ "../src/main/persistence/profile-state/profile-state-automation-runs-writer.ts", "../src/main/persistence/profile-state/profile-state-offline-settings.ts", "../src/main/persistence/profile-state/profile-state-export-path.ts", + "../src/main/persistence/profile-state/profile-state-legacy-backup-path.ts", "../src/main/persistence/profile-state/profile-state-backup-path.ts", "../src/main/persistence/profile-state/profile-state-backup-rotation.ts", "../src/main/persistence/profile-state/profile-state-backup-job.ts", diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index 4d164dc3c9f9..13b8d1afae67 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -26,7 +26,8 @@ import { openProfileStateDatabase, openProfileStateDatabaseReadOnly } from '../persistence/profile-state/profile-state-database' -import { assertNoRetainedProfileStateExports } from '../persistence/profile-state/profile-state-recovery-required' +import { parseProfileStateRoot } from '../persistence/profile-state/profile-state-document-validation' +import { assertProfileStateCanInitialize } from '../persistence/profile-state/profile-state-recovery-required' import { hasProfileStateDatabaseFiles } from '../persistence/profile-state/profile-state-storage-classification' export type TransferProfileState = PersistedState @@ -61,7 +62,7 @@ export function profileStateStorage(profileId: string, userDataPath: string): Pr return 'sqlite' } if (!hasDatabase) { - assertNoRetainedProfileStateExports({ dataFile, databaseFile, profileId }) + assertProfileStateCanInitialize({ dataFile, databaseFile, profileId }) } return hasDatabase ? 'sqlite' : 'json' } @@ -117,7 +118,7 @@ function parseProfileState(rawJson: string | undefined): TransferProfileState { if (rawJson === undefined) { return structuredClone(getDefaultPersistedState(homedir())) } - return normalizeProfileProjectState(JSON.parse(rawJson)) + return normalizeProfileProjectState(parseProfileStateRoot(rawJson)) } export function normalizeProfileProjectState( diff --git a/src/main/orca-profiles/profile-project-transfer-migration.test.ts b/src/main/orca-profiles/profile-project-transfer-migration.test.ts index 99ed6da11df1..582c569dcc7a 100644 --- a/src/main/orca-profiles/profile-project-transfer-migration.test.ts +++ b/src/main/orca-profiles/profile-project-transfer-migration.test.ts @@ -141,6 +141,42 @@ describe('profile transfer migration', () => { expect(existsSync(paths('target').databaseFile)).toBe(true) }) + it.each(['copy', 'move'] as const)( + '%s refuses an apparently empty target that retains a legacy JSON backup', + (mode) => { + writeState('source', true, [repo]) + const sourceRevision = readProfileStateWithRevision('source', directory).revision + const targetJson = writeState('target', false) + const target = paths('target') + writeFileSync(`${target.dataFile}.bak.0`, targetJson) + rmSync(target.dataFile) + + expect(() => transfer(mode)).toThrow('restore a selected backup') + expect(readProfileStateWithRevision('source', directory)).toMatchObject({ + revision: sourceRevision, + state: { repos: [repo] } + }) + expect(existsSync(target.dataFile)).toBe(false) + expect(existsSync(target.databaseFile)).toBe(false) + expect(readFileSync(`${target.dataFile}.bak.0`, 'utf8')).toBe(targetJson) + } + ) + + it.each(['[]', '7', '"invalid"', 'true'])( + 'refuses a non-object JSON target (%s) without replacing its contents', + (raw) => { + writeState('source', false, [repo]) + writeState('target', false) + const target = paths('target') + writeFileSync(target.dataFile, raw) + + expect(() => transfer()).toThrow('Profile state JSON root must be an object') + expect(readFileSync(target.dataFile, 'utf8')).toBe(raw) + expect(existsSync(target.databaseFile)).toBe(false) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + } + ) + it('migrates a JSON source before moving into SQLite and retains its exact rollback bytes', () => { const sourceJson = writeState('source', false, [repo]) writeState('target', true) diff --git a/src/main/persistence/loading-store/backup-recovery-rotation.ts b/src/main/persistence/loading-store/backup-recovery-rotation.ts index 7cf0a7a80781..7ed6b21249ee 100644 --- a/src/main/persistence/loading-store/backup-recovery-rotation.ts +++ b/src/main/persistence/loading-store/backup-recovery-rotation.ts @@ -11,12 +11,13 @@ import { import { access, copyFile, rename, rm, stat } from 'node:fs/promises' import { dirname } from 'node:path' -const BACKUP_COUNT = 5 -const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 +import { + PROFILE_STATE_LEGACY_BACKUP_COUNT as BACKUP_COUNT, + profileStateLegacyBackupPath as backupPath +} from '../profile-state/profile-state-legacy-backup-path' +export { hasStateBackup } from '../profile-state/profile-state-legacy-backup-path' -function backupPath(dataFile: string, index: number): string { - return `${dataFile}.bak.${index}` -} +const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 /** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ async function exists(path: string): Promise { @@ -26,18 +27,7 @@ async function exists(path: string): Promise { ) } -export function hasStateBackup(dataFile: string): boolean { - for (let index = 0; index < BACKUP_COUNT; index += 1) { - if (existsSync(backupPath(dataFile, index))) { - return true - } - } - return false -} - -import type { StoreRuntimeState } from './store-runtime-state' - -type BackupRecoveryRotationOperationsRuntime = Pick +type BackupRecoveryRotationOperationsRuntime = { backupRotationInFlight: boolean } export class BackupRecoveryRotationOperations { constructor(private readonly runtime: BackupRecoveryRotationOperationsRuntime) {} diff --git a/src/main/persistence/loading-store/profile-state-authority.ts b/src/main/persistence/loading-store/profile-state-authority.ts index f366622a1fb7..355df722e94f 100644 --- a/src/main/persistence/loading-store/profile-state-authority.ts +++ b/src/main/persistence/loading-store/profile-state-authority.ts @@ -1,3 +1,4 @@ +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' import type { AutomationRun } from '../../../shared/automations-types' import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' @@ -108,11 +109,14 @@ export type ProfileStateDomainReplacement = { payload: string | null } +export type ProfileStateStartupPaneAlias = Omit + /** A startup read paired with the authority that observed its revision. */ export type ProfileStateAuthorityInitialState< Authority extends ProfileStatePersistenceAuthority = ProfileStateAuthority > = { readonly authority: Authority + readonly unboundPaneAliases?: readonly ProfileStateStartupPaneAlias[] } & ( | { readonly serializedState: string | undefined; readonly takeParsedState?: never } | { diff --git a/src/main/persistence/loading-store/profile-state-checkpoints.test.ts b/src/main/persistence/loading-store/profile-state-checkpoints.test.ts index a57fb98c73f9..1ef5c6ef556b 100644 --- a/src/main/persistence/loading-store/profile-state-checkpoints.test.ts +++ b/src/main/persistence/loading-store/profile-state-checkpoints.test.ts @@ -73,6 +73,14 @@ const EXPECTED_CHECKPOINT = { } describe('complete profile state checkpoints', () => { + it('does not retain a save timer after writes are frozen', () => { + const { store } = fixture() + store.freezeWrites() + const setTimer = vi.spyOn(globalThis, 'setTimeout') + scheduleSave(store) + expect(setTimer).not.toHaveBeenCalled() + }) + it.each(['sync', 'async'] as const)( 'rejects a stale %s checkpoint even when the local hash is unchanged', async (mode) => { diff --git a/src/main/persistence/loading-store/profile-state-import-lifetime.test.ts b/src/main/persistence/loading-store/profile-state-import-lifetime.test.ts new file mode 100644 index 000000000000..45f4111551b7 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-import-lifetime.test.ts @@ -0,0 +1,117 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { agentHookServer } from '../../agent-hooks/server' +import { + clearMigrationUnsupportedPty, + setMigrationUnsupportedPty, + setMigrationUnsupportedPtyPersistenceListener +} from '../../agent-hooks/migration-unsupported-pty-state' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import * as composition from './store-domain-composition' +import { scheduleSave } from './write-scheduling' +import { Store } from './store' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] +const stores: Store[] = [] +const livePaneKey = 'live-tab:11111111-1111-4111-8111-111111111111' + +afterEach(async () => { + agentHookServer.setPaneKeyAliasPersistenceListener(null) + setMigrationUnsupportedPtyPersistenceListener(null) + agentHookServer.clearPaneKeyAliasesForPty('later-live-pty') + clearMigrationUnsupportedPty('later-live-pty') + for (const store of stores.splice(0)) { + await store.freezeWritesAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it.each([false, true])('isolates imported aliases and live listeners (load failure=%s)', (fail) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const directory = mkdtempSync(join(tmpdir(), 'orca-import-lifetime-')) + directories.push(directory) + const live = new Store({ dataFile: join(directory, 'live', 'orca-data.json') }) + stores.push(live) + const source = buildProfileStateCutoverFixture(directory) + for (const session of [ + source.workspaceSession, + ...Object.values(source.workspaceSessionsByHostId ?? {}) + ]) { + if (!session) { + continue + } + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId)) { + layout.root = { type: 'leaf', leafId: 'pane:1' } + layout.activeLeafId = 'pane:1' + layout.ptyIdsByLeafId = { 'pane:1': `imported-${tabId}` } + } + } + const registerAlias = vi.spyOn(agentHookServer, 'registerPaneKeyAlias') + const replaceListener = vi.spyOn(agentHookServer, 'setPaneKeyAliasPersistenceListener') + if (fail) { + const createDomains = composition.createStoreDomains + vi.spyOn(composition, 'createStoreDomains').mockImplementationOnce((runtime) => { + const domains = createDomains(runtime) + vi.spyOn(domains.adaptation, 'hydrateFolderWorkspaceDiffComments').mockImplementationOnce( + () => { + scheduleSave(domains.scheduling) + throw new Error('normalization refused') + } + ) + return domains + }) + } + const pendingTimers = vi.getTimerCount() + const createImport = () => + new Store({ + dataFile: join(directory, 'imported', 'orca-data.json'), + serializedState: JSON.stringify(source) + }) + if (fail) { + expect(createImport).toThrow('normalization refused') + } else { + const imported = createImport() + stores.push(imported) + expect(JSON.parse(imported.prepareProfileStateExport().json).legacyPaneKeyAliasEntries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ legacyPaneKey: 'tab-local:1', ptyId: 'imported-tab-local' }), + expect.objectContaining({ legacyPaneKey: 'tab-remote:1', ptyId: 'imported-tab-remote' }) + ]) + ) + } + expect(registerAlias).not.toHaveBeenCalled() + expect(replaceListener).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(pendingTimers) + agentHookServer.registerPaneKeyAlias('live-tab:1', livePaneKey, 'later-live-pty') + setMigrationUnsupportedPty({ + ptyId: 'later-live-pty', + paneKey: livePaneKey, + tabId: 'live-tab', + worktreeId: 'live-worktree', + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1 + }) + const persisted = JSON.parse(live.prepareProfileStateExport().json) + expect(persisted.legacyPaneKeyAliasEntries).toEqual([ + expect.objectContaining({ legacyPaneKey: 'live-tab:1', ptyId: 'later-live-pty' }) + ]) + expect(persisted.migrationUnsupportedPtyEntries).toEqual([ + expect.objectContaining({ ptyId: 'later-live-pty', paneKey: livePaneKey }) + ]) +}) diff --git a/src/main/persistence/loading-store/pty-binding-async-durability.test.ts b/src/main/persistence/loading-store/pty-binding-async-durability.test.ts index ef4de7e130a8..7892205be066 100644 --- a/src/main/persistence/loading-store/pty-binding-async-durability.test.ts +++ b/src/main/persistence/loading-store/pty-binding-async-durability.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' import { fixture } from './profile-state-delayed-authority-fixture' @@ -95,6 +95,60 @@ describe('durable asynchronous PTY binding', () => { }) }) + it.each(['tab title', 'pane title'] as const)( + 'rolls back a failed replacement while preserving a newer %s', + async (edit) => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect( + store.persistPtyBinding({ + ...binding, + ptyId: 'failed-replacement', + incarnationId: 'failed-incarnation', + expectedBinding: binding + }) + ).rejects.toThrow('disk refused') + await gate.started.promise + const session = store.getWorkspaceSession() + const tab = session.tabsByWorktree[binding.worktreeId].find( + (candidate) => candidate.id === binding.tabId + ) + if (!tab) { + throw new Error('binding did not create its terminal row') + } + if (edit === 'tab title') { + tab.customTitle = 'new title' + } else { + session.terminalLayoutsByTabId[binding.tabId].titlesByLeafId = { + [binding.leafId]: 'new title' + } + } + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession + expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ + [binding.leafId]: binding.ptyId + }) + expect(persisted.terminalPtyIncarnationsByPaneKey[`${binding.tabId}:${binding.leafId}`]).toBe( + binding.incarnationId + ) + if (edit === 'tab title') { + expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( + expect.objectContaining({ id: binding.tabId, customTitle: 'new title' }) + ) + } else { + expect(persisted.terminalLayoutsByTabId[binding.tabId].titlesByLeafId).toEqual({ + [binding.leafId]: 'new title' + }) + } + } + ) + it('rolls back a failed binding while retaining an unrelated newer navigation edit', async () => { const { store, authority } = await fixture() vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -110,6 +164,47 @@ describe('durable asynchronous PTY binding', () => { expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId]).toBeUndefined() }) + it('retains a newer root sibling when rolling back a failed leaf replacement', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect( + store.persistPtyBinding({ ...binding, ptyId: 'failed-replacement' }) + ).rejects.toThrow('disk refused') + await gate.started.promise + const session = store.getWorkspaceSession() + const tab = session.tabsByWorktree[binding.worktreeId].find( + (candidate) => candidate.id === binding.tabId + ) + if (!tab) { + throw new Error('binding did not create its terminal row') + } + const layout = session.terminalLayoutsByTabId[binding.tabId] + layout.root = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: TEST_LEAF_2 }, + second: { type: 'leaf', leafId: binding.leafId } + } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [TEST_LEAF_2]: 'new-root-pty' } + tab.ptyId = 'new-root-pty' + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession + expect(persisted.terminalLayoutsByTabId[binding.tabId].root).toEqual(layout.root) + expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ + [binding.leafId]: binding.ptyId, + [TEST_LEAF_2]: 'new-root-pty' + }) + expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( + expect.objectContaining({ id: binding.tabId, ptyId: 'new-root-pty' }) + ) + }) + it('removes a failed new binding while retaining a newer sibling tab', async () => { const { store, authority } = await fixture() vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts b/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts new file mode 100644 index 000000000000..0feed681bf03 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts @@ -0,0 +1,135 @@ +import { expect, it, vi } from 'vitest' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' +import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +it.each(['tab title', 'pane title', 'new sibling'] as const)( + 'retains a valid unbound new surface after a failed binding and newer %s', + async (edit) => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'new-binding-tab', + leafId: TEST_LEAF_1, + ptyId: 'failed-pty', + incarnationId: 'failed-incarnation' + } + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + const session = store.getWorkspaceSession() + const tab = session.tabsByWorktree[binding.worktreeId].find( + (candidate) => candidate.id === binding.tabId + ) + if (!tab) { + throw new Error('binding did not create its terminal row') + } + const layout = session.terminalLayoutsByTabId[binding.tabId] + if (edit === 'tab title') { + tab.customTitle = 'new title' + } else if (edit === 'pane title') { + layout.titlesByLeafId = { [binding.leafId]: 'new title' } + } else { + layout.root = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: binding.leafId }, + second: { type: 'leaf', leafId: TEST_LEAF_2 } + } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [TEST_LEAF_2]: 'sibling-pty' } + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [`${binding.tabId}:${TEST_LEAF_2}`]: 'sibling-incarnation' + } + tab.ptyId = 'sibling-pty' + } + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession + expect(JSON.stringify(persisted)).not.toContain('failed-pty') + expect(JSON.stringify(persisted)).not.toContain('failed-incarnation') + expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( + expect.objectContaining({ + id: binding.tabId, + worktreeId: binding.worktreeId, + createdAt: expect.any(Number), + ptyId: edit === 'new sibling' ? 'sibling-pty' : null, + ...(edit === 'tab title' ? { customTitle: 'new title' } : {}) + }) + ) + expect(persisted.terminalLayoutsByTabId[binding.tabId]).toMatchObject({ + root: layout.root, + ...(edit === 'pane title' ? { titlesByLeafId: { [binding.leafId]: 'new title' } } : {}) + }) + if (edit === 'new sibling') { + expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ + [TEST_LEAF_2]: 'sibling-pty' + }) + expect(persisted.terminalPtyIncarnationsByPaneKey).toEqual({ + [`${binding.tabId}:${TEST_LEAF_2}`]: 'sibling-incarnation' + }) + } + } +) + +it('preserves the valid newer tree after a failed split insertion', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'split-binding-tab', + leafId: TEST_LEAF_1, + ptyId: 'original-pty', + incarnationId: 'original-incarnation' + } + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect( + store.persistPtyBinding({ + ...binding, + leafId: TEST_LEAF_2, + ptyId: 'failed-pty', + incarnationId: 'failed-incarnation' + }) + ).rejects.toThrow('disk refused') + await gate.started.promise + const layout = store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId] + if (!layout.root) { + throw new Error('binding did not create its layout') + } + const laterLeaf = '33333333-3333-4333-8333-333333333333' + layout.root = { + type: 'split', + direction: 'horizontal', + first: layout.root, + second: { type: 'leaf', leafId: laterLeaf } + } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [laterLeaf]: 'later-sibling-pty' } + const expectedRoot = structuredClone(layout.root) + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession.terminalLayoutsByTabId[binding.tabId] + expect(persisted.root).toEqual(expectedRoot) + expect(collectLayoutLeafIdsInOrder(persisted.root)).toContain(laterLeaf) + expect(persisted.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'original-pty', + [laterLeaf]: 'later-sibling-pty' + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index faf828932fce..f902ff7d2526 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -1,7 +1,8 @@ import { isDeepStrictEqual } from 'node:util' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../restoring-sessions/workspace-session-write-rollback' +import { rollbackFailedPtyBinding } from './pty-binding-write-rollback' import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields' import type { PtyBindingSourceExpectation } from './store' @@ -154,18 +155,29 @@ function writePtyBinding( const boundSession = cloneWorkspaceSessionState(session) return () => { const current = sessions.getWorkspaceSession(resolvedHostId) - const ownerState = (value: WorkspaceSessionState) => ({ - tab: value.tabsByWorktree[bindingWorktreeId]?.find((tab) => tab.id === args.tabId), - layout: value.terminalLayoutsByTabId[args.tabId], - incarnation: value.terminalPtyIncarnationsByPaneKey?.[paneKey] - }) + const ownerState = (value: WorkspaceSessionState) => { + const tab = value.tabsByWorktree[bindingWorktreeId]?.find((tab) => tab.id === args.tabId) + return { + createdAt: tab?.createdAt, + generation: tab?.generation, + worktreeId: tab?.worktreeId, + ptyId: isTerminalLeafId(args.leafId) + ? value.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId?.[args.leafId] + : tab?.ptyId, + incarnation: value.terminalPtyIncarnationsByPaneKey?.[paneKey] + } + } + // Presentation edits do not replace the binding that must be rolled back. if (!isDeepStrictEqual(ownerState(current), ownerState(boundSession))) { return } - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( + const rolledBack = rollbackFailedPtyBinding( sessionBeforeBinding, boundSession, - current + current, + bindingWorktreeId, + args.tabId, + args.leafId ) if (rolledBack !== current) { restore(rolledBack) diff --git a/src/main/persistence/loading-store/pty-binding-write-rollback.ts b/src/main/persistence/loading-store/pty-binding-write-rollback.ts new file mode 100644 index 000000000000..af7cb63d7b0d --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-write-rollback.ts @@ -0,0 +1,94 @@ +import { isDeepStrictEqual } from 'node:util' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../restoring-sessions/workspace-session-write-rollback' + +function restoreBindingSlot( + original: Record | undefined, + staged: Record | undefined, + current: Record | undefined, + key: string +): Record | undefined { + if (current?.[key] !== staged?.[key] || original?.[key] === staged?.[key]) { + return current + } + const restored = { ...current } + const previous = original?.[key] + if (previous === undefined) { + delete restored[key] + } else { + restored[key] = previous + } + return original === undefined && Object.keys(restored).length === 0 ? undefined : restored +} + +export function rollbackFailedPtyBinding( + original: WorkspaceSessionState, + staged: WorkspaceSessionState, + current: WorkspaceSessionState, + worktreeId: string, + tabId: string, + leafId: string +): WorkspaceSessionState { + const tab = (session: WorkspaceSessionState) => + session.tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + const stagedTab = tab(staged) + const originalLayout = original.terminalLayoutsByTabId[tabId] + const stagedLayout = staged.terminalLayoutsByTabId[tabId] + let baseline = original + if ( + stagedTab && + (!isDeepStrictEqual(tab(current), stagedTab) || + !isDeepStrictEqual(current.terminalLayoutsByTabId[tabId], stagedLayout)) + ) { + // Keep edited new surfaces structurally valid, without the failed process binding. + baseline = { + ...original, + tabsByWorktree: tab(original) + ? original.tabsByWorktree + : { + ...original.tabsByWorktree, + [worktreeId]: [ + ...(original.tabsByWorktree[worktreeId] ?? []), + { ...stagedTab, ptyId: null } + ] + }, + terminalLayoutsByTabId: + originalLayout || !stagedLayout + ? original.terminalLayoutsByTabId + : { + ...original.terminalLayoutsByTabId, + [tabId]: { ...stagedLayout, ptyIdsByLeafId: {} } + } + } + } + const restored = rollbackWorkspaceSessionAfterFailedAsyncWrite(baseline, staged, current) + const layout = restored.terminalLayoutsByTabId[tabId] + const currentRoot = current.terminalLayoutsByTabId[tabId]?.root + return { + ...restored, + ...(layout + ? { + terminalLayoutsByTabId: { + ...restored.terminalLayoutsByTabId, + [tabId]: { + ...layout, + // A tree is one value; fieldwise rollback can mix leaf and split node shapes. + root: isDeepStrictEqual(currentRoot, stagedLayout?.root) ? layout.root : currentRoot, + ptyIdsByLeafId: restoreBindingSlot( + originalLayout?.ptyIdsByLeafId, + stagedLayout?.ptyIdsByLeafId, + layout.ptyIdsByLeafId, + leafId + ) + } + } + } + : {}), + terminalPtyIncarnationsByPaneKey: restoreBindingSlot( + original.terminalPtyIncarnationsByPaneKey, + staged.terminalPtyIncarnationsByPaneKey, + restored.terminalPtyIncarnationsByPaneKey, + `${tabId}:${leafId}` + ) + } +} diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index c3f2059efe1c..707110cee213 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -65,6 +65,23 @@ import { installSshLeaseRecoveryOperationsContext } from './ssh-lease-recovery-operations' +export type StoreDomainOperations = WriteSchedulingOperations & + PrimaryStateWriteOperations & + ProjectCollectionOperations & + RepoLifecycleOperations & + MobileTabSelectionPersistence & + SparsePresetPersistence & + AutomationPersistence & + MetadataLineageOperations & + ProfilePreferences & + SessionHostPartitionOperations & + SessionSnapshotOperations & + PtyBindingPersistenceOperations & + SshProfileOperations & + RetiredWorktreeNamePersistence & + SshLeaseRecoveryOperations & + WriteFlushBarrierOperations + export type StoreDomains = { adaptation: LoadedStateAdaptationOperations backups: BackupRecoveryRotationOperations diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 4dac86f5845e..14588aa4a408 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -13,28 +13,13 @@ import { createStoreDomains, installStoreDomainContexts, STORE_DOMAIN_OPERATION_CLASSES, - type StoreDomains + type StoreDomains, + type StoreDomainOperations } from './store-domain-composition' import type { PersistedState } from '../../../shared/persisted-state-types' import { scheduleSave } from './write-scheduling' import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' -import type { WriteSchedulingOperations } from './write-scheduling' -import type { PrimaryStateWriteOperations } from './primary-state-writes' import { enqueuePrimaryStateOperation, writeToDiskAsync } from './primary-state-writes' -import type { ProjectCollectionOperations } from './project-collection-operations' -import type { RepoLifecycleOperations } from './repo-lifecycle-operations' -import type { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence' -import type { SparsePresetPersistence } from './sparse-preset-persistence' -import type { AutomationPersistence } from './automation-persistence' -import type { MetadataLineageOperations } from './metadata-lineage-operations' -import type { ProfilePreferences } from './profile-preferences' -import type { SessionHostPartitionOperations } from './session-host-partitions' -import type { SessionSnapshotOperations } from './session-snapshot-operations' -import type { PtyBindingPersistenceOperations } from './pty-binding-persistence' -import type { SshProfileOperations } from './ssh-profile-operations' -import type { RetiredWorktreeNamePersistence } from './retired-worktree-name-persistence' -import type { SshLeaseRecoveryOperations } from './ssh-lease-recovery-operations' -import type { WriteFlushBarrierOperations } from './write-flush-barriers' import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' import { writeVersionedProfileStateExport } from '../profile-state/profile-state-versioned-export' import { @@ -46,6 +31,7 @@ import { import type { AsyncProfileStateAuthority, ProfileStateAuthorityInitialState, + ProfileStateStartupPaneAlias, ProfileStatePersistenceAuthority, ProfileStateMaintenance } from './profile-state-authority' @@ -53,6 +39,7 @@ import type { export type StoreOptions = StoreRuntimeOptions & { /** Storage-form JSON supplied by a read-only profile migration/import boundary. */ serializedState?: string + collectUnboundPaneAlias?: (entry: ProfileStateStartupPaneAlias) => void /** Reuse the authority's validated startup read without retaining a cached copy. */ initialAuthorityState?: ProfileStateAuthorityInitialState } @@ -89,7 +76,9 @@ export class Store { } const initial = options.initialAuthorityState const parsedState = initial?.takeParsedState?.() + const imported = options.serializedState !== undefined this.runtime = new StoreRuntimeState(options) + this.runtime.writesFrozen = imported this.domains = createStoreDomains(this.runtime) installStoreDomainContexts(this, this.domains) this.runtime.flushOrThrow = () => this.flushOrThrow() @@ -111,7 +100,10 @@ export class Store { } else { loaded = this.domains.loader.load() } - const normalized = normalizePersistedPaneIdentityState(loaded) + const normalized = normalizePersistedPaneIdentityState(loaded, { + registerAliases: !imported, + collectUnboundPaneAlias: options.collectUnboundPaneAlias + }) this.state = normalized.state this.runtime.state = this.state this.runtime.activeViewPreference = new ActiveViewPreference( @@ -123,32 +115,38 @@ export class Store { // Load is the only place an orphaned repo id can be swept: every removal path needs the repo to // still be registered, so rows outlive their owner without one (#17776). const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue() - for (const entry of normalized.migrationUnsupportedEntries) { - setMigrationUnsupportedPty(entry) - } - for (const entry of normalized.legacyPaneKeyAliasEntries) { - registerPersistedPaneKeyAlias(entry) - } - setMigrationUnsupportedPtyPersistenceListener((entries) => { - this.state.migrationUnsupportedPtyEntries = entries - scheduleSave(this.domains.scheduling) - }) - agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { - this.state.legacyPaneKeyAliasEntries = entries - scheduleSave(this.domains.scheduling) - }) - if ( - normalized.changed || - this.runtime.loadNeedsSave || - adaptedProjectGroups || - sweptRepoIds.length > 0 - ) { - scheduleSave(this.domains.scheduling) - } - // An imported source is not a legacy JSON authority. The caller must - // commit through its database/export boundary instead of writing a file. - if (options.serializedState !== undefined) { - this.freezeWrites() + // Imported snapshots cannot own the live hook server or write their source file. + if (!imported) { + for (const entry of initial?.unboundPaneAliases ?? []) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + undefined, + entry.updatedAt + ) + } + for (const entry of normalized.migrationUnsupportedEntries) { + setMigrationUnsupportedPty(entry) + } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + registerPersistedPaneKeyAlias(entry) + } + setMigrationUnsupportedPtyPersistenceListener((entries) => { + this.state.migrationUnsupportedPtyEntries = entries + scheduleSave(this.domains.scheduling) + }) + agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { + this.state.legacyPaneKeyAliasEntries = entries + scheduleSave(this.domains.scheduling) + }) + if ( + normalized.changed || + this.runtime.loadNeedsSave || + adaptedProjectGroups || + sweptRepoIds.length > 0 + ) { + scheduleSave(this.domains.scheduling) + } } } @@ -312,24 +310,7 @@ export class Store { } // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging derives Store's prototype API directly from the exact concrete domain classes installed below -export interface Store - extends - WriteSchedulingOperations, - PrimaryStateWriteOperations, - ProjectCollectionOperations, - RepoLifecycleOperations, - MobileTabSelectionPersistence, - SparsePresetPersistence, - AutomationPersistence, - MetadataLineageOperations, - ProfilePreferences, - SessionHostPartitionOperations, - SessionSnapshotOperations, - PtyBindingPersistenceOperations, - SshProfileOperations, - RetiredWorktreeNamePersistence, - SshLeaseRecoveryOperations, - WriteFlushBarrierOperations {} +export interface Store extends StoreDomainOperations {} for (const OperationClass of STORE_DOMAIN_OPERATION_CLASSES) { const descriptors = Object.getOwnPropertyDescriptors(OperationClass.prototype) diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts index a05d7f70cad6..bf1b2c8a1674 100644 --- a/src/main/persistence/loading-store/write-flush-barriers.ts +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -46,27 +46,25 @@ export class WriteFlushBarrierOperations { } flush(): void { - this[writeFlushBarrierOperationsContext].runtime.automationListProjectionCache = null - if ( - this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted || - this[writeFlushBarrierOperationsContext].runtime.profileMaintenancePending - ) { + const { runtime, writes } = this[writeFlushBarrierOperationsContext] + runtime.automationListProjectionCache = null + if (runtime.quitFlushStarted || runtime.profileMaintenancePending) { return } - if (this[writeFlushBarrierOperationsContext].runtime.profileStateAuthority?.asynchronous) { - this[writeFlushBarrierOperationsContext].runtime.writeGeneration++ + if (runtime.profileStateAuthority?.asynchronous) { + runtime.writeGeneration++ void flushCurrentStateAsync(this, false, undefined, false).catch((error) => console.error('[persistence] Failed to flush state:', error) ) return } try { - this[writeFlushBarrierOperationsContext].writes.flushOrThrow() + writes.flushOrThrow() } catch (err) { console.error('[persistence] Failed to flush state:', err) } try { - this[writeFlushBarrierOperationsContext].writes.flushActiveViewPreferenceOrThrow() + writes.flushActiveViewPreferenceOrThrow() } catch (err) { console.error('[active-view] Failed to flush preference:', err) } @@ -121,11 +119,8 @@ export class WriteFlushBarrierOperations { flushPendingOrThrowAsync( options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} ): Promise { - if ( - this[writeFlushBarrierOperationsContext].runtime.writesFrozen || - this[writeFlushBarrierOperationsContext].runtime.profileMaintenancePending || - this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted - ) { + const { runtime } = this[writeFlushBarrierOperationsContext] + if (runtime.writesFrozen || runtime.profileMaintenancePending || runtime.quitFlushStarted) { return Promise.reject(new Error('Cannot flush while persistence is finalized')) } return flushCurrentStateAsync( diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts index 888c93b9eb15..820334dfd7e9 100644 --- a/src/main/persistence/loading-store/write-scheduling.ts +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -16,6 +16,7 @@ type WriteSchedulingOperationsRuntime = Pick< | 'quitFlushStarted' | 'writeGeneration' | 'writeTimer' + | 'writesFrozen' > const writeSchedulingOperationsContext = Symbol('WriteSchedulingOperations') @@ -32,10 +33,8 @@ export class WriteSchedulingOperations { } async waitForPendingWrite(): Promise { - await Promise.all([ - this[writeSchedulingOperationsContext].runtime.pendingWrite, - this[writeSchedulingOperationsContext].runtime.activeViewPreference.waitForPendingWrite() - ]) + const { runtime } = this[writeSchedulingOperationsContext] + await Promise.all([runtime.pendingWrite, runtime.activeViewPreference.waitForPendingWrite()]) } } @@ -43,39 +42,35 @@ export function scheduleSave( owner: WriteSchedulingOperations, dirtyDomains?: readonly string[] ): void { - owner[writeSchedulingOperationsContext].runtime.automationListProjectionCache = null - const trackedDomains = owner[writeSchedulingOperationsContext].runtime.dirtyProfileStateDomains + const { runtime, writes } = owner[writeSchedulingOperationsContext] + runtime.automationListProjectionCache = null + const trackedDomains = runtime.dirtyProfileStateDomains if (dirtyDomains === undefined) { - owner[writeSchedulingOperationsContext].runtime.dirtyProfileStateDomains = null + runtime.dirtyProfileStateDomains = null } else if (trackedDomains !== null) { for (const domain of dirtyDomains) { trackedDomains.add(domain) } } - // Why: once the quit flush has snapshotted, a newly debounced write would fire during - // teardown with nothing awaiting it, and the process can exit mid-rename. The quit - // flush is the last write by construction. - if (owner[writeSchedulingOperationsContext].runtime.quitFlushStarted) { + // A timer admitted after the final snapshot could outlive the awaited shutdown work. + if (runtime.quitFlushStarted) { return } - owner[writeSchedulingOperationsContext].runtime.writeGeneration += 1 - if (owner[writeSchedulingOperationsContext].runtime.profileMaintenancePending) { + runtime.writeGeneration += 1 + if (runtime.writesFrozen || runtime.profileMaintenancePending) { return } const now = Date.now() - owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt ??= now - if (owner[writeSchedulingOperationsContext].runtime.writeTimer) { - clearTimeout(owner[writeSchedulingOperationsContext].runtime.writeTimer) + runtime.firstPendingSaveAt ??= now + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) } - const untilMaxWait = Math.max( - 0, - owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt + SAVE_MAX_WAIT_MS - now - ) + const untilMaxWait = Math.max(0, runtime.firstPendingSaveAt + SAVE_MAX_WAIT_MS - now) const delay = Math.min(SAVE_DEBOUNCE_MS, untilMaxWait) - owner[writeSchedulingOperationsContext].runtime.writeTimer = setTimeout(() => { - owner[writeSchedulingOperationsContext].runtime.writeTimer = null - owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt = null - void enqueueWrite(owner[writeSchedulingOperationsContext].writes).catch(() => {}) + runtime.writeTimer = setTimeout(() => { + runtime.writeTimer = null + runtime.firstPendingSaveAt = null + void enqueueWrite(writes).catch(() => {}) }, delay) } diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts index 4f2bfe6140ca..59d57d6eb6c3 100644 --- a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts @@ -8,7 +8,7 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import Database from '../../sqlite/sync-database' import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' @@ -276,6 +276,29 @@ describe('profile state authority bootstrap', () => { expect(readdirSync(dirname(options.dataFile))).toEqual(['orca-data.json']) }) + it.each(['legacy-backup', 'sqlite-export'])( + 'preserves a %s created while an empty database is being initialized', + (artifact) => { + const options = { ...paths(createDirectory()), allowEmptyProfileState: true } + const path = + artifact === 'legacy-backup' + ? `${options.dataFile}.bak.0` + : profileStateJsonExportPath(options.dataFile, 1) + const source = '{"settings":{"theme":"dark"}}' + const originalClose = Database.prototype.close + vi.spyOn(Database.prototype, 'close').mockImplementationOnce(function (this: Database) { + originalClose.call(this) + writeFileSync(path, source) + }) + + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + expect(existsSync(options.databaseFile)).toBe(false) + expect(existsSync(options.dataFile)).toBe(false) + expect(readFileSync(path, 'utf8')).toBe(source) + expect(readdirSync(dirname(path))).toEqual([basename(path)]) + } + ) + it.each(['-wal', '-shm', '-journal'])( 'treats an orphaned SQLite %s sidecar as authority evidence with or without JSON', (suffix) => { diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts index bd4b4811dd99..bcafeab6366e 100644 --- a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts @@ -2,13 +2,23 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { randomUUID } from 'node:crypto' import { dirname } from 'node:path' import { publishFileDurableSync } from '../../durable-file-write' -import type { ProfileStateAuthorityInitialState } from '../loading-store/profile-state-authority' +import type { + ProfileStateAuthorityInitialState, + ProfileStateStartupPaneAlias +} from '../loading-store/profile-state-authority' import { Store } from '../loading-store/store' import { isProfileStateSqliteAvailable, openProfileStateDatabase } from './profile-state-database' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' import { migrateProfileStateToSqlite } from './profile-state-migration' -import { ProfileStateRecoveryRequiredError } from './profile-state-recovery-required' -export { ProfileStateRecoveryRequiredError } from './profile-state-recovery-required' +import { + assertProfileStateCanInitialize, + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-recovery-required' +export { + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-recovery-required' import { classifyProfileStateStorage, profileStateDatabaseFiles, @@ -37,15 +47,6 @@ export type ProfileStateAuthorityBootstrapOptions = { allowEmptyProfileState?: boolean } -export class ProfileStateAuthorityBootstrapError extends Error { - readonly code = 'ambiguous-profile-state' as const - - constructor(message: string) { - super(message) - this.name = 'ProfileStateAuthorityBootstrapError' - } -} - /** Normalize legacy state once, then hand one validated authority to Store. */ export function bootstrapProfileStateAuthority( options: ProfileStateAuthorityBootstrapOptions @@ -62,6 +63,9 @@ export function bootstrapProfileStateAuthority( 'SQLite profile state is present but this runtime cannot validate it' ) } + if (classification === 'json-only' || classification === 'neither') { + assertProfileStateCanInitialize(options) + } if (classification === 'json-only') { return migrateJsonOnlyProfile(options) } @@ -110,6 +114,7 @@ function createEmptyProfileStateDatabase({ 'Profile state storage changed while creating an empty database' ) } + assertProfileStateCanInitialize({ dataFile, databaseFile, profileId }) if (!publishFileDurableSync(temporaryDatabaseFile, databaseFile)) { throw new ProfileStateAuthorityBootstrapError( 'Profile state storage changed while creating an empty database' @@ -131,7 +136,12 @@ function migrateJsonOnlyProfile( const rawJson = readFileSync(options.dataFile, 'utf8') // serializedState makes malformed input fail closed and prevents backup // recovery or a normalization write from changing the legacy source. - const store = new Store({ dataFile: options.dataFile, serializedState: rawJson }) + const unboundPaneAliases: ProfileStateStartupPaneAlias[] = [] + const store = new Store({ + dataFile: options.dataFile, + serializedState: rawJson, + collectUnboundPaneAlias: (entry) => unboundPaneAliases.push(entry) + }) const prepared = store.prepareProfileStateExport() const migrated = migrateProfileStateToSqlite({ ...options, @@ -139,5 +149,10 @@ function migrateJsonOnlyProfile( serializedState: prepared.json }) prepared.commit() - return { classification: 'json-only', ...migrated, migrated: true } + return { + classification: 'json-only', + ...migrated, + initialState: { ...migrated.initialState, unboundPaneAliases }, + migrated: true + } } diff --git a/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts b/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts index e182ae9d975b..87b202c9612b 100644 --- a/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts +++ b/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts @@ -3,6 +3,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import { migrateProfileStateToSqlite } from './profile-state-migration' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { profileStateJsonExportPath } from './profile-state-export-path' import type { ProfileStateAuthority } from '../loading-store/profile-state-authority' vi.mock('node:fs', async (original) => ({ ...(await original()) })) @@ -28,6 +31,42 @@ afterEach(() => { }) describe('first database publication with competing startup', () => { + it('names its migration export after the snapshot actually captured', () => { + const root = fs.mkdtempSync(join(tmpdir(), 'orca-migration-export-race-')) + roots.push(root) + const options = { + dataFile: join(root, 'orca-data.json'), + databaseFile: join(root, 'profile-state.db'), + profileId: 'migration-export-race', + expectedLegacyJson: '{"settings":{"theme":"dark"}}', + serializedState: '{"settings":{"theme":"dark"}}' + } + fs.writeFileSync(options.dataFile, options.expectedLegacyJson) + const link = fs.linkSync + vi.spyOn(fs, 'linkSync').mockImplementation((from, to) => { + link(from, to) + if (to === options.databaseFile) { + const peer = new ProfileStateSqliteAuthority(options.databaseFile, options.profileId) + try { + peer.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"light"}' }]) + } finally { + peer.close() + } + } + }) + + const migrated = migrateProfileStateToSqlite(options) + authorities.push(migrated.authority) + expect(migrated.authority.revision).toBe(2) + expect(fs.existsSync(profileStateJsonExportPath(options.dataFile, 1))).toBe(false) + expect( + JSON.parse(fs.readFileSync(profileStateJsonExportPath(options.dataFile, 2), 'utf8')) + ).toEqual({ + settings: { theme: 'light' } + }) + expect(fs.readFileSync(options.dataFile, 'utf8')).toBe(options.expectedLegacyJson) + }) + it.each(['empty', 'legacy'])('cannot replace an acknowledged competing %s profile', (kind) => { const root = fs.mkdtempSync(join(tmpdir(), 'orca-bootstrap-publication-race-')) roots.push(root) diff --git a/src/main/persistence/profile-state/profile-state-legacy-backup-path.ts b/src/main/persistence/profile-state/profile-state-legacy-backup-path.ts new file mode 100644 index 000000000000..ba222b61fd77 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-legacy-backup-path.ts @@ -0,0 +1,16 @@ +import { existsSync } from 'node:fs' + +export const PROFILE_STATE_LEGACY_BACKUP_COUNT = 5 + +export function profileStateLegacyBackupPath(dataFile: string, index: number): string { + return `${dataFile}.bak.${index}` +} + +export function hasStateBackup(dataFile: string): boolean { + for (let index = 0; index < PROFILE_STATE_LEGACY_BACKUP_COUNT; index += 1) { + if (existsSync(profileStateLegacyBackupPath(dataFile, index))) { + return true + } + } + return false +} diff --git a/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts index 9b13a608a5cd..f2ee4d88d1e4 100644 --- a/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts @@ -1,8 +1,9 @@ import { build } from 'esbuild' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { agentHookServer } from '../../agent-hooks/server' import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' import type { Store } from '../loading-store/store' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' @@ -79,6 +80,67 @@ function readState(input: ReturnType) { } describe('live profile authority admission', () => { + it.each([false, true])( + 'hands unbound aliases to admitted startup only (worker refused=%s)', + async (refused) => { + const input = options() + const source = buildProfileStateCutoverFixture(join(input.dataFile, '..')) + const session = source.workspaceSession + for (const tab of Object.values(session.tabsByWorktree).flat()) { + tab.ptyId = null + } + session.terminalLayoutsByTabId['tab-local'] = { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: {} + } + writeFileSync(input.dataFile, JSON.stringify(source)) + const register = vi.spyOn(agentHookServer, 'registerPaneKeyAlias') + if (refused) { + await expect( + createLiveProfileStateStore(input, { + workerPath: join(input.dataFile, '..', 'missing-worker.js') + }) + ).rejects.toThrow() + expect(register).not.toHaveBeenCalled() + return + } + const { store } = await open(input) + const leafId = store.getWorkspaceSession().terminalLayoutsByTabId['tab-local'].activeLeafId + const userDataPath = join(input.dataFile, '..') + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + join(userDataPath, 'agent-hooks', 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab-local:1': { + paneKey: 'tab-local:1', + tabId: 'tab-local', + worktreeId: 'repo-local::/fixture/local', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' } + } + } + }) + ) + try { + await agentHookServer.start({ env: 'production', userDataPath }) + expect(agentHookServer.getStatusSnapshot()).toContainEqual( + expect.objectContaining({ + paneKey: `tab-local:${leafId}`, + prompt: 'legacy cached' + }) + ) + } finally { + agentHookServer.stop() + } + } + ) + it('migrates once, loads admitted state and reopens worker-acknowledged writes', async () => { const input = options() writeFileSync( diff --git a/src/main/persistence/profile-state/profile-state-live-store-factory.ts b/src/main/persistence/profile-state/profile-state-live-store-factory.ts index 7fb27a8523e7..bfec215106d3 100644 --- a/src/main/persistence/profile-state/profile-state-live-store-factory.ts +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.ts @@ -37,7 +37,10 @@ export async function createLiveProfileStateStore( dataFile: options.dataFile, storageAuthority: options.storageAuthority, profileStateAuthority: authority, - initialAuthorityState + initialAuthorityState: { + ...initialAuthorityState, + unboundPaneAliases: initial.unboundPaneAliases + } }) } } catch (error) { diff --git a/src/main/persistence/profile-state/profile-state-migration.ts b/src/main/persistence/profile-state/profile-state-migration.ts index de1535027e2b..05d990d073ba 100644 --- a/src/main/persistence/profile-state/profile-state-migration.ts +++ b/src/main/persistence/profile-state/profile-state-migration.ts @@ -4,8 +4,8 @@ import { dirname } from 'node:path' import { publishFileDurableSync } from '../../durable-file-write' import { openProfileStateDatabase } from './profile-state-database' import { hashProfileStateJson, importProfileStateJson } from './profile-state-documents' -import { profileStateJsonExportPath } from './profile-state-export-path' -import { assertNoRetainedProfileStateExports } from './profile-state-recovery-required' +import { writeVersionedProfileStateExport } from './profile-state-versioned-export' +import { assertProfileStateCanInitialize } from './profile-state-recovery-required' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' import { classifyProfileStateStorage, @@ -28,15 +28,13 @@ export function migrateProfileStateToSqlite(options: ProfileStateMigrationOption initialState: ProfileStateAuthorityInitialState } { assertMigrationSourceUnchanged(options) - assertNoRetainedProfileStateExports(options) mkdirSync(dirname(options.databaseFile), { recursive: true }) const temporaryDatabaseFile = `${options.databaseFile}.migration.${process.pid}.${randomUUID()}.tmp` let published = false try { const opened = openProfileStateDatabase(temporaryDatabaseFile, options.profileId) - let revision: number try { - revision = importProfileStateJson( + importProfileStateJson( opened.db, options.serializedState, options.expectedLegacyJson === undefined @@ -55,7 +53,7 @@ export function migrateProfileStateToSqlite(options: ProfileStateMigrationOption published = true const authority = new ProfileStateSqliteAuthority(options.databaseFile, options.profileId) try { - authority.writeJsonExport(profileStateJsonExportPath(options.dataFile, revision)) + writeVersionedProfileStateExport(options.dataFile, (path) => authority.writeJsonExport(path)) const initialState = authority.readInitialState() return { authority, initialState } } catch (error) { @@ -72,6 +70,7 @@ export function migrateProfileStateToSqlite(options: ProfileStateMigrationOption } function assertMigrationSourceUnchanged(options: ProfileStateMigrationOptions): void { + assertProfileStateCanInitialize(options) const expectedClassification = options.expectedLegacyJson === undefined ? 'neither' : 'json-only' if ( classifyProfileStateStorage(options.dataFile, options.databaseFile) !== expectedClassification diff --git a/src/main/persistence/profile-state/profile-state-offline-settings.test.ts b/src/main/persistence/profile-state/profile-state-offline-settings.test.ts index 412d90b13483..45f81ccdb9fd 100644 --- a/src/main/persistence/profile-state/profile-state-offline-settings.test.ts +++ b/src/main/persistence/profile-state/profile-state-offline-settings.test.ts @@ -97,6 +97,18 @@ describe.each(['read', 'update'] as const)('offline settings %s recovery', (oper } }) + it.each([0, 1, 2, 3, 4])('refuses defaults when only legacy backup %s remains', (slot) => { + const location = createLocation() + const backup = `${location.dataFile}.bak.${slot}` + const source = '{"settings":{"agentStatusHooksEnabled":false}}' + writeFileSync(backup, source) + + expect(() => run(location)).toThrow('restore a selected backup') + expect(existsSync(location.dataFile)).toBe(false) + expect(existsSync(location.databaseFile)).toBe(false) + expect(readFileSync(backup, 'utf8')).toBe(source) + }) + it('fails closed when retained exports cannot be enumerated', () => { const location = createLocation() vi.spyOn(exportPaths, 'profileStateJsonExportPaths').mockImplementation(() => { diff --git a/src/main/persistence/profile-state/profile-state-offline-settings.ts b/src/main/persistence/profile-state/profile-state-offline-settings.ts index d3538e64392f..b07fc82f1f56 100644 --- a/src/main/persistence/profile-state/profile-state-offline-settings.ts +++ b/src/main/persistence/profile-state/profile-state-offline-settings.ts @@ -6,6 +6,7 @@ import { getDefaultPersistedState } from '../../../shared/constants' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' import { profileStateJsonMatchesAcceptance } from './profile-state-documents' +import { isRecord, parseProfileStateRoot } from './profile-state-document-validation' import { isProfileStateSqliteAvailable, openProfileStateDatabase, @@ -16,7 +17,7 @@ import { readProfileStateDomainsWithRevisionFromDatabase } from './profile-state-domain-reader' import { writeProfileStateDomain } from './profile-state-domain-writes' -import { assertNoRetainedProfileStateExports } from './profile-state-recovery-required' +import { assertProfileStateCanInitialize } from './profile-state-recovery-required' import { classifyProfileStateStorage } from './profile-state-storage-classification' export type ProfileStateOfflineLocation = { @@ -41,7 +42,7 @@ export function readAgentHookSettingsFromProfileState( ): AgentHookSettings { const classification = classifyProfileStateStorage(location.dataFile, location.databaseFile) if (classification === 'json-only' || classification === 'neither') { - assertNoRetainedProfileStateExports(location) + assertProfileStateCanInitialize(location) return readAgentHookSettingsFromJson(location.dataFile) } @@ -107,9 +108,9 @@ export function updateAgentHookSettingsFromProfileState( return updateAgentHookSettingsInProfileState(location, enabled) } - assertNoRetainedProfileStateExports(location) + assertProfileStateCanInitialize(location) const state = existsSync(location.dataFile) - ? parseRootState(readFileSync(location.dataFile, 'utf8')) + ? parseProfileStateRoot(readFileSync(location.dataFile, 'utf8')) : structuredClone(getDefaultPersistedState(homedir())) const persistedSettings = isRecord(state.settings) ? state.settings : {} const settings = { @@ -184,7 +185,7 @@ function readAgentHookSettingsFromJson(dataFile: string): AgentHookSettings { } function readAgentHookSettingsFromSnapshot(raw: string): AgentHookSettings { - const state = parseRootState(raw) + const state = parseProfileStateRoot(raw) return readAgentHookSettingsFromSettingsValue(state.settings) } @@ -196,21 +197,6 @@ function readAgentHookSettingsFromSettingsValue(value: unknown): AgentHookSettin } } -function parseRootState(raw: string): Record { - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch (error) { - throw new Error( - `Profile state JSON is invalid: ${error instanceof Error ? error.message : String(error)}` - ) - } - if (!isRecord(parsed)) { - throw new Error('Profile state JSON root must be an object') - } - return parsed -} - function writeJsonProfileState(dataFile: string, state: Record): void { mkdirSync(dirname(dataFile), { recursive: true }) writeFileDurableSync( @@ -219,7 +205,3 @@ function writeJsonProfileState(dataFile: string, state: Record) `${JSON.stringify(state, null, 2)}\n` ) } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/main/persistence/profile-state/profile-state-recovery-required.ts b/src/main/persistence/profile-state/profile-state-recovery-required.ts index cff324fedce5..9c3765285621 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-required.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-required.ts @@ -1,3 +1,5 @@ +import { existsSync } from 'node:fs' +import { hasStateBackup } from './profile-state-legacy-backup-path' import { profileStateJsonExportPaths } from './profile-state-export-path' import { profileStateDatabaseBackups } from './profile-state-backup-path' @@ -7,6 +9,25 @@ type ProfileStateRecoveryLocation = { profileId: string } +export class ProfileStateAuthorityBootstrapError extends Error { + readonly code = 'ambiguous-profile-state' as const + + constructor(message: string) { + super(message) + this.name = 'ProfileStateAuthorityBootstrapError' + } +} + +/** Never establish a new authority over evidence that the primary was lost. */ +export function assertProfileStateCanInitialize(options: ProfileStateRecoveryLocation): void { + assertNoRetainedProfileStateExports(options) + if (!existsSync(options.dataFile) && hasStateBackup(options.dataFile)) { + throw new ProfileStateAuthorityBootstrapError( + `Legacy profile JSON is missing while its .bak.0–.bak.4 backups remain. Stop Orca and restore a selected backup to ${options.dataFile} before reopening.` + ) + } +} + /** Startup can surface this error with the exact artifacts an explicit rollback may use. */ export class ProfileStateRecoveryRequiredError extends Error { readonly code = 'profile-state-recovery-required' as const diff --git a/src/main/persistence/profile-state/profile-state-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-store-factory.test.ts index 6255cc9be84f..535a700fff37 100644 --- a/src/main/persistence/profile-state/profile-state-store-factory.test.ts +++ b/src/main/persistence/profile-state/profile-state-store-factory.test.ts @@ -167,6 +167,53 @@ describe('profile state Store authority factory', () => { expect(readFileSync(options.dataFile, 'utf8')).toBe(source) }) + it.each([0, 1, 2, 3, 4])( + 'requires selected recovery when only legacy backup slot %s remains', + (slot) => { + const options = createOptions() + const backup = `${options.dataFile}.bak.${slot}` + const source = '{"settings":{"theme":"dark"},"futureDomain":{"preserved":true}}' + writeFileSync(backup, source) + + expect(() => + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + ).toThrow('restore a selected backup') + expect(existsSync(options.databaseFile)).toBe(false) + expect(existsSync(options.dataFile)).toBe(false) + expect(readFileSync(backup, 'utf8')).toBe(source) + + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(options.directory))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + profileId: options.profileId, + exportPath: backup + }) + const recovered = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + expect(recovered.backend).toBe('sqlite') + expect(JSON.parse(recovered.store.prepareProfileStateExport().json)).toMatchObject({ + settings: { theme: 'dark' }, + futureDomain: { preserved: true } + }) + expect(readFileSync(backup, 'utf8')).toBe(source) + } + ) + + it.each(['legacy', 'sqlite-established'] as const)( + 'preserves admitted %s recovery from a missing JSON primary', + (authorityMode) => { + const options = createOptions() + const source = '{"settings":{"theme":"dark"}}' + writeFileSync(`${options.dataFile}.bak.0`, source) + + const recovered = createProfileStateStore({ ...options, authorityMode }) + expect(recovered.backend).toBe('json') + expect(recovered.store.getSettings().theme).toBe('dark') + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + } + ) + it('uses one explicit candidate policy to migrate JSON and construct a SQLite Store', () => { const options = createOptions() const source = JSON.stringify({ settings: { theme: 'dark' }, unknownDomain: { keep: true } }) diff --git a/src/main/persistence/profile-state/profile-state-versioned-export.test.ts b/src/main/persistence/profile-state/profile-state-versioned-export.test.ts new file mode 100644 index 000000000000..492c96a2bca0 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-versioned-export.test.ts @@ -0,0 +1,81 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { profileStateJsonExportPath } from './profile-state-export-path' +import { writeVersionedProfileStateExport } from './profile-state-versioned-export' + +vi.mock('node:fs', async (original) => ({ ...(await original()) })) + +const roots: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = fs.mkdtempSync(join(tmpdir(), 'orca-versioned-export-')) + roots.push(root) + const dataFile = join(root, 'orca-data.json') + const target = profileStateJsonExportPath(dataFile, 4) + const source = '{"settings":{"theme":"dark"}}' + const write = (revision = 4) => + writeVersionedProfileStateExport(dataFile, (staging) => { + writeFileDurableSync(durableWriteTempPath(staging), staging, source) + return revision + }) + return { root, target, source, write } +} + +describe('immutable versioned profile exports', () => { + it('publishes once and accepts repeated identical exports', () => { + const { root, target, source, write } = fixture() + expect(write()).toBe(4) + expect(write()).toBe(4) + expect(fs.readFileSync(target, 'utf8')).toBe(source) + expect(fs.readdirSync(root)).toEqual([basename(target)]) + }) + + it('does not retain an empty profile export', () => { + const { root, write } = fixture() + expect(write(0)).toBeUndefined() + expect(fs.readdirSync(root)).toEqual([]) + }) + + it.each(['identical', 'divergent'] as const)( + 'preserves a concurrently published %s revision', + (kind) => { + const { root, target, source, write } = fixture() + const competing = kind === 'identical' ? source : '{"settings":{"theme":"light"}}' + let raced = false + const publishCompetitor = (path: fs.PathLike) => { + if (!raced && path === target) { + raced = true + fs.writeFileSync(target, competing) + } + } + const rename = fs.renameSync + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + publishCompetitor(to) + rename(from, to) + }) + const link = fs.linkSync + vi.spyOn(fs, 'linkSync').mockImplementation((from, to) => { + publishCompetitor(to) + link(from, to) + }) + + if (kind === 'identical') { + expect(write()).toBe(4) + } else { + expect(write).toThrow('already exists with different content') + } + expect(raced).toBe(true) + expect(fs.readFileSync(target, 'utf8')).toBe(competing) + expect(fs.readdirSync(root)).toEqual([basename(target)]) + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-versioned-export.ts b/src/main/persistence/profile-state/profile-state-versioned-export.ts index 827aa350652f..1aaf705213d3 100644 --- a/src/main/persistence/profile-state/profile-state-versioned-export.ts +++ b/src/main/persistence/profile-state/profile-state-versioned-export.ts @@ -1,6 +1,7 @@ -import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname } from 'node:path' -import { renameDurableSync } from '../../durable-file-write' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' +import { durableWriteTempPath, publishFileDurableSync } from '../../durable-file-write' import { profileStateJsonExportPath } from './profile-state-export-path' /** An existing revision must never be replaced with different content. */ @@ -8,18 +9,15 @@ export function writeVersionedProfileStateExport( dataFile: string, writeExport: (targetPath: string) => number ): number | undefined { - const stagingPath = `${dataFile}.sqlite-export.pending.${process.pid}.${Date.now()}.tmp` - let published = false + const stagingPath = durableWriteTempPath(`${dataFile}.sqlite-export.pending`) try { const revision = writeExport(stagingPath) if (revision === 0) { - rmSync(stagingPath, { force: true }) - published = true return undefined } const targetPath = profileStateJsonExportPath(dataFile, revision) mkdirSync(dirname(targetPath), { recursive: true }) - if (existsSync(targetPath)) { + if (!publishFileDurableSync(stagingPath, targetPath)) { const staged = readFileSync(stagingPath) const existing = readFileSync(targetPath) if (!staged.equals(existing)) { @@ -27,15 +25,11 @@ export function writeVersionedProfileStateExport( `Profile state export revision ${revision} already exists with different content` ) } - rmSync(stagingPath, { force: true }) - } else { - renameDurableSync(stagingPath, targetPath) + fsyncFileSync(targetPath) + bestEffortFsyncDirectorySync(dirname(targetPath)) } - published = true return revision } finally { - if (!published) { - rmSync(stagingPath, { force: true }) - } + rmSync(stagingPath, { force: true }) } } diff --git a/src/main/persistence/profile-state/profile-state-worker-authority.test.ts b/src/main/persistence/profile-state/profile-state-worker-authority.test.ts new file mode 100644 index 000000000000..29cf4c6dfa7a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-worker-authority.test.ts @@ -0,0 +1,59 @@ +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from '../loading-store/profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('worker authority close admission', () => { + it('refuses new commands while its existing backup drains', async () => { + const { authority, directory, readState } = await createWorkerMaintenanceFixture() + const before = readState() + const release = maintenanceBarrier() + vi.spyOn(ProfileStateBackupRotation.prototype, 'drain').mockReturnValueOnce(release.promise) + const closeWriter = vi.spyOn(ProfileStateWriteWorkerClient.prototype, 'close') + + const closing = authority.close() + expect(authority.close()).toBe(closing) + expect(closeWriter).not.toHaveBeenCalled() + expect(() => authority.assertWritable()).toThrow('closing') + await expect( + authority.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toMatchObject({ code: 'profile-state-writer-closed', outcome: 'known-failure' }) + await expect( + authority.writeJsonExport(join(directory, 'late-export.json')) + ).rejects.toMatchObject({ + code: 'profile-state-writer-closed' + }) + + release.resolve() + await closing + expect(closeWriter).toHaveBeenCalledOnce() + expect(readState()).toEqual(before) + }) + + it('finishes an accepted write before releasing its database', async () => { + const { authority, readState } = await createWorkerMaintenanceFixture() + const accepted = authority.writeSerializedDomains([ + { domain: 'ui', payload: '{"accepted":true}' } + ]) + const closing = authority.close() + await expect(accepted).resolves.toBeUndefined() + await closing + expect(readState().ui).toEqual({ accepted: true }) + await expect(authority.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-closed' + }) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-worker-authority.ts b/src/main/persistence/profile-state/profile-state-worker-authority.ts index e5ccc60b8065..f45b92041bbd 100644 --- a/src/main/persistence/profile-state/profile-state-worker-authority.ts +++ b/src/main/persistence/profile-state/profile-state-worker-authority.ts @@ -95,6 +95,7 @@ export class ProfileStateWorkerAuthority implements AsyncProfileStateAuthority { } close(): Promise { + this.writer.stopAdmission() this.closing ??= this.finishClose() return this.closing } diff --git a/src/main/persistence/profile-state/profile-state-writer-connection.ts b/src/main/persistence/profile-state/profile-state-writer-connection.ts index 9a8c1ab4bca3..41eb703806f6 100644 --- a/src/main/persistence/profile-state/profile-state-writer-connection.ts +++ b/src/main/persistence/profile-state/profile-state-writer-connection.ts @@ -27,8 +27,7 @@ export class ProfileStateWriterConnection { private failure: Error | undefined private draining = false private closePromise: Promise | undefined - private readonly exited: Promise - private markExited: () => void = () => {} + private readonly exit = Promise.withResolvers() private didExit = false private closeAcknowledged = false private readonly timeoutMs: number @@ -41,9 +40,6 @@ export class ProfileStateWriterConnection { ) { this.initialRevision = initialization.revision this.timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS - this.exited = new Promise((resolve) => { - this.markExited = resolve - }) const pending = this.createPending(0, 'initialize') this.active = pending this.ready = pending.promise.then(() => {}) @@ -68,7 +64,7 @@ export class ProfileStateWriterConnection { ) worker.once('exit', (code) => { this.didExit = true - this.markExited() + this.exit.resolve() if (!this.closeAcknowledged || code !== 0) { this.fault( new ProfileStateWriterError( @@ -81,7 +77,7 @@ export class ProfileStateWriterConnection { }) } catch (cause) { this.didExit = true - this.markExited() + this.exit.resolve() this.fault( new ProfileStateWriterError( 'profile-state-writer-unavailable', @@ -102,11 +98,15 @@ export class ProfileStateWriterConnection { this.dispatchedOutcome() ) ) - await this.exited + await this.exit.promise } - close(): Promise { + stopAdmission(): void { this.draining = true + } + + close(): Promise { + this.stopAdmission() this.closePromise ??= this.finishClose() return this.closePromise } @@ -139,7 +139,7 @@ export class ProfileStateWriterConnection { this.timeoutMs ) try { - await this.exited + await this.exit.promise } finally { clearTimeout(timer) } @@ -148,7 +148,7 @@ export class ProfileStateWriterConnection { throw this.failure } } else { - await this.exited + await this.exit.promise } } @@ -267,14 +267,13 @@ export class ProfileStateWriterConnection { return this.active?.command === 'initialize' ? 'known-failure' : 'indeterminate' } - private invalidResponse(): Error { + private invalidResponse(): void { const error = new ProfileStateWriterError( 'profile-state-writer-protocol', 'Invalid profile state writer response', this.dispatchedOutcome() ) this.fault(error) - return error } private fault(error: Error): void { diff --git a/src/main/persistence/profile-state/profile-state-writer-request.ts b/src/main/persistence/profile-state/profile-state-writer-request.ts index 2070c1da85d4..8db4c4f65802 100644 --- a/src/main/persistence/profile-state/profile-state-writer-request.ts +++ b/src/main/persistence/profile-state/profile-state-writer-request.ts @@ -50,11 +50,10 @@ export function createProfileStateWriterRequest( timeoutMs: number, onTimeout: () => void ): PendingProfileStateWriterRequest { - let resolve: PendingProfileStateWriterRequest['resolve'] = () => {} - let reject: PendingProfileStateWriterRequest['reject'] = () => {} - const promise = new Promise((accept, refuse) => { - resolve = accept - reject = refuse - }) - return { id, command, promise, resolve, reject, timer: setTimeout(onTimeout, timeoutMs) } + return { + id, + command, + ...Promise.withResolvers(), + timer: setTimeout(onTimeout, timeoutMs) + } } diff --git a/src/main/persistence/restoring-sessions/pane-identity-migration.ts b/src/main/persistence/restoring-sessions/pane-identity-migration.ts index 98d97a649a31..6cd2bbf129fd 100644 --- a/src/main/persistence/restoring-sessions/pane-identity-migration.ts +++ b/src/main/persistence/restoring-sessions/pane-identity-migration.ts @@ -1,8 +1,8 @@ +import type { ProfileStateStartupPaneAlias } from '../loading-store/profile-state-authority' import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' -import { agentHookServer } from '../../agent-hooks/server' import { collectLayoutLeafIdsInOrder, firstLayoutLeafId } from './terminal-layout-normalization' export function findWorktreeIdForTab( @@ -67,47 +67,49 @@ export function createLazyTerminalTabLookup(session: WorkspaceSessionState): Ter } } -/** Bridges a tab's legacy numeric pane keys to stable ones; returns the alias rows worth persisting. */ -export function registerLegacyPaneKeyAliasesForTab(args: { +export type PaneAliasNormalizationOptions = { + registerAliases?: boolean + collectUnboundPaneAlias?: (entry: ProfileStateStartupPaneAlias) => void +} + +type LegacyPaneKeyAlias = Omit & { ptyId?: string } + +/** Includes unbound aliases needed by the live hook server, even though they are not persisted. */ +export function collectLegacyPaneKeyAliasesForTab(args: { tabId: string tab: TerminalTab | undefined inputLayout: TerminalLayoutSnapshot normalizedLayout: TerminalLayoutSnapshot leafIdByInputLeafId: Map -}): LegacyPaneKeyAliasEntry[] { - const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] +}): LegacyPaneKeyAlias[] { + const legacyPaneKeyAliasEntries: LegacyPaneKeyAlias[] = [] const registeredLegacyPaneKeys = new Set() const hasLeafPtyBindings = Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length > 0 const fallbackPtyId = !hasLeafPtyBindings && typeof args.tab?.ptyId === 'string' ? args.tab.ptyId : undefined - const registerLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): boolean => { + const collectLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): void => { if (!isTerminalLeafId(leafId)) { - return false + return } let paneKey: string try { paneKey = makePaneKey(args.tabId, leafId) } catch { - return false + return } const numeric = /^(?:pane:)?(\d+)$/.exec(inputLeafId)?.[1] if (!numeric) { - return false + return } // Why: PaneManager ids are 1-based; a zero-based alias in split layouts makes tab:1 ambiguous and misroutes panes. const legacyPaneKey = `${args.tabId}:${numeric}` - agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, ptyId) registeredLegacyPaneKeys.add(legacyPaneKey) - if (ptyId) { - legacyPaneKeyAliasEntries.push({ - ptyId, - legacyPaneKey, - stablePaneKey: paneKey, - updatedAt: Date.now() - }) - return true - } - return false + legacyPaneKeyAliasEntries.push({ + ptyId, + legacyPaneKey, + stablePaneKey: paneKey, + updatedAt: Date.now() + }) } const inputLeafIds = new Set([ ...collectLayoutLeafIdsInOrder(args.inputLayout.root), @@ -119,7 +121,7 @@ export function registerLegacyPaneKeyAliasesForTab(args: { } const leafId = args.leafIdByInputLeafId.get(inputLeafId) if (leafId) { - registerLegacyAlias( + collectLegacyAlias( inputLeafId, leafId, args.inputLayout.ptyIdsByLeafId?.[inputLeafId] ?? fallbackPtyId @@ -142,7 +144,6 @@ export function registerLegacyPaneKeyAliasesForTab(args: { if (registeredLegacyPaneKeys.has(legacyPaneKey)) { continue } - agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, args.tab.ptyId) legacyPaneKeyAliasEntries.push({ ptyId: args.tab.ptyId, legacyPaneKey, diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts index cfc26c7bb8a6..38ec014c7c96 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts @@ -2,6 +2,7 @@ import type { LegacyPaneKeyAliasEntry, PersistedState } from '../../../shared/pe import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' +import { agentHookServer } from '../../agent-hooks/server' import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId, @@ -12,7 +13,8 @@ import { isTerminalLeafId, parsePaneKey } from '../../../shared/stable-pane-id' import { findCrossHostPaneTabIds, withoutPaneTabIds } from './cross-host-pane-tab-ids' import { createLazyTerminalTabLookup, - registerLegacyPaneKeyAliasesForTab + collectLegacyPaneKeyAliasesForTab, + type PaneAliasNormalizationOptions } from './pane-identity-migration' import { normalizeTerminalLayoutSnapshotForPersistence } from './terminal-layout-normalization' import { @@ -37,7 +39,7 @@ export { export function normalizeWorkspaceSessionPaneIdentities( session: WorkspaceSessionState, priorLayoutsByTabId: Record = {}, - options: { skipAliasTabIds?: ReadonlySet } = {} + options: PaneAliasNormalizationOptions & { skipAliasTabIds?: ReadonlySet } = {} ): { session: WorkspaceSessionState changed: boolean @@ -49,9 +51,6 @@ export function normalizeWorkspaceSessionPaneIdentities( let changed = false const leafIdByInputLeafIdByTabId = new Map>() const leafIdByPtyIdByTabId = new Map>() - // Why always empty: legacy numeric pane keys are bridged by aliases now, not persisted as - // restart-required rows; the field stays so callers keep clearing stale rows written by old builds. - const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] const terminalLayoutsByTabId: Record = {} let tabsById: ReturnType | null = null @@ -64,7 +63,7 @@ export function normalizeWorkspaceSessionPaneIdentities( leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId) if (!options.skipAliasTabIds?.has(tabId)) { tabsById ??= createLazyTerminalTabLookup(session) - const tabAliasEntries = registerLegacyPaneKeyAliasesForTab({ + const tabAliasEntries = collectLegacyPaneKeyAliasesForTab({ tabId, tab: tabsById.get(tabId), inputLayout: layout, @@ -73,7 +72,18 @@ export function normalizeWorkspaceSessionPaneIdentities( }) // Why: old split layouts can generate enough alias rows to exceed V8's argument limit if spread into push(). for (const entry of tabAliasEntries) { - legacyPaneKeyAliasEntries.push(entry) + if (options.registerAliases !== false) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId + ) + } + if (entry.ptyId) { + legacyPaneKeyAliasEntries.push({ ...entry, ptyId: entry.ptyId }) + } else { + options.collectUnboundPaneAlias?.(entry) + } } } const leafIdByPtyId = new Map() @@ -97,7 +107,8 @@ export function normalizeWorkspaceSessionPaneIdentities( changed, leafIdByInputLeafIdByTabId, leafIdByPtyIdByTabId, - migrationUnsupportedEntries, + // Aliases replace old restart-required rows; callers still clear that legacy field. + migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries } } @@ -163,7 +174,10 @@ function mergeAcknowledgementLeafIdMapsByTabId( return merged } -export function normalizePersistedPaneIdentityState(state: PersistedState): { +export function normalizePersistedPaneIdentityState( + state: PersistedState, + options: PaneAliasNormalizationOptions = {} +): { state: PersistedState changed: boolean migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] @@ -174,6 +188,7 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { state.workspaceSession, {}, { + ...options, skipAliasTabIds: crossHostTabIds } ) @@ -200,6 +215,7 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { hostSession, {}, { + ...options, skipAliasTabIds: crossHostTabIds } ) diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts index c9d5b8c6592d..ba97195a5f52 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -3,7 +3,6 @@ import { EventEmitter } from 'node:events' import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events' import { ORCA_APP_RESTART_ABORTED_EVENT, - ORCA_APP_RESTART_COMMITTED_EVENT, ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' @@ -13,11 +12,25 @@ import { registerRendererRestartIpcRelays } from './renderer-restart-wiring' +function restartIpc(eventTarget: EventTarget) { + const ipcRenderer = Object.assign(new EventEmitter(), { + invoke: vi.fn(async () => {}), + postMessage: vi.fn(), + send: vi.fn(), + sendSync: vi.fn(), + sendToHost: vi.fn() + }) + const relay = { handleStatus: vi.fn(), abort: vi.fn() } + registerRendererRestartIpcRelays(ipcRenderer, eventTarget, relay) + return { ipcRenderer, ...relay } +} + describe('renderer restart wiring', () => { it.each(['no-op', 'failure'] as const)( 'keeps a committed restart prepared after a later %s', async (outcome) => { const eventTarget = new EventTarget() + const { ipcRenderer } = restartIpc(eventTarget) const aborted = vi.fn() const started = vi.fn() const checkpoint = vi.fn(async () => {}) @@ -26,7 +39,7 @@ describe('renderer restart wiring', () => { await prepareAndInvokeAppRestart( eventTarget, async () => { - eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_COMMITTED_EVENT)) + ipcRenderer.emit('app:restart-committed') return true }, checkpoint, @@ -75,31 +88,13 @@ describe('renderer restart wiring', () => { const abandoned = vi.fn() eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, abandoned) eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, abandoned) - const ipcRenderer = { - on: vi.fn[0]['on']>() - } - registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { - handleStatus: vi.fn(), - abort: vi.fn() - }) + const { ipcRenderer } = restartIpc(eventTarget) const checkpoint = vi.fn(async () => {}) await prepareAndInvokeAppRestart(eventTarget, async () => true, checkpoint, Boolean) - const sender = Object.assign(new EventEmitter(), { - invoke: vi.fn(async () => {}), - postMessage: vi.fn(), - send: vi.fn(), - sendSync: vi.fn(), - sendToHost: vi.fn() - }) - const emit = (channel: string) => { - const listener = ipcRenderer.on.mock.calls.find(([name]) => name === channel)?.[1] - expect(listener).toBeTypeOf('function') - listener?.({ ports: [], sender, defaultPrevented: false, preventDefault: vi.fn() }) - } - emit('app:restart-committed') + ipcRenderer.emit('app:restart-committed') await prepareAndInvokeAppRestart(eventTarget, async () => false, checkpoint, Boolean) expect(checkpoint).toHaveBeenCalledOnce() - emit('window:unload-prevented') + ipcRenderer.emit('window:unload-prevented') await prepareAndInvokeAppRestart(eventTarget, async () => true, checkpoint, Boolean) expect(checkpoint).toHaveBeenCalledOnce() expect(abandoned).not.toHaveBeenCalled() @@ -123,43 +118,27 @@ describe('renderer restart wiring', () => { async () => {}, async () => {} ) - const added = add.mock.calls.filter(([name]) => name === ORCA_APP_RESTART_COMMITTED_EVENT) - const removed = remove.mock.calls.filter(([name]) => name === ORCA_APP_RESTART_COMMITTED_EVENT) - expect(added).toHaveLength(2) - expect(removed).toEqual(added) + expect(add.mock.calls).toHaveLength(2) + expect(remove.mock.calls).toEqual(add.mock.calls) }) it('relays updater status, aborted installs, and prevented unload events', () => { const eventTarget = new EventTarget() const unloadPrevented = vi.fn() const restartAborted = vi.fn() - const restartCommitted = vi.fn() - const handleStatus = vi.fn() - const abort = vi.fn() - const listeners = new Map void>() - const ipcRenderer = { - on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { - listeners.set(channel, listener) - return ipcRenderer - }) - } as unknown as Parameters[0] + const { ipcRenderer, handleStatus, abort } = restartIpc(eventTarget) eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, unloadPrevented) eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, restartAborted) - eventTarget.addEventListener(ORCA_APP_RESTART_COMMITTED_EVENT, restartCommitted) - - registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus, abort }) - listeners.get('updater:status')?.({}, { state: 'error', message: 'install failed' }) + ipcRenderer.emit('updater:status', {}, { state: 'error', message: 'install failed' }) // Why: main abandons an install without any status when its verdict outlived the cycle. - listeners.get('updater:quitAndInstallAborted')?.({}) - listeners.get('window:unload-prevented')?.({}) - listeners.get('app:restart-committed')?.({}) + ipcRenderer.emit('updater:quitAndInstallAborted') + ipcRenderer.emit('window:unload-prevented') - expect(ipcRenderer.on).toHaveBeenCalledTimes(4) + expect(ipcRenderer.eventNames()).toHaveLength(4) expect(handleStatus).toHaveBeenCalledWith({ state: 'error', message: 'install failed' }) expect(abort).toHaveBeenCalledTimes(1) expect(unloadPrevented).toHaveBeenCalledTimes(1) expect(restartAborted).toHaveBeenCalledTimes(1) - expect(restartCommitted).toHaveBeenCalledTimes(1) }) it('marks preparation before invoking main and aborts on IPC failure', async () => { diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index ee2584900edb..38ea1882b9fa 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -7,7 +7,6 @@ import { import type { UpdateStatus } from '../shared/update-status-types' import { ORCA_APP_RESTART_ABORTED_EVENT, - ORCA_APP_RESTART_COMMITTED_EVENT, ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT @@ -39,7 +38,6 @@ export function registerRendererRestartIpcRelays( }) ipcRenderer.on('app:restart-committed', () => { appRestartState(eventTarget).committed = true - eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_COMMITTED_EVENT)) }) ipcRenderer.on('window:unload-prevented', () => { // A quit veto cannot reopen a profile whose maintenance has already committed. @@ -82,10 +80,6 @@ export async function prepareAndInvokeAppRestart( throw new Error('App restart preparation is already in progress') } state.pending = true - const markCommitted = (): void => { - state.committed = true - } - eventTarget.addEventListener(ORCA_APP_RESTART_COMMITTED_EVENT, markCommitted) try { if (!state.committed) { await prepareRendererForAppRestart(eventTarget, { @@ -109,6 +103,5 @@ export async function prepareAndInvokeAppRestart( } } finally { state.pending = false - eventTarget.removeEventListener(ORCA_APP_RESTART_COMMITTED_EVENT, markCommitted) } } diff --git a/src/shared/updater-renderer-events.ts b/src/shared/updater-renderer-events.ts index baafab5da226..49097bad6ae8 100644 --- a/src/shared/updater-renderer-events.ts +++ b/src/shared/updater-renderer-events.ts @@ -2,4 +2,3 @@ export const ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT = 'orca:updater-quit-an export const ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT = 'orca:updater-quit-and-install-aborted' export const ORCA_APP_RESTART_STARTED_EVENT = 'orca:app-restart-started' export const ORCA_APP_RESTART_ABORTED_EVENT = 'orca:app-restart-aborted' -export const ORCA_APP_RESTART_COMMITTED_EVENT = 'orca:app-restart-committed' From 1c058af58cfeb877cea477826a20c19405d43239 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 03:59:34 -0700 Subject: [PATCH 08/43] Finish SQLite recovery startup and base PR regression coverage --- .../cli-main-module-bundle-parity.test.ts | 19 +++--- src/cli/runtime-client-deferral.test.ts | 4 +- .../profile-state-recovery-launch.test.ts | 20 ++++++ .../runtime/profile-state-recovery-launch.ts | 9 ++- src/main/index.ts | 3 +- src/main/ipc/pty-dead-owner-respawn.test.ts | 13 ++-- src/main/orcad/orcad-push-startup.test.ts | 23 ++++--- ...rsistence-loading-store-extraction.test.ts | 68 +++++++++++-------- .../loading-store/loaded-state-parsing.ts | 30 ++------ .../profile-state-automation-runs.ts | 24 +------ ...ofile-state-complete-domain-writes.test.ts | 7 +- .../profile-state-database-schema.ts | 22 +++++- .../startup/desktop-startup-ordering.test.ts | 4 +- .../startup/main-window-core-services.test.ts | 12 ++-- .../pre-gone-crash-sampling-wiring.test.ts | 4 +- src/main/startup/startup-diagnostics.test.ts | 15 +++- src/main/startup/startup-diagnostics.ts | 8 ++- tests/e2e/helpers/orca-restart.ts | 22 +++++- ...rsisted-session-production-upgrade.spec.ts | 37 +++------- ...le-state-automatic-backup-recovery.spec.ts | 10 ++- ...emote-terminal-tab-retirement.unit.test.ts | 20 +++--- 21 files changed, 216 insertions(+), 158 deletions(-) rename src/cli/main-module-bundle-parity.test.ts => config/scripts/cli-main-module-bundle-parity.test.ts (75%) diff --git a/src/cli/main-module-bundle-parity.test.ts b/config/scripts/cli-main-module-bundle-parity.test.ts similarity index 75% rename from src/cli/main-module-bundle-parity.test.ts rename to config/scripts/cli-main-module-bundle-parity.test.ts index a109fcb9fdb5..5760459b3044 100644 --- a/src/cli/main-module-bundle-parity.test.ts +++ b/config/scripts/cli-main-module-bundle-parity.test.ts @@ -1,6 +1,7 @@ import { readFileSync, readdirSync } from 'node:fs' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' +import { electronViteConfig } from '../../electron.vite.config' const REPO_ROOT = resolve(__dirname, '..', '..') const CLI_ROOT = join(REPO_ROOT, 'src', 'cli') @@ -30,12 +31,12 @@ function findMainImports(): { file: string; module: string }[] { }) } -function findElectronViteMainEntries(): Set { - const config = readFileSync(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf-8') - return new Set( - // Why: entries wrap across lines once the path is long, so allow whitespace. - [...config.matchAll(/resolve\(\s*'src\/main\/([^']+)\.ts'\s*\)/g)].map((match) => match[1]) - ) +function findElectronViteMainEntries(): Record { + const input = electronViteConfig.main?.build?.rollupOptions?.input + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Expected named main-process inputs') + } + return input } describe('CLI imports of main-process modules', () => { @@ -45,7 +46,9 @@ describe('CLI imports of main-process modules', () => { // final-artifact runtime verifier. it('has an electron-vite entry for every main module the CLI imports', () => { const entries = findElectronViteMainEntries() - const missing = findMainImports().filter(({ module }) => !entries.has(module)) + const missing = findMainImports().filter( + ({ module }) => entries[module] !== join(REPO_ROOT, 'src', 'main', `${module}.ts`) + ) expect(missing).toEqual([]) }) @@ -53,6 +56,6 @@ describe('CLI imports of main-process modules', () => { it('finds the imports it is meant to guard', () => { // Why: a broken matcher would make the guard above vacuously pass. expect(findMainImports().length).toBeGreaterThanOrEqual(2) - expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(2) + expect(Object.keys(findElectronViteMainEntries()).length).toBeGreaterThanOrEqual(2) }) }) diff --git a/src/cli/runtime-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts index fdf77082729c..1487e2364f73 100644 --- a/src/cli/runtime-client-deferral.test.ts +++ b/src/cli/runtime-client-deferral.test.ts @@ -153,7 +153,9 @@ describe('RuntimeClient module-graph deferral', () => { async (_name, argv, constructs) => { vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code') vi.stubEnv('ORCA_ENVIRONMENT', 'some-environment') - getCliStatusMock.mockResolvedValue({ result: { runtime: { reachable: false } } }) + getCliStatusMock.mockResolvedValue({ + result: { runtime: { reachable: false }, app: { running: false } } + }) await main(argv, '/tmp/repo') diff --git a/src/cli/runtime/profile-state-recovery-launch.test.ts b/src/cli/runtime/profile-state-recovery-launch.test.ts index 92c306618ad4..5f8d83e8a2ea 100644 --- a/src/cli/runtime/profile-state-recovery-launch.test.ts +++ b/src/cli/runtime/profile-state-recovery-launch.test.ts @@ -116,4 +116,24 @@ describe('profile-state recovery launch', () => { await expect(launchProfileStateRecovery(request)).rejects.toThrow('Executable unavailable') expect(mocks.run).toHaveBeenCalledOnce() }) + + it('retains bounded child diagnostics when recovery exits without a result', async () => { + mocks.run.mockResolvedValue({ + code: null, + signal: 'SIGTRAP', + timedOut: false, + stdout: '', + stderr: `${'x'.repeat(5000)}\nsandbox unavailable\n` + }) + await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({ + data: { + exitCode: null, + signal: 'SIGTRAP', + timedOut: false, + outputTruncated: false, + stderr: `${'x'.repeat(5000)}\nsandbox unavailable`.slice(-4096) + } + }) + expect(mocks.run).toHaveBeenCalledOnce() + }) }) diff --git a/src/cli/runtime/profile-state-recovery-launch.ts b/src/cli/runtime/profile-state-recovery-launch.ts index 68d5bf0916ae..dd0d68ff2e43 100644 --- a/src/cli/runtime/profile-state-recovery-launch.ts +++ b/src/cli/runtime/profile-state-recovery-launch.ts @@ -66,6 +66,13 @@ export async function launchProfileStateRecovery( } throw new RuntimeClientError( 'runtime_error', - 'Orca recovery did not complete successfully. Inspect retained recovery artifacts before retrying.' + 'Orca recovery did not complete successfully. Inspect retained recovery artifacts before retrying.', + { + exitCode: response.code, + signal: response.signal, + timedOut: response.timedOut, + outputTruncated: response.outputTruncated ?? false, + stderr: response.stderr.trim().slice(-4096) + } ) } diff --git a/src/main/index.ts b/src/main/index.ts index 9c710cfc79d0..a55707700c50 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,7 @@ import { app, clipboard, dialog, type BrowserWindow } from 'electron' import { parseSkillShareId } from '../shared/skill-share-link' import { createMacAppActivationHandler } from './window/macos-app-activation' +import { isBackgroundLaunch } from './window/foreground-activation-policy' import { focusExistingWindow as focusExistingWindowAction, setMainWindowOpener @@ -130,7 +131,7 @@ if (preflightReady) { }) } console.error(`[profile-state] ${message}`) - if (!state.isServeMode && process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + if (!state.isServeMode && !isBackgroundLaunch()) { try { await presentProfileStateStartupRecoveryDialog({ message, diff --git a/src/main/ipc/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index 4669d7e55287..7a72c0d53b49 100644 --- a/src/main/ipc/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty-dead-owner-respawn.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' @@ -102,7 +103,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-proven-absent-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -113,7 +114,7 @@ describe('registerPtyHandlers', () => { getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { @@ -226,7 +227,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-probe-blip-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -237,7 +238,7 @@ describe('registerPtyHandlers', () => { getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { @@ -350,7 +351,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: {} } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -361,7 +362,7 @@ describe('registerPtyHandlers', () => { getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) let runtimeOwnsPane = true const runtime = { setPtyController: vi.fn(), diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 0ba7f7275ee9..326f94af1dc4 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -35,16 +35,21 @@ vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: () => null, getSshPtyProvider: () => null })) -vi.mock('../persistence/loading-store/store', () => ({ - Store: class { - getSettings() { - return {} +vi.mock('./orcad-profile-state-startup', () => ({ + createOrcadProfileStateStartup: async () => ({ + store: { + getSettings: () => ({}), + flushFinalOrThrowAsync: async () => {}, + freezeWritesAsync: async () => {} + }, + authority: { + backend: 'sqlite', + classification: 'neither', + authority_mode: 'sqlite-candidate', + runtime: 'orcad', + migrated: false } - - async flushPendingOrThrowAsync() {} - - freezeWrites() {} - } + }) })) vi.mock('../orca-profiles/profile-index-store', () => ({ initOrcaProfilePaths() {}, diff --git a/src/main/persistence-loading-store-extraction.test.ts b/src/main/persistence-loading-store-extraction.test.ts index a3c5e2489096..094987a91bf6 100644 --- a/src/main/persistence-loading-store-extraction.test.ts +++ b/src/main/persistence-loading-store-extraction.test.ts @@ -1,10 +1,18 @@ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import type * as FsModule from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, + writeSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../shared/constants' import type { PersistedState } from '../shared/persisted-state-types' -import type * as StartupDiagnosticsModule from './startup/startup-diagnostics' import type { Store as PersistenceStore } from './persistence/loading-store/store' import { createStore, @@ -14,10 +22,9 @@ import { writeDataFile } from './persistence-test-harness' -const { trackMock, getCohortAtEmitMock, logStartupDiagnosticMock } = vi.hoisted(() => ({ +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ trackMock: vi.fn(), - getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })), - logStartupDiagnosticMock: vi.fn() + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })) })) vi.mock('electron', () => ({ @@ -41,11 +48,21 @@ vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(() => ({ hosts: [] })), sshConfigHostsToTargets: vi.fn(() => []) })) -vi.mock('./startup/startup-diagnostics', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, logStartupDiagnostic: logStartupDiagnosticMock } +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, writeSync: vi.fn(actual.writeSync) } }) +function getLoadDoneLines(): string[] { + return vi + .mocked(writeSync) + .mock.calls.flatMap(([fd, text]) => + fd === 2 && typeof text === 'string' && text.startsWith('[startup] persistence-load-done ') + ? [text] + : [] + ) +} + describe('loading Store extraction seams', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-loading-store-')) @@ -53,7 +70,7 @@ describe('loading Store extraction seams', () => { afterEach(() => { vi.unstubAllEnvs() - logStartupDiagnosticMock.mockReset() + vi.mocked(writeSync).mockClear() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -78,9 +95,7 @@ describe('loading Store extraction seams', () => { expect(store.getWorkspaceSession().activeTabId).toBe(sentinel) expect(workspaceSessionStringifyCalls).toHaveLength(0) - expect( - logStartupDiagnosticMock.mock.calls.some(([event]) => event === 'persistence-load-done') - ).toBe(false) + expect(getLoadDoneLines()).toEqual([]) }) it('reports the unchanged workspace-session byte count when startup diagnostics are enabled', () => { @@ -104,16 +119,14 @@ describe('loading Store extraction seams', () => { expect(store.getWorkspaceSession().activeTabId).toBe(sentinel) expect(workspaceSessionStringifyCalls).toHaveLength(1) - const loadDoneCall = logStartupDiagnosticMock.mock.calls.find( - ([event]) => event === 'persistence-load-done' - ) - expect(loadDoneCall).toBeDefined() - const details = loadDoneCall?.[1] as Record | undefined - expect(details).toEqual({ - t: expect.any(Number), - repos: state.repos.length, - workspaceSessionBytes: Buffer.byteLength(JSON.stringify(store.getWorkspaceSession())) - }) + const expectedBytes = Buffer.byteLength(JSON.stringify(store.getWorkspaceSession())) + expect(getLoadDoneLines()).toEqual([ + expect.stringMatching( + new RegExp( + `^\\[startup\\] persistence-load-done t=\\d+ repos=${state.repos.length} workspaceSessionBytes=${expectedBytes}\\n$` + ) + ) + ]) }) it('timestamps persistence-load-done before resolving its details closure', () => { @@ -149,12 +162,11 @@ describe('loading Store extraction seams', () => { nowSpy.mockRestore() } - const loadDoneCall = logStartupDiagnosticMock.mock.calls.find( - ([event]) => event === 'persistence-load-done' - ) - const details = loadDoneCall?.[1] as Record | undefined - expect(details?.workspaceSessionBytes).toEqual(expect.any(Number)) - expect(details?.t).toBe(0) + expect(getLoadDoneLines()).toEqual([ + expect.stringMatching( + /^\[startup\] persistence-load-done t=0 repos=\d+ workspaceSessionBytes=\d+\n$/ + ) + ]) }) it('accepts the first JSON-parseable backup even when an older backup has richer state', async () => { diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index 380482447c5b..f2d46301f74f 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -9,10 +9,7 @@ import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-s import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' import { clearMissingProjectGroupMemberships } from '../../../shared/project-groups' import { migrateWorkspaceSessionTerminalScrollbackSnapshots } from '../../terminal-scrollback-snapshots' -import { - isStartupDiagnosticsEnabled, - logStartupDiagnostic -} from '../../startup/startup-diagnostics' +import { logStartupMilestone } from '../../startup/startup-diagnostics' import { PROTECTED_SECRET_SLOT, sshPtyOwnerLeaseSecretSlot @@ -43,21 +40,6 @@ import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-setting import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' import { normalizeLoadedProfileState } from './normalize-loaded-profile-state' -type PersistenceStartupDetails = Record | (() => Record) - -function logPersistenceStartupMilestone( - event: string, - details: PersistenceStartupDetails = {} -): void { - if (!isStartupDiagnosticsEnabled()) { - return - } - // Why: snapshot `t` before resolving lazy details — otherwise an expensive details closure is billed to the milestone it measures. - const t = Math.round(performance.now()) - const resolvedDetails = typeof details === 'function' ? details() : details - logStartupDiagnostic(event, { t, ...resolvedDetails }) -} - import type { StoreRuntimeState } from './store-runtime-state' import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' import type { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' @@ -114,7 +96,7 @@ export class LoadedStateParsingOperations { const fileExistedOnLoad = authoritySource ? serialized !== undefined || parsedInput !== undefined : serialized !== undefined || existsSync(dataFile) - logPersistenceStartupMilestone('persistence-load-start', { + logStartupMilestone('persistence-load-start', { fileExists: fileExistedOnLoad }) @@ -126,13 +108,13 @@ export class LoadedStateParsingOperations { const raw = parsedInput === undefined ? (serialized ?? readFileSync(dataFile, 'utf-8')) : undefined if (raw !== undefined) { - logPersistenceStartupMilestone('persistence-read-done', { + logStartupMilestone('persistence-read-done', { bytes: Buffer.byteLength(raw), durationMs: Math.round(performance.now() - readStartedAt) }) - logPersistenceStartupMilestone('persistence-json-parse-start') + logStartupMilestone('persistence-json-parse-start') parsed = JSON.parse(raw) - logPersistenceStartupMilestone('persistence-json-parse-done') + logStartupMilestone('persistence-json-parse-done') } else { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Legacy partial records enter the existing domain normalizers through this loader type. parsed = parsedInput as PersistedState @@ -336,7 +318,7 @@ export class LoadedStateParsingOperations { migrated.githubCache = readGithubCacheSnapshot(this.runtime.dataFile) ?? migrated.githubCache } - logPersistenceStartupMilestone('persistence-load-done', () => ({ + logStartupMilestone('persistence-load-done', () => ({ repos: migrated.repos.length, workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) })) diff --git a/src/main/persistence/profile-state/profile-state-automation-runs.ts b/src/main/persistence/profile-state/profile-state-automation-runs.ts index 2a44c79f210e..8b81cf070197 100644 --- a/src/main/persistence/profile-state/profile-state-automation-runs.ts +++ b/src/main/persistence/profile-state/profile-state-automation-runs.ts @@ -1,9 +1,6 @@ import type Database from '../../sqlite/sync-database' import { markAutomationRunsDocumentStorage } from './profile-state-automation-runs-storage' -import { - PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, - PROFILE_STATE_AUTOMATION_RUNS_TABLE -} from './profile-state-automation-runs-model' +import { PROFILE_STATE_AUTOMATION_RUNS_TABLE } from './profile-state-automation-runs-model' export { PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, @@ -19,25 +16,6 @@ export { } from './profile-state-automation-runs-writer' export { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs-reader' -export function createProfileStateAutomationRunsTablesSql(): string { - return `CREATE TABLE IF NOT EXISTS ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} ( - domain TEXT PRIMARY KEY NOT NULL, - presence TEXT NOT NULL, - domain_version INTEGER NOT NULL, - revision INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - content_hash TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} ( - run_id TEXT PRIMARY KEY NOT NULL, - ordinal INTEGER NOT NULL, - payload TEXT NOT NULL, - content_hash TEXT NOT NULL, - revision INTEGER NOT NULL, - updated_at INTEGER NOT NULL - );` -} - export function clearProfileStateAutomationRuns(db: Database.Database): void { db.prepare(`DELETE FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE}`).run() markAutomationRunsDocumentStorage(db) diff --git a/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts b/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts index ba94e37b1519..177c79dd8741 100644 --- a/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts +++ b/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts @@ -64,9 +64,10 @@ describe.each([false, true])('complete domain writes (established: %s)', (establ const before = authority.readSerializedState() const revision = readRevision() - expect(() => - Reflect.apply(authority.writeCompleteSerializedDomains, authority, [value]) - ).toThrow() + expect(() => { + // @ts-expect-error Intentionally malformed input must fail runtime validation. + authority.writeCompleteSerializedDomains(value) + }).toThrow() expect(authority.readSerializedState()).toBe(before) expect(readRevision()).toBe(revision) diff --git a/src/main/persistence/profile-state/profile-state-database-schema.ts b/src/main/persistence/profile-state/profile-state-database-schema.ts index 84b63c17a2d8..4f6cd4538259 100644 --- a/src/main/persistence/profile-state/profile-state-database-schema.ts +++ b/src/main/persistence/profile-state/profile-state-database-schema.ts @@ -1,6 +1,9 @@ // Schema 3 requires explicit automation storage metadata even when history is empty. -import { createProfileStateAutomationRunsTablesSql } from './profile-state-automation-runs' +import { + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE +} from './profile-state-automation-runs-model' export const PROFILE_STATE_DATABASE_SCHEMA_VERSION = 3 export const PROFILE_STATE_DOCUMENT_VERSION = 1 @@ -19,5 +22,20 @@ export function createProfileStateTablesSql(): string { domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL ); - ${createProfileStateAutomationRunsTablesSql()}` + CREATE TABLE IF NOT EXISTS ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} ( + domain TEXT PRIMARY KEY NOT NULL, + presence TEXT NOT NULL, + domain_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + content_hash TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} ( + run_id TEXT PRIMARY KEY NOT NULL, + ordinal INTEGER NOT NULL, + payload TEXT NOT NULL, + content_hash TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL + );` } diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 376a41fd3076..8f435cbb2c5b 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -110,9 +110,7 @@ describe('startup ordering', () => { expect(entrySource).toContain('formatProfileStateStartupFailure') expect(entrySource).toContain('const message = formatProfileStateStartupFailure(error)') expect(entrySource).toContain('presentProfileStateStartupRecoveryDialog') - expect(entrySource).toContain( - "!state.isServeMode && process.env.ORCA_BACKGROUND_LAUNCH !== '1'" - ) + expect(entrySource).toContain('!state.isServeMode && !isBackgroundLaunch()') expect(entrySource).toContain( "console.warn('[profile-state] Recovery dialog failed; exiting safely:'" ) diff --git a/src/main/startup/main-window-core-services.test.ts b/src/main/startup/main-window-core-services.test.ts index f4b34ae48580..9910dffcc551 100644 --- a/src/main/startup/main-window-core-services.test.ts +++ b/src/main/startup/main-window-core-services.test.ts @@ -9,8 +9,8 @@ const { store } = vi.hoisted(() => { const store = { - writeLatestProfileStateJsonCompatibilityExport: vi.fn(), - writeLatestProfileStateJsonExport: vi.fn(), + writeLatestProfileStateJsonCompatibilityExportAsync: vi.fn(async () => {}), + writeLatestProfileStateJsonExportAsync: vi.fn(async () => {}), getSettings: vi.fn(() => ({})) } return { @@ -107,11 +107,11 @@ describe('main window profile-state update preparation', () => { claudeRuntimeAuth: state.claudeRuntimeAuth, store }) - expect(store.writeLatestProfileStateJsonExport).toHaveBeenCalledOnce() - expect(store.writeLatestProfileStateJsonCompatibilityExport).toHaveBeenCalledOnce() + expect(store.writeLatestProfileStateJsonExportAsync).toHaveBeenCalledOnce() + expect(store.writeLatestProfileStateJsonCompatibilityExportAsync).toHaveBeenCalledOnce() expect(options).toHaveProperty('onBeforeUpdateQuitFailure', 'abort') - expect(store.writeLatestProfileStateJsonExport.mock.invocationCallOrder[0]).toBeLessThan( - store.writeLatestProfileStateJsonCompatibilityExport.mock.invocationCallOrder[0] + expect(store.writeLatestProfileStateJsonExportAsync.mock.invocationCallOrder[0]).toBeLessThan( + store.writeLatestProfileStateJsonCompatibilityExportAsync.mock.invocationCallOrder[0] ) }) }) diff --git a/src/main/startup/pre-gone-crash-sampling-wiring.test.ts b/src/main/startup/pre-gone-crash-sampling-wiring.test.ts index 2a8e008c0b3d..292645f874f3 100644 --- a/src/main/startup/pre-gone-crash-sampling-wiring.test.ts +++ b/src/main/startup/pre-gone-crash-sampling-wiring.test.ts @@ -44,6 +44,8 @@ describe('pre-gone crash sampling startup wiring', () => { expect(readySource).toContain( "import { initializeReadyRuntimeServices } from './main-process-ready-runtime'" ) - expect(readySource).toContain('\n await initializeReadyRuntimeServices()') + expect(readySource).toContain( + 'try {\n await initializeReadyFoundation()\n await initializeReadyRuntimeServices()' + ) }) }) diff --git a/src/main/startup/startup-diagnostics.test.ts b/src/main/startup/startup-diagnostics.test.ts index dcf7a4237b3a..4ba75269d6c4 100644 --- a/src/main/startup/startup-diagnostics.test.ts +++ b/src/main/startup/startup-diagnostics.test.ts @@ -1,11 +1,24 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { isStartupDiagnosticsEnabled, logStartupDiagnostic, + logStartupMilestone, STARTUP_DIAGNOSTICS_ENV, writeStartupDiagnosticLine } from './startup-diagnostics' +afterEach(() => vi.unstubAllEnvs()) + +it('does not compute lazy milestone details unless diagnostics are enabled', () => { + vi.stubEnv(STARTUP_DIAGNOSTICS_ENV, '0') + const details = vi.fn(() => ({ bytes: 123 })) + logStartupMilestone('persistence-load-done', details) + expect(details).not.toHaveBeenCalled() + vi.stubEnv(STARTUP_DIAGNOSTICS_ENV, '1') + logStartupMilestone('persistence-load-done', details) + expect(details).toHaveBeenCalledOnce() +}) + describe('writeStartupDiagnosticLine', () => { it('writes directly to stderr fd 2 with a newline', () => { const write = vi.fn() diff --git a/src/main/startup/startup-diagnostics.ts b/src/main/startup/startup-diagnostics.ts index d5cd7718d881..dff09612711f 100644 --- a/src/main/startup/startup-diagnostics.ts +++ b/src/main/startup/startup-diagnostics.ts @@ -32,8 +32,12 @@ export function logStartupDiagnostic( // Why: startup benchmarking needs in-process timestamps — harness-side stderr // arrival times include pipe buffering jitter. `t` is ms since process start. -export function logStartupMilestone(event: string, details: Record = {}): void { +export function logStartupMilestone( + event: string, + details: Record | (() => Record) = {} +): void { if (isStartupDiagnosticsEnabled()) { - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) + const t = Math.round(performance.now()) + logStartupDiagnostic(event, { t, ...(typeof details === 'function' ? details() : details) }) } } diff --git a/tests/e2e/helpers/orca-restart.ts b/tests/e2e/helpers/orca-restart.ts index 6ca5e7873024..8be4adb79f84 100644 --- a/tests/e2e/helpers/orca-restart.ts +++ b/tests/e2e/helpers/orca-restart.ts @@ -20,6 +20,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node: import { createServer } from 'node:net' import os from 'node:os' import path from 'node:path' +import { runProcess, type ProcessResult } from '../../../src/shared/child-process/run-process' import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile' import { getOrcaElectronLaunchArgs } from './electron-launch-args' import { retryTransientMainEvaluate } from './electron-main-evaluate-retry' @@ -51,6 +52,7 @@ type RestartSession = { userDataDir: string seedCodexResumeRollout: (sessionId: string, cwd: string) => string launch: (options?: LaunchOptions) => Promise + launchUntilExit: (executablePath: string) => Promise /** Gracefully close a launch, letting beforeunload flush session state. */ close: (app: ElectronApplication) => Promise /** Remove the shared userDataDir after the test is done. */ @@ -211,6 +213,24 @@ export function createRestartSession( await closeElectronAppForE2E(app) } + // Startup refusals exit before a renderer exists; capture their output from process creation. + const launchUntilExit = async (executablePath: string): Promise => { + runtimeWsPort ??= await reserveRestartRuntimeWsPort() + return runProcess({ + program: executablePath, + args: getOrcaElectronLaunchArgs(mainPath, false), + env: { + ...homeIsolation.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_E2E_HEADLESS: '1', + ORCA_E2E_RUNTIME_WS_PORT: String(runtimeWsPort) + }, + timeoutMs: 30_000, + detached: process.platform !== 'win32', + terminationBarrier: true + }) + } + const dispose = async (): Promise => { await cleanupE2EDaemons(userDataDir) if (process.env.ORCA_E2E_PRESERVE_RESTART_PROFILE === '1') { @@ -222,7 +242,7 @@ export function createRestartSession( } } - return { userDataDir, seedCodexResumeRollout, launch, close, dispose } + return { userDataDir, seedCodexResumeRollout, launch, launchUntilExit, close, dispose } } /** diff --git a/tests/e2e/persisted-session-production-upgrade.spec.ts b/tests/e2e/persisted-session-production-upgrade.spec.ts index d18c31cf92bd..dbfba16936ee 100644 --- a/tests/e2e/persisted-session-production-upgrade.spec.ts +++ b/tests/e2e/persisted-session-production-upgrade.spec.ts @@ -478,6 +478,7 @@ test('fails closed when a packaged old build mutates live SQLite compatibility J try { const candidateLaunch = await session.launch() candidateApp = candidateLaunch.app + const candidateExecutable = await candidateApp.evaluate(() => process.execPath) await waitForSessionReady(candidateLaunch.page) await candidateLaunch.page.evaluate(async () => { const updateSettings = window.__store?.getState().updateSettingsOrThrow @@ -526,19 +527,11 @@ test('fails closed when a packaged old build mutates live SQLite compatibility J expect(mutatedJson).toMatchObject({ settings: { terminalFontSize: 23 } }) expect(existsSync(databasePath)).toBe(true) - let stderr = '' - let launchError: unknown - try { - await session.launch({ - onStderr: (chunk) => { - stderr += chunk - } - }) - } catch (error) { - launchError = error - } - expect(launchError).toBeDefined() - expect(stderr).toContain('both JSON and SQLite storage without a matching acceptance marker') + const refused = await session.launchUntilExit(candidateExecutable) + expect(refused, refused.stderr).toMatchObject({ code: 1, signal: null, timedOut: false }) + expect(refused.stderr).toContain( + 'both JSON and SQLite storage without a matching acceptance marker' + ) expect(existsSync(databasePath)).toBe(true) const opened = openProfileStateDatabaseReadOnly(databasePath, DEFAULT_LOCAL_ORCA_PROFILE_ID) try { @@ -567,6 +560,7 @@ test('fails closed on a corrupt established SQLite profile and retains recovery try { const initialLaunch = await session.launch() app = initialLaunch.app + const candidateExecutable = await app.evaluate(() => process.execPath) await waitForSessionReady(initialLaunch.page) await session.close(app) app = null @@ -585,20 +579,9 @@ test('fails closed on a corrupt established SQLite profile and retains recovery const jsonBeforeCorruption = readFileSync(dataFile) writeFileSync(databaseFile, 'corrupt profile-state database') - let stderr = '' - let launchError: unknown - try { - await session.launch({ - onStderr: (chunk) => { - stderr += chunk - } - }) - } catch (error) { - launchError = error - } - - expect(launchError).toBeDefined() - expect(stderr).toContain('cannot safely open the active profile') + const refused = await session.launchUntilExit(candidateExecutable) + expect(refused, refused.stderr).toMatchObject({ code: 1, signal: null, timedOut: false }) + expect(refused.stderr).toContain('cannot safely open the active profile') expect(readFileSync(dataFile)).toEqual(jsonBeforeCorruption) expect( readdirSync(profileDirectory).filter((name) => diff --git a/tests/e2e/profile-state-automatic-backup-recovery.spec.ts b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts index d98a8575ac4e..d3eb3a815e91 100644 --- a/tests/e2e/profile-state-automatic-backup-recovery.spec.ts +++ b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts @@ -26,9 +26,13 @@ function rollbackProfileBackup(userDataDir: string, backupId: string, executable const cliIsolation = createElectronHomeIsolation({ inheritedEnv: process.env, launchEnv: { ORCA_USER_DATA_PATH: userDataDir, ORCA_BACKGROUND_LAUNCH: '1' }, - extraEnv: executable - ? { ORCA_APP_EXECUTABLE: executable, ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1' } - : {}, + extraEnv: { + ...(executable + ? { ORCA_APP_EXECUTABLE: executable, ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1' } + : {}), + // Raw development Electron needs the same sandbox opt-out as Playwright. + ...(process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: '1' } : {}) + }, userDataDir }) return runProcess({ diff --git a/tests/e2e/remote-terminal-tab-retirement.unit.test.ts b/tests/e2e/remote-terminal-tab-retirement.unit.test.ts index 45ac2f8b0a00..4d5ee50a1d70 100644 --- a/tests/e2e/remote-terminal-tab-retirement.unit.test.ts +++ b/tests/e2e/remote-terminal-tab-retirement.unit.test.ts @@ -10,6 +10,7 @@ import { type WebSessionTabsSyncState } from '../../src/renderer/src/runtime/web-session-tabs-sync' import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import { withDurableRuntimeStore } from '../../src/main/runtime/runtime-durable-store-fixture' vi.mock('../../src/renderer/src/store', () => ({ useAppStore: { setState: vi.fn() } @@ -129,13 +130,16 @@ describe('remote terminal tab retirement publication', () => { it('removes a permanent host exit from simultaneous viewers without stale resurrection', async () => { let session = makePersistedSession() const flushOrThrow = vi.fn() - const runtime = new OrcaRuntimeService({ - getWorkspaceSession: () => session, - setWorkspaceSession: (next) => { - session = next - }, - flushOrThrow - } as never) + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This retirement fixture implements the Store session and durability operations used by the runtime. + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession: (next) => { + session = next + }, + flushOrThrow + }) as never + ) runtime.attachWindow(1) const staleLiveSnapshot = makeHostSnapshot() runtime.syncWindowGraph(1, { @@ -177,7 +181,7 @@ describe('remote terminal tab retirement publication', () => { const publications: (typeof livePublication)[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((event) => publications.push(event)) - runtime.onPtyExit(PTY_ID, 0, INCARNATION_ID) + await runtime.onPtyExit(PTY_ID, 0, INCARNATION_ID) const retiredPublication = publications.at(-1) expect(retiredPublication).toBeDefined() if (!retiredPublication) { From 814cd95f1f9f5965af522f13eb0bb82e9cc346af Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 04:48:28 -0700 Subject: [PATCH 09/43] Fix SSH output routing across durable reconnect binding --- .../ssh-relay-session-recovery-races.test.ts | 177 +++++++++++------- src/main/ssh/ssh-relay-session.ts | 10 +- 2 files changed, 115 insertions(+), 72 deletions(-) diff --git a/src/main/ssh/ssh-relay-session-recovery-races.test.ts b/src/main/ssh/ssh-relay-session-recovery-races.test.ts index 26dd798ac5ad..f4600bb23e6c 100644 --- a/src/main/ssh/ssh-relay-session-recovery-races.test.ts +++ b/src/main/ssh/ssh-relay-session-recovery-races.test.ts @@ -140,17 +140,21 @@ describe('SshRelaySession recovery race fencing', () => { mockDeploySuccess() }) - function emitSourceFrame(args: { - targetId: string - token: string - clientGeneration: number - ownerGeneration: number - sourceStartSu: number - sourceEndSu: number - }): void { - ptyDataHandlerRef.current?.({ + function emitSourceFrame( + args: { + targetId: string + token: string + clientGeneration: number + ownerGeneration: number + sourceStartSu: number + sourceEndSu: number + data?: string + }, + sink = ptyDataHandlerRef.current + ): void { + sink?.({ id: `ssh:${args.targetId}@@pty-1`, - data: 'late', + data: args.data ?? 'late', providerGeneration: 23, ptyIncarnation: 'incarnation-1', sequenceChars: args.sourceEndSu - args.sourceStartSu, @@ -227,6 +231,66 @@ describe('SshRelaySession recovery race fencing', () => { return { session, deps } } + it('keeps source output contiguous while the recovered pane binding waits for disk', async () => { + const targetId = 'recovery-binding-disk-wait' + const { session, deps } = await prepareRecovery(targetId) + const binding = Promise.withResolvers() + vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([ + { + targetId, + ptyId: 'pty-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ]) + vi.mocked(deps.mockStore.persistPtyBinding).mockReturnValue(binding.promise) + const emit = (sourceStartSu: number, sink = ptyDataHandlerRef.current): void => + emitSourceFrame( + { + targetId, + token: 'new-token', + clientGeneration: 2, + ownerGeneration: 2, + sourceStartSu, + sourceEndSu: sourceStartSu + 4 + }, + sink + ) + let recoverySink: typeof ptyDataHandlerRef.current + const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } + attachForReconnectMock.mockResolvedValue({ + incarnationId: 'incarnation-1', + sourceRecovery: pendingRecovery(8), + sourceActivationLease: { + commit: vi.fn(), + rollback: vi.fn(async () => true), + transferToRecovery: (sink: (payload: unknown) => void) => { + recoverySink = sink + emit(4, sink) + completeRecovery({ id: 'pty-1', ...pendingRecovery(8) }) + return recoveryActivationLease + } + } + }) + + const reconnect = session.reconnect(deps.mockConn) + await vi.waitFor(() => expect(deps.mockStore.persistPtyBinding).toHaveBeenCalledOnce()) + emit(8, recoverySink) + expect(recoveryActivationLease.commit).not.toHaveBeenCalled() + binding.resolve(true) + await reconnect + emit(12) + + expect( + acceptOutputDataMock.mock.calls.map(([payload]) => payload.source.sourceStartSu) + ).toEqual([4, 8, 12]) + expect(recoveryActivationLease.commit).toHaveBeenCalledOnce() + }) + it('publishes held recovery data before an exact exit without waiting for completion', async () => { const targetId = 'exit-with-complete-private-body' const { session, deps } = await prepareRecovery(targetId) @@ -235,22 +299,18 @@ describe('SshRelaySession recovery race fencing', () => { commit: vi.fn(), rollback: vi.fn(async () => true), transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - sink({ - id: `ssh:${targetId}@@pty-1`, - data: 'held', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: 4, - source: { - relayPtyId: 'pty-1', - spanId: 'new-token:4:8', + emitSourceFrame( + { + targetId, + token: 'new-token', clientGeneration: 2, ownerGeneration: 2, - deliveryToken: 'new-token', sourceStartSu: 4, - sourceEndSu: 8 - } - }) + sourceEndSu: 8, + data: 'held' + }, + sink + ) return recoveryActivationLease }) } @@ -301,22 +361,18 @@ describe('SshRelaySession recovery race fencing', () => { commit: vi.fn(), rollback: vi.fn(async () => true), transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - sink({ - id: `ssh:${targetId}@@pty-1`, - data: 'partial', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: 2, - source: { - relayPtyId: 'pty-1', - spanId: 'new-token:4:6', + emitSourceFrame( + { + targetId, + token: 'new-token', clientGeneration: 2, ownerGeneration: 2, - deliveryToken: 'new-token', sourceStartSu: 4, - sourceEndSu: 6 - } - }) + sourceEndSu: 6, + data: 'partial' + }, + sink + ) return recoveryActivationLease }) } @@ -501,38 +557,23 @@ describe('SshRelaySession recovery race fencing', () => { commit: vi.fn(), rollback: vi.fn(), transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - sink({ - id: `ssh:${targetId}@@pty-1`, - data: 'x'.repeat(2 * 1024 * 1024 + 1), - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: 4, - source: { - relayPtyId: 'pty-1', - spanId: 'new-token:4:8', - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token', - sourceStartSu: 4, - sourceEndSu: 8 - } - }) - sink({ - id: `ssh:${targetId}@@pty-1`, - data: 'later', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: 4, - source: { - relayPtyId: 'pty-1', - spanId: 'new-token:8:12', - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token', - sourceStartSu: 8, - sourceEndSu: 12 - } - }) + for (const [data, sourceStartSu] of [ + ['x'.repeat(2 * 1024 * 1024 + 1), 4], + ['later', 8] + ] as const) { + emitSourceFrame( + { + targetId, + token: 'new-token', + clientGeneration: 2, + ownerGeneration: 2, + sourceStartSu, + sourceEndSu: sourceStartSu + 4, + data + }, + sink + ) + } return recoveryActivationLease }) } diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 3ad48113ffce..b739fbd3d8ce 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1799,10 +1799,6 @@ export class SshRelaySession { } const pending = this.pendingPtyReattaches.get(payload.id) if (pending && this.activePtyConsumerOwner()?.outputFlowControl) { - if (pending.livePassthrough) { - void this.acceptPtyData(payload).catch(() => {}) - return - } this.quarantineReattachData(pending, payload) return } @@ -2074,6 +2070,12 @@ export class SshRelaySession { } private quarantineReattachData(pending: PendingPtyReattach, payload: SshPtyDataPayload): void { + if (pending.livePassthrough) { + if (this.ownsPtyRecoveryAttempt(payload.id, pending)) { + void this.acceptPtyData(payload).catch(() => {}) + } + return + } this.observePrivateRecoveryFrame(pending, payload) if (pending.restoreRequired) { return From 831e444f73de844b14e9a9df907b18abc391c294 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 04:54:30 -0700 Subject: [PATCH 10/43] Retire durable bindings for terminals that exit during spawn --- src/main/ipc/pty/ipc/spawn-commit.ts | 5 +- src/main/ipc/pty/pane/spawn-registration.ts | 30 ++++ src/main/ipc/pty/runtime/spawn-commit.ts | 29 +-- .../pty-spawn-exit-durability.test.ts | 166 ++++++++++++++++++ 4 files changed, 215 insertions(+), 15 deletions(-) create mode 100644 src/main/ipc/pty/pane/spawn-registration.ts create mode 100644 src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 90b700f24b2e..b750c1ce44fe 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -24,6 +24,7 @@ import { } from '../pane/launch-authority' import type { PtyIpcSpawnState } from './spawn-state' import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' +import { registerPersistedPtySpawn } from '../pane/spawn-registration' import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { @@ -101,7 +102,9 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise +): Promise { + try { + runtime?.registerPty(...args) + } catch (error) { + const [ptyId, worktreeId, connectionId, binding] = args + // An exit during the binding write precedes runtime surface registration. + if ( + error instanceof Error && + error.message === 'agent_session_exited_during_start' && + runtime?.getPtyLivenessVerdict?.(ptyId)?.status === 'exited' && + binding + ) { + await retirePersistedStablePaneOwner( + store, + { ...binding, ptyId, persistedIncarnationId: binding.incarnationId }, + worktreeId, + connectionId + ) + } + throw error + } +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index dcfce3665b05..5b5dd8df0180 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -36,6 +36,7 @@ import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { RuntimePtySpawnState } from './spawn-state' +import { registerPersistedPtySpawn } from '../pane/spawn-registration' export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args @@ -68,7 +69,9 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (ctx.result.incarnationId) { ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) } - ctx.deps.runtime?.registerPty( + await registerPersistedPtySpawn( + ctx.deps.runtime, + ctx.hostSessionBinding?.store ?? ctx.deps.store, ctx.result.id, owner.surface.worktreeId, args.connectionId ?? null, @@ -150,25 +153,21 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { }) if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) { try { + const { store, worktreeId, tabId, leafId, expectedSourceBinding } = ctx.hostSessionBinding const binding = { - worktreeId: ctx.hostSessionBinding.worktreeId, - tabId: ctx.hostSessionBinding.tabId, - leafId: ctx.hostSessionBinding.leafId, + worktreeId, + tabId, + leafId, ptyId: ctx.result.id, hostAdmittedMembership: true, ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), - ...(ctx.hostSessionBinding.expectedSourceBinding - ? { expectedSourceBinding: ctx.hostSessionBinding.expectedSourceBinding } - : {}), - origin: spawnCommitBindingOrigin(ctx.result, ctx.hostSessionBinding.expectedSourceBinding) + ...(expectedSourceBinding ? { expectedSourceBinding } : {}), + origin: spawnCommitBindingOrigin(ctx.result, expectedSourceBinding) } const persisted = args.connectionId - ? await ctx.hostSessionBinding.store.persistPtyBinding( - binding, - toSshExecutionHostId(args.connectionId) - ) - : await ctx.hostSessionBinding.store.persistPtyBinding(binding) + ? await store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) + : await store.persistPtyBinding(binding) if (persisted === false) { throw new Error('terminal_split_source_not_found') } @@ -196,7 +195,9 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) } if (args.worktreeId) { - ctx.deps.runtime?.registerPty( + await registerPersistedPtySpawn( + ctx.deps.runtime, + ctx.hostSessionBinding?.store ?? ctx.deps.store, ctx.result.id, args.worktreeId, args.connectionId ?? null, diff --git a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts new file mode 100644 index 000000000000..2164d0207e41 --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts @@ -0,0 +1,166 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' +import { registerPersistedPtySpawn } from '../../ipc/pty/pane/spawn-registration' +import { toSshExecutionHostId } from '../../../shared/execution-host' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +it.each([ + { controller: 'runtime', connectionId: null, exitCode: 0 }, + { controller: 'runtime', connectionId: 'test-host', exitCode: 0 }, + { controller: 'ipc', connectionId: null, exitCode: -1 }, + { controller: 'ipc', connectionId: 'test-host', exitCode: 0 } +])( + 'retires an exited $controller binding on $connectionId after disk finishes', + async ({ controller, connectionId, exitCode }) => { + const { store, authority, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: connectionId + ? `ssh:${connectionId}@@pty-exited-during-durable-bind` + : 'pty-exited-during-durable-bind', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' + } + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) + runtime.assertPtyRegistrationAllowed(binding.ptyId, binding.incarnationId) + let commit: () => Promise + if (controller === 'runtime') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; its runtime/store are real and preflight-only dependencies are unreachable. + const deps = { runtime, store, options: {} } as PtyRuntimeControllerDeps + const ctx = createRuntimePtySpawnState(deps, { ...binding, connectionId, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.hostSessionBinding = { store, ...binding } + ctx.metadataLeafId = binding.leafId + commit = () => commitRuntimePtySpawn(ctx) + } else { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; its runtime/store are real and preflight-only dependencies are unreachable. + const deps = { runtime, store } as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { ...binding, connectionId, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + commit = () => commitPtyIpcSpawn(ctx) + } + const gate = authority.pause() + const pending = expect(commit()).rejects.toThrow('agent_session_exited_during_start') + await gate.started.promise + await runtime.onPtyExit(binding.ptyId, exitCode, binding.incarnationId, { + providerExitObserved: true + }) + gate.finish.resolve() + await pending + const state = readState() + const session = connectionId + ? state.workspaceSessionsByHostId[toSshExecutionHostId(connectionId)] + : state.workspaceSession + expect( + session.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId] + ).toBeUndefined() + } +) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'exited-pty', + incarnationId: 'old-incarnation' +} + +it.each(['replacement-pty', binding.ptyId])( + 'preserves a replacement binding to %s while retirement waits', + async (ptyId) => { + const { store, authority, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) + await store.persistPtyBinding(binding) + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + const gate = authority.pause() + const replacement = store.persistPtyBinding({ + ...binding, + ptyId, + incarnationId: 'new-incarnation' + }) + await gate.started.promise + const registration = expect( + registerPersistedPtySpawn(runtime, store, binding.ptyId, binding.worktreeId, null, binding) + ).rejects.toThrow('agent_session_exited_during_start') + gate.finish.resolve() + await Promise.all([replacement, registration]) + const session = readState().workspaceSession + expect(session.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId[binding.leafId]).toBe(ptyId) + expect(session.terminalPtyIncarnationsByPaneKey[`${binding.tabId}:${binding.leafId}`]).toBe( + 'new-incarnation' + ) + } +) + +it('retains the binding when loss of contact supplies no process-exit proof', async () => { + const { store, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) + await store.persistPtyBinding(binding) + await runtime.onPtyExit(binding.ptyId, -1, binding.incarnationId) + await expect( + registerPersistedPtySpawn(runtime, store, binding.ptyId, binding.worktreeId, null, binding) + ).rejects.toThrow('agent_session_exited_during_start') + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId[ + binding.leafId + ] + ).toBe(binding.ptyId) +}) + +it('does not settle rejected registration until its exit cleanup reaches SQLite', async () => { + const { store, authority, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) + await store.persistPtyBinding(binding) + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + const gate = authority.pause() + let rejected = false + const pending = registerPersistedPtySpawn( + runtime, + store, + binding.ptyId, + binding.worktreeId, + null, + binding + ).catch((error: unknown) => { + rejected = true + throw error + }) + const failure = expect(pending).rejects.toThrow('agent_session_exited_during_start') + await gate.started.promise + expect(rejected).toBe(false) + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId[ + binding.leafId + ] + ).toBe(binding.ptyId) + gate.finish.resolve() + await failure + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[ + binding.leafId + ] + ).toBeUndefined() +}) From a2aab4480dd635ce258233b5ebbffd0f75c03a82 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 05:08:48 -0700 Subject: [PATCH 11/43] Keep successful spawn publication synchronous --- src/main/ipc/pty/ipc/spawn-commit.ts | 25 ++++------------ src/main/ipc/pty/pane/spawn-registration.ts | 12 +++++--- src/main/ipc/pty/pane/spawn-telemetry.ts | 24 +++++++++++++++ src/main/ipc/pty/runtime/spawn-commit.ts | 30 +++++++------------ .../pty-spawn-exit-durability.test.ts | 21 ++++++++++--- 5 files changed, 65 insertions(+), 47 deletions(-) create mode 100644 src/main/ipc/pty/pane/spawn-telemetry.ts diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index b750c1ce44fe..79af82d7cf8c 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -4,13 +4,7 @@ import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' import { registerPty } from '../../../memory/pty-registry' import type { PtySpawnResult } from '../../../providers/types' import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' -import { track } from '../../../telemetry/client' -import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' -import { - agentKindSchema, - launchSourceSchema, - requestKindSchema -} from '../../../../shared/telemetry-events' +import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry' import { shouldSkipCodexHomeEnvForWindowsShell, codexReattachedHomeRouteField @@ -102,7 +96,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise -): Promise { +): Promise | undefined { try { runtime?.registerPty(...args) } catch (error) { @@ -18,13 +19,16 @@ export async function registerPersistedPtySpawn( runtime?.getPtyLivenessVerdict?.(ptyId)?.status === 'exited' && binding ) { - await retirePersistedStablePaneOwner( + return retirePersistedStablePaneOwner( store, { ...binding, ptyId, persistedIncarnationId: binding.incarnationId }, worktreeId, connectionId - ) + ).then(() => { + throw error + }) } throw error } + return undefined } diff --git a/src/main/ipc/pty/pane/spawn-telemetry.ts b/src/main/ipc/pty/pane/spawn-telemetry.ts new file mode 100644 index 000000000000..ccd3c3d582d5 --- /dev/null +++ b/src/main/ipc/pty/pane/spawn-telemetry.ts @@ -0,0 +1,24 @@ +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../../../shared/telemetry-events' +import type { PtySpawnIpcArgs } from '../ipc/spawn-types' + +export function recordPtySpawnTelemetry( + telemetry: NonNullable +): void { + const agentKind = agentKindSchema.safeParse(telemetry.agent_kind) + const launchSource = launchSourceSchema.safeParse(telemetry.launch_source) + const requestKind = requestKindSchema.safeParse(telemetry.request_kind) + if (agentKind.success && launchSource.success && requestKind.success) { + track('agent_started', { + agent_kind: agentKind.data, + launch_source: launchSource.data, + request_kind: requestKind.data, + ...getCohortAtEmit() + }) + } +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 5b5dd8df0180..9e71ec4fe0c2 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -16,13 +16,7 @@ import { rendererSerializerReadiness } from '../pane/serializer-state' import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' -import { track } from '../../../telemetry/client' -import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' -import { - agentKindSchema, - launchSourceSchema, - requestKindSchema -} from '../../../../shared/telemetry-events' +import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' import { @@ -69,7 +63,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (ctx.result.incarnationId) { ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) } - await registerPersistedPtySpawn( + const rejectedRegistration = registerPersistedPtySpawn( ctx.deps.runtime, ctx.hostSessionBinding?.store ?? ctx.deps.store, ctx.result.id, @@ -83,6 +77,9 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {}) } ) + if (rejectedRegistration) { + await rejectedRegistration + } if (!args.connectionId) { ctx.deps.options?.onCodexHomePtySpawned?.({ id: ctx.result.id, @@ -195,7 +192,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) } if (args.worktreeId) { - await registerPersistedPtySpawn( + const rejectedRegistration = registerPersistedPtySpawn( ctx.deps.runtime, ctx.hostSessionBinding?.store ?? ctx.deps.store, ctx.result.id, @@ -218,6 +215,9 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ? shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd) : undefined ) + if (rejectedRegistration) { + await rejectedRegistration + } } else { // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) @@ -232,17 +232,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { markClaudePtySpawned(ctx.result.id) } if (args.telemetry && !ctx.stablePaneOwner) { - const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) - const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) - const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) - if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { - track('agent_started', { - agent_kind: agentKindParse.data, - launch_source: launchSourceParse.data, - request_kind: requestKindParse.data, - ...getCohortAtEmit() - }) - } + recordPtySpawnTelemetry(args.telemetry) } // Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup. const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY) diff --git a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts index 2164d0207e41..adf6be51d411 100644 --- a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts @@ -119,9 +119,9 @@ it('retains the binding when loss of contact supplies no process-exit proof', as runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) await store.persistPtyBinding(binding) await runtime.onPtyExit(binding.ptyId, -1, binding.incarnationId) - await expect( + expect(() => registerPersistedPtySpawn(runtime, store, binding.ptyId, binding.worktreeId, null, binding) - ).rejects.toThrow('agent_session_exited_during_start') + ).toThrow('agent_session_exited_during_start') expect( readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId[ binding.leafId @@ -137,14 +137,18 @@ it('does not settle rejected registration until its exit cleanup reaches SQLite' await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) const gate = authority.pause() let rejected = false - const pending = registerPersistedPtySpawn( + const cleanup = registerPersistedPtySpawn( runtime, store, binding.ptyId, binding.worktreeId, null, binding - ).catch((error: unknown) => { + ) + if (!cleanup) { + throw new Error('exited registration did not start durable cleanup') + } + const pending = cleanup.catch((error: unknown) => { rejected = true throw error }) @@ -164,3 +168,12 @@ it('does not settle rejected registration until its exit cleanup reaches SQLite' ] ).toBeUndefined() }) + +it('keeps successful registration synchronous through the remaining spawn publication', async () => { + const { store } = await fixture() + const runtime = new OrcaRuntimeService(store) + await store.persistPtyBinding(binding) + expect( + registerPersistedPtySpawn(runtime, store, binding.ptyId, binding.worktreeId, null, binding) + ).toBeUndefined() +}) From 2c4961ffee5fc2e948ab38508d695c74c2874521 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 05:53:19 -0700 Subject: [PATCH 12/43] Retain database and startup evidence for crash restart failures --- ...state-terminal-restart-persistence.spec.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/e2e/profile-state-terminal-restart-persistence.spec.ts b/tests/e2e/profile-state-terminal-restart-persistence.spec.ts index a0ac975a35ad..09146e470d1a 100644 --- a/tests/e2e/profile-state-terminal-restart-persistence.spec.ts +++ b/tests/e2e/profile-state-terminal-restart-persistence.spec.ts @@ -480,11 +480,17 @@ test.describe('SQLite candidate terminal restart persistence', () => { const repoPath = seededRepoPathOrSkip() const session = createRestartSession(testInfo) + const stderr: string[] = [] + const launchOptions = { + onStderr: (chunk: string): void => { + stderr.push(chunk) + } + } let firstApp: ElectronApplication | null = null let secondApp: ElectronApplication | null = null try { - const firstLaunch = await session.launch() + const firstLaunch = await session.launch(launchOptions) firstApp = firstLaunch.app const { worktreeId, ptyId } = await bootstrapFirstLaunch(firstLaunch.page, repoPath) @@ -534,8 +540,18 @@ test.describe('SQLite candidate terminal restart persistence', () => { }) .toBe(true) firstApp = null + for (const suffix of ['', '-wal', '-shm']) { + const filename = `profile-state.db${suffix}` + const filePath = path.join(profileDirectory, filename) + if (existsSync(filePath)) { + await testInfo.attach(filename, { + body: readFileSync(filePath), + contentType: 'application/octet-stream' + }) + } + } - const secondLaunch = await session.launch() + const secondLaunch = await session.launch(launchOptions) secondApp = secondLaunch.app await bootstrapRestoredLaunch(secondLaunch.page, worktreeId) expect(existsSync(legacyProfileState)).toBe(false) @@ -561,6 +577,10 @@ test.describe('SQLite candidate terminal restart persistence', () => { await session.close(firstApp) } await session.dispose() + await testInfo.attach('restart-stderr', { + body: stderr.join(''), + contentType: 'text/plain' + }) } }) }) From eb030ed19793d53f611f002359f4df7c34ff7b1d Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 06:33:11 -0700 Subject: [PATCH 13/43] Persist terminal activity before acknowledging a pane binding --- .../pty-binding-async-durability.test.ts | 18 ++++ .../pty-binding-fast-lane.test.ts | 1 + .../loading-store/pty-binding-fast-lane.ts | 7 ++ .../pty-binding-session-update.ts | 10 ++ .../terminal-binding-crash-hydration.test.ts | 96 +++++++++++++++++++ 5 files changed, 132 insertions(+) create mode 100644 src/renderer/src/store/slices/terminal-binding-crash-hydration.test.ts diff --git a/src/main/persistence/loading-store/pty-binding-async-durability.test.ts b/src/main/persistence/loading-store/pty-binding-async-durability.test.ts index 7892205be066..066cea3cce50 100644 --- a/src/main/persistence/loading-store/pty-binding-async-durability.test.ts +++ b/src/main/persistence/loading-store/pty-binding-async-durability.test.ts @@ -23,6 +23,8 @@ const binding = { describe('durable asynchronous PTY binding', () => { it('acknowledges only after the binding reaches SQLite', async () => { const { store, authority, readState } = await fixture() + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + await store.flushPendingOrThrowAsync() const gate = authority.pause() let acknowledged = false const pending = store.persistPtyBinding(binding).then((result) => { @@ -32,6 +34,7 @@ describe('durable asynchronous PTY binding', () => { await gate.started.promise expect(acknowledged).toBe(false) expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([]) gate.finish.resolve() expect(await pending).toBe(true) expect( @@ -39,6 +42,20 @@ describe('durable asynchronous PTY binding', () => { ).toEqual({ [binding.leafId]: binding.ptyId }) + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([binding.worktreeId]) + }) + + it('repairs activity on an otherwise matching durable reattach', async () => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + await store.flushPendingOrThrowAsync() + authority.captures.length = 0 + await store.persistPtyBinding(binding) + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([binding.worktreeId]) + expect(authority.captures).toHaveLength(1) + await store.persistPtyBinding(binding) + expect(authority.captures).toHaveLength(1) }) it('evaluates membership refusal after an older write finishes', async () => { @@ -64,6 +81,7 @@ describe('durable asynchronous PTY binding', () => { it('restores the binding after a known write failure', async () => { const { store, authority } = await fixture() vi.spyOn(console, 'error').mockImplementation(() => {}) + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] const before = structuredClone(store.getWorkspaceSession()) const gate = authority.pause() const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') diff --git a/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts b/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts index 8ac6de70a0d7..3f8cc1c7e7da 100644 --- a/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts +++ b/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts @@ -71,6 +71,7 @@ describe('evaluatePtyBindingFastLane', () => { ) ).toEqual(['layout_missing']) expect(miss({ incarnationId: 'a' })).toEqual(['incarnation']) + expect(miss({}, session({ activeWorktreeIdsOnShutdown: [] }))).toEqual(['inactive_worktree']) expect(miss({}, session({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'a' } }))).toEqual([ 'incarnation' ]) diff --git a/src/main/persistence/loading-store/pty-binding-fast-lane.ts b/src/main/persistence/loading-store/pty-binding-fast-lane.ts index 9ae6304ed0f0..3f0763c883fb 100644 --- a/src/main/persistence/loading-store/pty-binding-fast-lane.ts +++ b/src/main/persistence/loading-store/pty-binding-fast-lane.ts @@ -18,6 +18,7 @@ export type PtyBindingFastLaneMiss = | 'leaf_pty' | 'incarnation' | 'tombstone' + | 'inactive_worktree' | 'not_durable' export type PtyBindingFastLaneRequest = { @@ -80,6 +81,12 @@ export function evaluatePtyBindingFastLane( if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { misses.push('tombstone') } + if ( + session.activeWorktreeIdsOnShutdown && + !session.activeWorktreeIdsOnShutdown.includes(bindingWorktreeId) + ) { + misses.push('inactive_worktree') + } if (!durable) { misses.push('not_durable') } diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts index 054d87bffd43..3024543505a6 100644 --- a/src/main/persistence/loading-store/pty-binding-session-update.ts +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -88,6 +88,16 @@ export function applyPtyBinding( ...session.defaultTerminalTabsAppliedByWorktreeId, [bindingWorktreeId]: true } + // Acknowledged spawns must survive a crash before the renderer records their activity. + if ( + session.activeWorktreeIdsOnShutdown && + !session.activeWorktreeIdsOnShutdown.includes(bindingWorktreeId) + ) { + session.activeWorktreeIdsOnShutdown = [ + ...session.activeWorktreeIdsOnShutdown, + bindingWorktreeId + ] + } if (!isTerminalLeafId(args.leafId)) { // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. advanceTopologyFence() diff --git a/src/renderer/src/store/slices/terminal-binding-crash-hydration.test.ts b/src/renderer/src/store/slices/terminal-binding-crash-hydration.test.ts new file mode 100644 index 000000000000..f0ca60e5b645 --- /dev/null +++ b/src/renderer/src/store/slices/terminal-binding-crash-hydration.test.ts @@ -0,0 +1,96 @@ +import './terminal-hydration-store-test-bootstrap' +import { describe, expect, it, vi } from 'vitest' +import { applyPtyBinding } from '../../../../main/persistence/loading-store/pty-binding-session-update' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { reconcileHydratedWorkspaceTabModels } from '../../app-shell/reconcile-hydrated-workspace-tab-models' +import { createTestStore, makeTab, makeWorktree, TEST_REPO } from './store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +vi.mock('@/components/terminal-pane/pty-transport', () => ({ + registerEagerPtyBuffer: vi.fn(), + ensurePtyDispatcher: vi.fn() +})) + +const TAB_ID = 'crash-survivor' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const INCARNATION_ID = '22222222-2222-4222-8222-222222222222' +const FOLDER_KEY = folderWorkspaceKey('crash-folder') + +describe.each(['repo1::/repo1', FOLDER_KEY])('binding crash hydration for %s', (worktreeId) => { + it.each([false, true])( + 'restores the acknowledged terminal before a renderer snapshot (existing row: %s)', + async (existingRow) => { + const ptyId = `${worktreeId}@@surviving-pty` + const persisted: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeWorktreeId: worktreeId, + activeTabId: TAB_ID, + activeTabIdByWorktree: { [worktreeId]: TAB_ID }, + activeWorktreeIdsOnShutdown: [], + unifiedTabs: {}, + tabGroups: {}, + tabGroupLayouts: {}, + ...(existingRow + ? { tabsByWorktree: { [worktreeId]: [makeTab({ id: TAB_ID, worktreeId, ptyId: null })] } } + : {}) + } + applyPtyBinding( + { worktreeId, tabId: TAB_ID, leafId: LEAF_ID, ptyId, incarnationId: INCARNATION_ID }, + persisted, + worktreeId, + `${TAB_ID}:${LEAF_ID}` + ) + + // A crash leaves only the binding transaction; no renderer snapshot can fill its gaps. + const restoredSession = structuredClone(persisted) + const store = createTestStore() + store.setState({ + repos: [TEST_REPO], + worktreesByRepo: { + repo1: [makeWorktree({ id: 'repo1::/repo1', repoId: 'repo1', path: '/repo1' })] + } + }) + const options = { additionalValidWorkspaceKeys: [FOLDER_KEY] } + store.getState().hydrateWorkspaceSession(restoredSession, options) + store.getState().hydrateTabsSession(restoredSession, options) + reconcileHydratedWorkspaceTabModels( + restoredSession, + store.getState().reconcileWorktreeTabModels + ) + + const beforeReconnect = store.getState() + expect(beforeReconnect.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([TAB_ID]) + expect(beforeReconnect.pendingReconnectPtyIdByTabId[TAB_ID]).toBe(ptyId) + expect(beforeReconnect.unifiedTabsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ id: TAB_ID, entityId: TAB_ID, contentType: 'terminal' }) + ]) + expect(beforeReconnect.groupsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ activeTabId: TAB_ID, tabOrder: [TAB_ID] }) + ]) + expect(beforeReconnect.layoutByWorktree[worktreeId]).toEqual({ + type: 'leaf', + groupId: beforeReconnect.groupsByWorktree[worktreeId][0].id + }) + expect(beforeReconnect.terminalLayoutsByTabId[TAB_ID]).toMatchObject({ + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + ptyIdsByLeafId: { [LEAF_ID]: ptyId } + }) + expect(restoredSession.terminalPtyIncarnationsByPaneKey?.[`${TAB_ID}:${LEAF_ID}`]).toBe( + INCARNATION_ID + ) + + await store.getState().reconnectPersistedTerminals() + + expect(store.getState().workspaceSessionReady).toBe(true) + expect(store.getState().activeTabId).toBe(TAB_ID) + expect(store.getState().tabsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ id: TAB_ID, ptyId }) + ]) + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([ptyId]) + } + ) +}) From c0a762c9876007901701aafaf06b7fcba3e9fd61 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 06:40:47 -0700 Subject: [PATCH 14/43] Keep crash hydration regression outside renderer compilation --- ...minal-binding-crash-hydration.unit.test.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) rename src/renderer/src/store/slices/terminal-binding-crash-hydration.test.ts => tests/e2e/terminal-binding-crash-hydration.unit.test.ts (84%) diff --git a/src/renderer/src/store/slices/terminal-binding-crash-hydration.test.ts b/tests/e2e/terminal-binding-crash-hydration.unit.test.ts similarity index 84% rename from src/renderer/src/store/slices/terminal-binding-crash-hydration.test.ts rename to tests/e2e/terminal-binding-crash-hydration.unit.test.ts index f0ca60e5b645..ec83a396e38e 100644 --- a/src/renderer/src/store/slices/terminal-binding-crash-hydration.test.ts +++ b/tests/e2e/terminal-binding-crash-hydration.unit.test.ts @@ -1,11 +1,16 @@ -import './terminal-hydration-store-test-bootstrap' +import '../../src/renderer/src/store/slices/terminal-hydration-store-test-bootstrap' import { describe, expect, it, vi } from 'vitest' -import { applyPtyBinding } from '../../../../main/persistence/loading-store/pty-binding-session-update' -import { getDefaultWorkspaceSession } from '../../../../shared/constants' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import { reconcileHydratedWorkspaceTabModels } from '../../app-shell/reconcile-hydrated-workspace-tab-models' -import { createTestStore, makeTab, makeWorktree, TEST_REPO } from './store-test-helpers' +import { applyPtyBinding } from '../../src/main/persistence/loading-store/pty-binding-session-update' +import { getDefaultWorkspaceSession } from '../../src/shared/constants' +import { folderWorkspaceKey } from '../../src/shared/workspace-scope' +import type { WorkspaceSessionState } from '../../src/shared/workspace-session-state-types' +import { reconcileHydratedWorkspaceTabModels } from '../../src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models' +import { + createTestStore, + makeTab, + makeWorktree, + TEST_REPO +} from '../../src/renderer/src/store/slices/store-test-helpers' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) From c977d29e0cb534442fee8c7606b711e57542065b Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 12:08:02 -0700 Subject: [PATCH 15/43] fix: fence pending profile retirement and recovery work --- .../build-plugins/plain-node-entry-guard.ts | 26 +++- .../cli-main-module-bundle-parity.test.ts | 12 +- config/scripts/plain-node-entry-guard.test.ts | 60 ++++++--- electron.vite.config.ts | 36 +----- src/main/ipc/pty/pane/stable-owner.ts | 4 +- .../loading-store/primary-state-writes.ts | 6 +- .../profile-state-sqlite-authority.test.ts | 3 +- .../pty-retirement-async-durability.test.ts | 119 ++++++++++++++++++ .../loading-store/store-runtime-state.ts | 3 +- .../profile-state-backup-rotation.test.ts | 47 +++++++ .../profile-state-backup-rotation.ts | 18 +-- .../profile-state-recovery-copy.ts | 1 + 12 files changed, 263 insertions(+), 72 deletions(-) create mode 100644 src/main/persistence/loading-store/pty-retirement-async-durability.test.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 75d386d0c401..2589a4921e9b 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -16,15 +16,33 @@ type OutputChunk = Rollup.OutputChunk // electron, and smoke-loads daemon-entry under plain Node to prove its module // graph still resolves. -// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime): -// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run -// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them. +// The CLI loads these paths after electron-vite replaces out/main. +export const CLI_MAIN_ENTRY_NAMES = [ + 'agent-hooks/managed-agent-hook-controls', + 'codex/managed-home-shell-preflight', + 'claude-accounts/keychain', + ...[ + 'access', + 'active-location', + 'storage-classification', + 'offline-settings', + 'export-path', + 'backup-path', + 'database-recovery', + 'domain-reader', + 'recovery', + 'recovery-command' + ].map((module) => `persistence/profile-state/profile-state-${module}`), + 'startup/http1-compatibility-marker' +] as const + +// Plain-Node processes and CLI modules cannot load Electron's API. const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', 'computer-sidecar', 'wsl-transcript-fs-process-entry', - 'agent-hooks/managed-agent-hook-controls' + ...CLI_MAIN_ENTRY_NAMES ] as const // Entries executed as worker threads of the main process. Electron's module is diff --git a/config/scripts/cli-main-module-bundle-parity.test.ts b/config/scripts/cli-main-module-bundle-parity.test.ts index 5760459b3044..1cd9f6b7f1a6 100644 --- a/config/scripts/cli-main-module-bundle-parity.test.ts +++ b/config/scripts/cli-main-module-bundle-parity.test.ts @@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from 'node:fs' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { electronViteConfig } from '../../electron.vite.config' +import { GUARDED_ENTRY_NAMES } from '../build-plugins/plain-node-entry-guard' const REPO_ROOT = resolve(__dirname, '..', '..') const CLI_ROOT = join(REPO_ROOT, 'src', 'cli') @@ -19,7 +20,7 @@ function listCliSourceFiles(dir: string): string[] { } // Why: `import type` is erased by tsc, so it needs no emitted module at runtime. -const VALUE_IMPORT_FROM_MAIN = /(? { @@ -53,8 +54,17 @@ describe('CLI imports of main-process modules', () => { expect(missing).toEqual([]) }) + it('guards every CLI main module against Electron imports', () => { + const guarded = new Set(GUARDED_ENTRY_NAMES) + expect(findMainImports().filter(({ module }) => !guarded.has(module))).toEqual([]) + }) + it('finds the imports it is meant to guard', () => { // Why: a broken matcher would make the guard above vacuously pass. + expect(findMainImports()).toContainEqual({ + file: 'src/cli/profile-state-location.ts', + module: 'persistence/profile-state/profile-state-active-location' + }) expect(findMainImports().length).toBeGreaterThanOrEqual(2) expect(Object.keys(findElectronViteMainEntries()).length).toBeGreaterThanOrEqual(2) }) diff --git a/config/scripts/plain-node-entry-guard.test.ts b/config/scripts/plain-node-entry-guard.test.ts index 673b796fd438..843d185b1016 100644 --- a/config/scripts/plain-node-entry-guard.test.ts +++ b/config/scripts/plain-node-entry-guard.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import type { Plugin, Rollup } from 'vite' import { afterEach, describe, expect, it } from 'vitest' import { + CLI_MAIN_ENTRY_NAMES, createPlainNodeEntryGuardPlugin, GUARDED_ENTRY_NAMES } from '../build-plugins/plain-node-entry-guard' @@ -158,8 +159,8 @@ describe('guarded entry names', () => { // main-process worker and kills it at startup. The worker entries carried only // hand-written "must stay electron-free" comments, and the port-scan worker sits // one import away from a client that deliberately does require electron. -describe('worker thread entry guard', () => { - function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void { +describe('CLI and worker thread entry guard', () => { + function runEntryWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void { const hook = plugin.writeBundle if (typeof hook !== 'function') { throw new Error('Expected writeBundle hook') @@ -171,7 +172,7 @@ describe('worker thread entry guard', () => { ) } - function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk { + function entryChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk { return { type: 'chunk', code, @@ -183,33 +184,54 @@ describe('worker thread entry guard', () => { } as Rollup.OutputChunk } + it.each(CLI_MAIN_ENTRY_NAMES)('rejects direct and transitive Electron imports in %s', (name) => { + const plugin = createPlainNodeEntryGuardPlugin() + const entry = entryChunk(name, 'require("electron")') + const bundle: Rollup.OutputBundle = { [entry.fileName]: entry } + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('requires electron') + + entry.code = '' + const shared = entryChunk('shared', 'require("electron/main")') + shared.isEntry = false + bundle[shared.fileName] = shared + for (const edge of ['imports', 'dynamicImports'] as const) { + entry[edge] = [shared.fileName] + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('requires electron') + entry[edge] = [] + } + + shared.code = 'require("node:fs")' + entry.imports = [shared.fileName] + expect(() => runEntryWriteBundle(plugin, bundle)).not.toThrow() + }) + it('rejects an Electron require reachable from a worker entry', () => { const plugin = createPlainNodeEntryGuardPlugin() - const bundle = { - 'port-scan-command-worker-entry.js': workerChunk( + const bundle: Rollup.OutputBundle = { + 'port-scan-command-worker-entry.js': entryChunk( 'port-scan-command-worker-entry', 'require("electron")' ) - } as Rollup.OutputBundle + } - expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron') + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('requires electron') }) it('names the worker-thread runtime so the failure is actionable', () => { const plugin = createPlainNodeEntryGuardPlugin() - const bundle = { - 'stt-worker.js': workerChunk('stt-worker', 'require("electron")') - } as Rollup.OutputBundle + const bundle: Rollup.OutputBundle = { + 'stt-worker.js': entryChunk('stt-worker', 'require("electron")') + } - expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread') + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('runs as a worker thread') }) // The real risk is transitive: a worker entry importing a shared chunk that // reaches the electron-requiring client, not a direct import anyone would spot. it('follows shared chunks out of a worker entry', () => { const plugin = createPlainNodeEntryGuardPlugin() - const bundle = { - 'session-scanner-opencode-sqlite-worker-entry.js': workerChunk( + const bundle: Rollup.OutputBundle = { + 'session-scanner-opencode-sqlite-worker-entry.js': entryChunk( 'session-scanner-opencode-sqlite-worker-entry', 'require("./chunks/shared.js")', ['chunks/shared.js'] @@ -223,20 +245,20 @@ describe('worker thread entry guard', () => { isEntry: false, name: 'shared' } as Rollup.OutputChunk - } as Rollup.OutputBundle + } - expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js') + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('chunks/shared.js') }) it('passes a clean worker entry', () => { const plugin = createPlainNodeEntryGuardPlugin() - const bundle = { - 'warp-theme-parser-worker.js': workerChunk( + const bundle: Rollup.OutputBundle = { + 'warp-theme-parser-worker.js': entryChunk( 'warp-theme-parser-worker', 'require("node:worker_threads")' ) - } as Rollup.OutputBundle + } - expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow() + expect(() => runEntryWriteBundle(plugin, bundle)).not.toThrow() }) }) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 789360c6c4d3..87156caf6e43 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -5,7 +5,10 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner' import { createPdfjsViewerAssetsPlugin } from './config/build-plugins/pdfjs-viewer-assets' -import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard' +import { + CLI_MAIN_ENTRY_NAMES, + createPlainNodeEntryGuardPlugin +} from './config/build-plugins/plain-node-entry-guard' import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ @@ -260,37 +263,8 @@ export const electronViteConfig: UserConfig = { 'main-thread-hang-watchdog-entry': resolve( 'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts' ), - // Why: electron-vite cleans out/main in dev. The dev CLI imports - // this path for `orca agent hooks ...`, so it must survive rebuilds. - 'agent-hooks/managed-agent-hook-controls': resolve( - 'src/main/agent-hooks/managed-agent-hook-controls.ts' - ), - 'codex/managed-home-shell-preflight': resolve( - 'src/main/codex/managed-home-shell-preflight.ts' - ), - // Why: account import mutates the user's macOS Keychain from the CLI. - 'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts'), - // Why: the dev CLI's offline profile-state commands load these paths after - // electron-vite cleans out/main; keep them as stable sidecar entries. ...Object.fromEntries( - [ - 'access', - 'active-location', - 'storage-classification', - 'offline-settings', - 'export-path', - 'backup-path', - 'database-recovery', - 'domain-reader', - 'recovery', - 'recovery-command' - ].map((module) => [ - `persistence/profile-state/profile-state-${module}`, - resolve(`src/main/persistence/profile-state/profile-state-${module}.ts`) - ]) - ), - 'startup/http1-compatibility-marker': resolve( - 'src/main/startup/http1-compatibility-marker.ts' + CLI_MAIN_ENTRY_NAMES.map((module) => [module, resolve(`src/main/${module}.ts`)]) ) }, // Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 40fcabde70c8..83ebdd50364d 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -136,8 +136,8 @@ export async function retirePersistedStablePaneOwner( const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) if (!current) { - // A prior stop may already have retired this pane while runtime retained its history. - return { value: true, persist: false } + // A renderer removal may still be waiting for its debounced write. + return { value: true, persist: 'if-dirty' } } if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { return { value: false, persist: false } diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index 2d395268c390..1d6168600f00 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -83,7 +83,11 @@ export class PrimaryStateWriteOperations { runtime.profileStateAuthority.assertWritable() } const mutation = mutate() - if (mutation.persist === false) { + if ( + mutation.persist === false || + (mutation.persist === 'if-dirty' && + runtime.lastDurableWriteGeneration >= runtime.writeGeneration) + ) { return mutation.value } runtime.writeGeneration++ diff --git a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts index ef3fe5d40209..596b75cf219e 100644 --- a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts +++ b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts @@ -811,7 +811,7 @@ describe('Store with an injected SQLite profile-state authority', () => { store.freezeWrites() }) - it('freezes Store writes before quarantining the SQLite database family', () => { + it('freezes Store writes before quarantining the SQLite database family', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-quarantine-')) temporaryDirectories.push(directory) const dataFile = join(directory, 'orca-data.json') @@ -822,6 +822,7 @@ describe('Store with an injected SQLite profile-state authority', () => { const store = new Store({ dataFile, profileStateAuthority: authority }) store.updateSettings({ theme: 'dark' }) store.flushOrThrow() + await authority.drainBackups() const sourceBytes = readFileSync(databasePath) writeFileSync(`${databasePath}-wal`, 'wal-preservation-sentinel') diff --git a/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts b/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts new file mode 100644 index 000000000000..6751cc3a0109 --- /dev/null +++ b/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { retirePersistedStablePaneOwner } from '../../ipc/pty/pane/stable-owner' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { retireTerminalSurfaceFromPersistence } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'retirement-tab', + leafId: TEST_LEAF_1, + ptyId: 'retirement-pty', + incarnationId: 'retirement-incarnation' +} + +async function retirementFixture(connectionId: string | undefined) { + const result = await fixture() + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + await result.store.persistPtyBinding(binding, hostId) + return { + ...result, + retire: () => + retirePersistedStablePaneOwner( + result.store, + { ...binding, persistedIncarnationId: binding.incarnationId }, + binding.worktreeId, + connectionId + ), + removeInMemory: () => { + result.store.setWorkspaceSession( + retireTerminalSurfaceFromPersistence(result.store.getWorkspaceSession(hostId), { + ...binding, + parentTabId: binding.tabId + }), + hostId + ) + }, + persistedLayout: () => { + const state = result.readState() + const session = hostId ? state.workspaceSessionsByHostId[hostId] : state.workspaceSession + return session.terminalLayoutsByTabId[binding.tabId] + } + } +} + +describe.each([undefined, 'retirement-ssh'])( + 'durable PTY retirement on host %s', + (connectionId) => { + it('waits for an already removed in-memory pane to reach SQLite', async () => { + const { authority, retire, removeInMemory, persistedLayout } = + await retirementFixture(connectionId) + removeInMemory() + expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) + const gate = authority.pause() + let acknowledged = false + const pending = retire().then((accepted) => { + acknowledged = true + return accepted + }) + try { + await Promise.race([gate.started.promise, pending]) + expect(acknowledged).toBe(false) + expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) + } finally { + gate.finish.resolve() + } + await expect(pending).resolves.toBe(true) + expect(persistedLayout()).toBeUndefined() + }) + + it('rejects when a pending removal cannot reach SQLite and retains it for retry', async () => { + const { authority, retire, removeInMemory, persistedLayout } = + await retirementFixture(connectionId) + vi.spyOn(console, 'error').mockImplementation(() => {}) + removeInMemory() + const gate = authority.pause() + const rejected = expect(retire()).rejects.toThrow('retirement disk refused') + try { + await Promise.race([gate.started.promise, rejected]) + gate.finish.reject( + new ProfileStateWriterError( + 'test-disk-failure', + 'retirement disk refused', + 'known-failure' + ) + ) + await rejected + } finally { + gate.finish.resolve() + } + expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) + await expect(retire()).resolves.toBe(true) + expect(persistedLayout()).toBeUndefined() + }) + + it('skips disk work when the pane removal is already durable', async () => { + const { authority, retire, persistedLayout } = await retirementFixture(connectionId) + await expect(retire()).resolves.toBe(true) + expect(persistedLayout()).toBeUndefined() + authority.captures.length = 0 + const revisionCheck = vi.spyOn(authority, 'assertCurrentRevision') + const fullStateWrite = vi.spyOn(authority, 'writeSerializedState') + await expect(retire()).resolves.toBe(true) + expect(authority.captures).toEqual([]) + expect(revisionCheck).not.toHaveBeenCalled() + expect(fullStateWrite).not.toHaveBeenCalled() + }) + } +) diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index aa6e9efa5d1d..e1f4d918c680 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -24,7 +24,8 @@ import type { AutomationRun } from '../../../shared/automations-types' export type DurableProfileStateMutation = { value: T - persist?: boolean + /** 'if-dirty' fences an existing change without rewriting an already durable generation. */ + persist?: boolean | 'if-dirty' rollback?: () => void } diff --git a/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts b/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts index df10d31f4be8..72df43fdbaee 100644 --- a/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import * as fsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -15,6 +16,11 @@ import { import { importProfileStateJson, readProfileStateSnapshot } from './profile-state-documents' import * as snapshots from './profile-state-database-snapshot' +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual } +}) + const directories: string[] = [] const rotations: ProfileStateBackupRotation[] = [] const databases: ReturnType[] = [] @@ -159,11 +165,52 @@ describe('automatic SQLite recovery generations', () => { it('cancels a queued backup before opening a source after Store close', async () => { const { databasePath, rotation } = fixture() rotation.schedule() + expect(() => rotation.assertIdle()).toThrow('Flush pending') rotation.stop() + expect(() => rotation.assertIdle()).toThrow('Flush pending') await rotation.drain() + expect(() => rotation.assertIdle()).not.toThrow() expect(profileStateDatabaseBackups(databasePath)).toEqual([]) }) + it('blocks synchronous quarantine until retention pruning finishes', async () => { + const { databasePath, rotation, clock } = fixture() + for (let generation = 0; generation < 5; generation++) { + rotation.schedule() + await rotation.drain() + clock.now += HOUR + } + const oldest = profileStateDatabaseBackups(databasePath)[4].path + const remove = fsPromises.rm + let begin: () => void = () => {} + let release: () => void = () => {} + const started = new Promise((resolve) => { + begin = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(fsPromises, 'rm').mockImplementation(async (path, options) => { + if (path === oldest) { + begin() + await gate + } + await remove(path, options) + }) + rotation.schedule() + try { + await started + rotation.stop() + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(6) + expect(() => rotation.assertIdle()).toThrow('Flush pending') + } finally { + release() + await rotation.drain() + } + expect(() => rotation.assertIdle()).not.toThrow() + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(5) + }) + it('owns an in-flight source until completion and blocks synchronous quarantine', async () => { const { databasePath, rotation, opened } = fixture() const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync diff --git a/src/main/persistence/profile-state/profile-state-backup-rotation.ts b/src/main/persistence/profile-state/profile-state-backup-rotation.ts index e5b5eb3c8b2c..3e7449cf9f89 100644 --- a/src/main/persistence/profile-state/profile-state-backup-rotation.ts +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.ts @@ -14,7 +14,6 @@ const BACKUP_RETRY_MS = 60 * 1000 export class ProfileStateBackupRotation { private pending: Promise | undefined private stopped = false - private copying = false private nextAttemptAt = 0 constructor( @@ -54,7 +53,7 @@ export class ProfileStateBackupRotation { } assertIdle(): void { - if (this.copying) { + if (this.pending) { throw new Error('Flush pending profile state backups before quarantining the database') } } @@ -76,16 +75,11 @@ export class ProfileStateBackupRotation { this.databasePath, createProfileStateDatabaseBackupId(now) ) - this.copying = true - try { - await this.runBackup({ - databasePath: this.databasePath, - profileId: this.profileId, - targetPath: target - }) - } finally { - this.copying = false - } + await this.runBackup({ + databasePath: this.databasePath, + profileId: this.profileId, + targetPath: target + }) this.nextAttemptAt = this.now() + BACKUP_INTERVAL_MS for (const backup of (await this.regularBackups()).slice(BACKUP_COUNT)) { await rm(backup.path, { force: true }) diff --git a/src/main/persistence/profile-state/profile-state-recovery-copy.ts b/src/main/persistence/profile-state/profile-state-recovery-copy.ts index 1c97e9ebd446..cb7a1f9315bd 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-copy.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-copy.ts @@ -13,6 +13,7 @@ export function copyProfileStateRecoveryFile(source: string, target: string): vo copyProfileStateRecoveryFiles([{ source, target }]) } +/** Targets are staging files; callers validate, fsync and publish. */ export function copyProfileStateRecoveryFiles(files: readonly RecoveryCopy[]): void { const clones: RecoveryCopy[] = [] const names = new Set() From f7540caf192783cb9726edfca9cc0d34bfabefda Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 12:13:30 -0700 Subject: [PATCH 16/43] test: use portable CLI source paths --- config/scripts/cli-main-module-bundle-parity.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/scripts/cli-main-module-bundle-parity.test.ts b/config/scripts/cli-main-module-bundle-parity.test.ts index 1cd9f6b7f1a6..ed810e343821 100644 --- a/config/scripts/cli-main-module-bundle-parity.test.ts +++ b/config/scripts/cli-main-module-bundle-parity.test.ts @@ -62,7 +62,7 @@ describe('CLI imports of main-process modules', () => { it('finds the imports it is meant to guard', () => { // Why: a broken matcher would make the guard above vacuously pass. expect(findMainImports()).toContainEqual({ - file: 'src/cli/profile-state-location.ts', + file: join('src', 'cli', 'profile-state-location.ts'), module: 'persistence/profile-state/profile-state-active-location' }) expect(findMainImports().length).toBeGreaterThanOrEqual(2) From 82a12f9671528d91d5e89648c727d6cb07be2b10 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 15:57:36 -0700 Subject: [PATCH 17/43] refactor: reuse profile recovery and settings validation --- .../profile-project-move-intent.ts | 5 +---- .../profile-state-offline-settings.ts | 17 ++++------------- .../profile-state-recovery-command.ts | 5 +---- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/main/orca-profiles/profile-project-move-intent.ts b/src/main/orca-profiles/profile-project-move-intent.ts index 12740645facc..67a33bb3962f 100644 --- a/src/main/orca-profiles/profile-project-move-intent.ts +++ b/src/main/orca-profiles/profile-project-move-intent.ts @@ -11,6 +11,7 @@ import { import { basename, join } from 'node:path' import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../shared/secure-file' import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { isRecord } from '../persistence/profile-state/profile-state-document-validation' import { readProfileStateWithRevision, writeSerializedProfileState, @@ -236,7 +237,3 @@ function validateMoveIdentity( function isHash(value: unknown): value is string { return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/main/persistence/profile-state/profile-state-offline-settings.ts b/src/main/persistence/profile-state/profile-state-offline-settings.ts index b07fc82f1f56..f6eecefd5226 100644 --- a/src/main/persistence/profile-state/profile-state-offline-settings.ts +++ b/src/main/persistence/profile-state/profile-state-offline-settings.ts @@ -174,19 +174,10 @@ function assertAcceptedLegacyJson( } function readAgentHookSettingsFromJson(dataFile: string): AgentHookSettings { - if (!existsSync(dataFile)) { - const defaults = getDefaultPersistedState(homedir()).settings - return { - agentStatusHooksEnabled: defaults.agentStatusHooksEnabled !== false, - disabledTuiAgents: normalizeDisabledTuiAgents(defaults.disabledTuiAgents) - } - } - return readAgentHookSettingsFromSnapshot(readFileSync(dataFile, 'utf8')) -} - -function readAgentHookSettingsFromSnapshot(raw: string): AgentHookSettings { - const state = parseProfileStateRoot(raw) - return readAgentHookSettingsFromSettingsValue(state.settings) + const settings = existsSync(dataFile) + ? parseProfileStateRoot(readFileSync(dataFile, 'utf8')).settings + : getDefaultPersistedState(homedir()).settings + return readAgentHookSettingsFromSettingsValue(settings) } function readAgentHookSettingsFromSettingsValue(value: unknown): AgentHookSettings { diff --git a/src/main/persistence/profile-state/profile-state-recovery-command.ts b/src/main/persistence/profile-state/profile-state-recovery-command.ts index 6ca92db8e9aa..381c72673e47 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-command.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-command.ts @@ -15,6 +15,7 @@ import { restoreProfileStateJsonExport } from './profile-state-recovery' import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' import type { ProfileStateMaintenance } from './profile-state-access' import { readProfileStateDomain } from './profile-state-domain-reader' +import { isRecord } from './profile-state-document-validation' import { invalidateHttp1CompatibilityMarker, writeHttp1CompatibilityMarker @@ -130,7 +131,3 @@ function syncHttp1CompatibilityMarkerAfterRollback( } writeHttp1CompatibilityMarker(userDataPath, enabled, profileId) } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} From bbc581ecc15e77922f45f857e80632a45c45bc07 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 16:02:40 -0700 Subject: [PATCH 18/43] fix: drain admitted terminal mutations and restore failed retirements --- .../primary-state-write-runtime.ts | 1 + .../loading-store/primary-state-writes.ts | 17 +- ...-state-pty-retirement-finalization.test.ts | 185 ++++++++++++++++++ .../session-snapshot-operations.ts | 39 ++-- .../loading-store/store-runtime-state.ts | 1 + 5 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts diff --git a/src/main/persistence/loading-store/primary-state-write-runtime.ts b/src/main/persistence/loading-store/primary-state-write-runtime.ts index 57f63bed3f96..de2f768cbf7a 100644 --- a/src/main/persistence/loading-store/primary-state-write-runtime.ts +++ b/src/main/persistence/loading-store/primary-state-write-runtime.ts @@ -6,6 +6,7 @@ export type PrimaryStateWriteOperationsRuntime = Pick< | 'backupRotationInFlight' | 'dataFile' | 'dirtyProfileStateDomains' + | 'durableMutationPhase' | 'flushOrThrow' | 'runDurableMutation' | 'firstPendingSaveAt' diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index 1d6168600f00..d8aca8d7e3c2 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -82,7 +82,7 @@ export class PrimaryStateWriteOperations { if (runtime.profileStateAuthority?.asynchronous) { runtime.profileStateAuthority.assertWritable() } - const mutation = mutate() + const mutation = this.runAdmittedMutationCallback('mutate', mutate) if ( mutation.persist === false || (mutation.persist === 'if-dirty' && @@ -103,7 +103,9 @@ export class PrimaryStateWriteOperations { } } catch (error) { if (profileStateWriterFailureOutcome(error) !== 'indeterminate') { - mutation.rollback?.() + if (mutation.rollback) { + this.runAdmittedMutationCallback('rollback', mutation.rollback) + } } throw error } @@ -111,6 +113,17 @@ export class PrimaryStateWriteOperations { }) } + private runAdmittedMutationCallback(phase: 'mutate' | 'rollback', callback: () => T): T { + const { runtime } = this[primaryStateWriteOperationsContext] + // Finalization drains admitted mutations; the disk wait must not admit new snapshots. + runtime.durableMutationPhase = phase + try { + return callback() + } finally { + runtime.durableMutationPhase = null + } + } + getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { return parseCodexResetCreditAttemptLedger( this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger diff --git a/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts new file mode 100644 index 000000000000..66af237ccdf2 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from 'vitest' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { retirePersistedStablePaneOwner } from '../../ipc/pty/pane/stable-owner' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' +import type { Store } from './store' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'finalizing-retirement-tab', + leafId: TEST_LEAF_1, + ptyId: 'finalizing-retirement-pty', + incarnationId: 'finalizing-retirement-incarnation' +} + +function retire(store: Store, connectionId?: string) { + return retirePersistedStablePaneOwner( + store, + { ...binding, persistedIncarnationId: binding.incarnationId }, + binding.worktreeId, + connectionId + ) +} + +function assertNewSnapshotsRefused(store: Store) { + const session = store.getWorkspaceSession() + expect(() => store.setWorkspaceSession(session)).toThrow('blocking new terminal snapshot work') + expect(() => store.stageWorkspaceSessionBeforeUnload(session)).toThrow( + 'blocking new terminal snapshot work' + ) +} + +describe.each([ + ['running', undefined], + ['maintenance', undefined], + ['freeze', undefined], + ['final', undefined], + ['running', 'retirement-ssh'], + ['maintenance', 'retirement-ssh'], + ['freeze', 'retirement-ssh'], + ['final', 'retirement-ssh'] +] as const)('admitted terminal retirement during %s on host %s', (kind, connectionId) => { + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + const persistedSession = ( + readState: Awaited>['readState'] + ) => { + const state = readState() + return hostId ? state.workspaceSessionsByHostId[hostId] : state.workspaceSession + } + const stop = (store: Store) => + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : kind === 'final' + ? store.flushFinalOrThrowAsync() + : Promise.resolve() + const assertSnapshotAdmission = (store: Store) => { + if (kind !== 'running') { + assertNewSnapshotsRefused(store) + } + } + + it('persists an accepted queued retirement while refusing new snapshots', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding, hostId) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + store.updateSettings({ theme: 'dark' }) + const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await started.promise + const accepted = retire(store, connectionId).then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ) + const stopping = stop(store) + assertSnapshotAdmission(store) + release.resolve() + await previous + await stopping + expect(await accepted).toEqual({ value: true }) + expect(persistedSession(readState).terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + assertSnapshotAdmission(store) + }) + + it('finishes a failed retirement rollback before closing its writer', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding, hostId) + const original = structuredClone(store.getWorkspaceSession(hostId)) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const failure = new ProfileStateWriterError( + 'test-disk-failure', + 'retirement disk refused', + 'known-failure' + ) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { + started.resolve() + await release.promise + throw failure + }) + const rejected = retire(store, connectionId).catch((error: unknown) => error) + await started.promise + expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + store.getWorkspaceSession(hostId).activeTabId = 'newer-active-tab' + const stopping = stop(store) + assertSnapshotAdmission(store) + release.resolve() + await stopping + expect(await rejected).toBe(failure) + expect(store.getWorkspaceSession(hostId)).toEqual({ + ...original, + activeTabId: 'newer-active-tab' + }) + expect(persistedSession(readState).terminalLayoutsByTabId[binding.tabId]).toEqual( + original.terminalLayoutsByTabId[binding.tabId] + ) + assertSnapshotAdmission(store) + }) +}) + +describe.each(['mutate', 'rollback'] as const)('admitted %s scope', (phase) => { + it('closes after a throwing callback and refuses new snapshots during finalization', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('callback failed') + if (phase === 'rollback') { + vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('disk refused')) + } + await expect( + store.runDurableMutation(() => { + if (phase === 'mutate') { + throw failure + } + store.updateSettings({ theme: 'dark' }) + return { + value: undefined, + rollback: () => { + throw failure + } + } + }) + ).rejects.toBe(failure) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeCompleteSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce( + async (domains) => { + started.resolve() + await release.promise + await write(domains) + } + ) + store.updateSettings({ theme: 'light' }) + const stopping = store.flushFinalOrThrowAsync() + try { + await started.promise + assertNewSnapshotsRefused(store) + } finally { + release.resolve() + await stopping + } + }) +}) diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index 8af16c7e3b2d..9c3cf23be7b7 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -3,7 +3,7 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' import { workspaceSessionPatchNeedsFullNormalization } from './terminal-session-cleanup' @@ -18,6 +18,7 @@ import { scheduleSave } from './write-scheduling' type SessionSnapshotOperationsRuntime = Pick< StoreRuntimeState, + | 'durableMutationPhase' | 'pendingSnapshotFileWork' | 'profileMaintenancePending' | 'quitFlushStarted' @@ -48,8 +49,15 @@ export class SessionSnapshotOperations { setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { const resolved = resolveHostId(hostId) + const { runtime } = this[sessionSnapshotOperationsContext] + if (runtime.durableMutationPhase === 'rollback') { + this.assertSnapshotAdmission(true) + // The fieldwise rollback already preserves newer edits; renderer rebasing would undo it. + this.publishSession(session, resolved) + return + } if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.assertSnapshotAdmission() + this.assertSnapshotAdmission(true) setLocalWorkspaceSession(this, session) return } @@ -83,23 +91,32 @@ export class SessionSnapshotOperations { if (Object.hasOwn(patch, 'browserUrlHistory')) { next = pruneWorkspaceSessionBrowserHistory(next) } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this[sessionSnapshotOperationsContext].runtime.state.workspaceSession = next + this.publishSession(next, resolved) + } + + private publishSession(session: WorkspaceSessionState, hostId: ExecutionHostId): void { + const { runtime, scheduling } = this[sessionSnapshotOperationsContext] + if (hostId === LOCAL_EXECUTION_HOST_ID) { + runtime.state.workspaceSession = session } else { - this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId = { - ...this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId, - [resolved]: next + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, + [hostId]: session } } scheduleSave( - this[sessionSnapshotOperationsContext].scheduling, - resolved === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId'] + scheduling, + hostId === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId'] ) } - private assertSnapshotAdmission(): void { + private assertSnapshotAdmission(allowAdmittedMutation = false): void { const { runtime } = this[sessionSnapshotOperationsContext] - if (runtime.profileMaintenancePending || runtime.quitFlushStarted || runtime.writesFrozen) { + if ( + runtime.writesFrozen || + ((runtime.profileMaintenancePending || runtime.quitFlushStarted) && + !(allowAdmittedMutation && runtime.durableMutationPhase !== null)) + ) { throw new Error('Profile maintenance or finalization is blocking new terminal snapshot work') } } diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index e1f4d918c680..ab9683e39e1f 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -52,6 +52,7 @@ export class StoreRuntimeState { inFlightAsyncTmpFile: string | null = null backupRotationInFlight = false writesFrozen = false + durableMutationPhase: 'mutate' | 'rollback' | null = null profileMaintenancePending = false pendingProfileMaintenance: Promise | null = null readonly pendingProfileFlushes = new Set>() From 1312d9800886460bf751ebb55a4710c5c779916a Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 18:23:31 -0700 Subject: [PATCH 19/43] refactor: simplify deployment command forwarding --- src/main/ssh/orcad-remote-deploy.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index 43ca4e43aa8b..be9837ff31e9 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -79,14 +79,10 @@ const DEFAULT_READINESS_TIMEOUT_MS = 90_000 const READINESS_POLL_MS = 500 const STOP_WAIT_SECONDS = 20 -function exec( - options: OrcadDeployOptions, - command: string, - signal = options.signal -): Promise { +function exec(options: OrcadDeployOptions, command: string): Promise { return execCommand(options.conn, command, { wrapCommand: options.host.commandDialect !== 'powershell', - signal + signal: options.signal }) } @@ -169,9 +165,8 @@ async function captureSnapshot( 'way back. Refusing to activate.' ) } + // Empty profiles need no rollback snapshot. if (capture === 'empty') { - // Nothing on the host to lose: a first deployment. Rollback will correctly report that - // it has no snapshot, rather than restoring an archive of nothing over a populated root. return null } return { From e5319cfc8a456c373af9742d9c5caa899f4d339f Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 19:44:57 -0700 Subject: [PATCH 20/43] fix: recover profile maintenance and preserve shutdown compatibility --- config/tsconfig.cli.json | 6 + src/main/index.ts | 9 +- src/main/ipc/orca-profiles.test.ts | 19 ++- src/main/ipc/orca-profiles.ts | 16 +- .../profile-persistence-deadline.test.ts | 37 +++++ .../profile-persistence-deadline.ts | 12 +- src/main/orcad/orcad-entry.test.ts | 4 +- src/main/orcad/orcad-entry.ts | 36 +---- src/main/orcad/orcad-launch-contract.test.ts | 15 +- src/main/orcad/orcad-lifecycle.ts | 57 ++++++- src/main/orcad/orcad-push-startup.test.ts | 14 +- .../loading-store/primary-state-write-json.ts | 87 ++++++++++ .../primary-state-write-runtime.ts | 16 ++ .../loading-store/primary-state-write-sync.ts | 3 + .../primary-state-write-worker.ts | 7 + .../loading-store/primary-state-writes.ts | 111 ++++--------- .../loading-store/profile-state-authority.ts | 2 +- ...le-state-maintenance-compatibility.test.ts | 61 +++++++ ...profile-state-maintenance-recovery.test.ts | 152 ++++++++++++++++++ .../profile-state-maintenance.test.ts | 16 +- .../profile-state-maintenance.ts | 117 +++++++++----- ...-state-pty-retirement-finalization.test.ts | 54 ++++--- .../profile-state-store-backups.test.ts | 6 +- .../profile-state-update-quit.test.ts | 17 +- .../profile-state-worker-coordination.test.ts | 51 ++++++ .../loading-store/store-runtime-state.ts | 1 + src/main/persistence/loading-store/store.ts | 2 +- .../loading-store/write-flush-barriers.ts | 11 +- .../loading-store/write-scheduling.ts | 6 +- .../profile-state-access-identity.ts | 56 +++++++ .../profile-state-access-owner.ts | 44 ++++- .../profile-state-access.test.ts | 140 +++++++++++++++- .../profile-state-authority-bootstrap.test.ts | 36 +++++ .../profile-state-authority-bootstrap.ts | 48 ++++-- .../profile-state-authority-exports.ts | 14 ++ .../profile-state/profile-state-backup-job.ts | 2 + .../profile-state-backup-rotation.ts | 19 ++- ...ofile-state-backup-temporary-files.test.ts | 91 +++++++++++ .../profile-state-backup-temporary-files.ts | 71 ++++++++ .../profile-state-backup-worker-entry.ts | 6 +- .../profile-state-backup-worker.test.ts | 33 ++++ .../profile-state-backup-worker.ts | 95 ++++++----- .../profile-state-database-snapshot.test.ts | 10 ++ .../profile-state-database-snapshot.ts | 21 ++- ...-state-json-compatibility-recovery.test.ts | 26 ++- .../profile-state-live-store-factory.test.ts | 8 +- .../profile-state-startup-failure.test.ts | 7 +- .../profile-state-startup-failure.ts | 8 +- .../profile-state-versioned-export.test.ts | 32 ++++ .../profile-state-versioned-export.ts | 21 ++- .../profile-state-worker-authority.test.ts | 2 +- .../profile-state-worker-authority.ts | 19 ++- .../profile-state-write-transaction.test.ts | 20 +++ .../profile-state-write-transaction.ts | 9 ++ ...rowser-process-user-agent-ordering.test.ts | 82 ++++++++-- .../startup/desktop-startup-ordering.test.ts | 2 +- src/main/startup/main-process-preflight.ts | 32 ++-- src/main/startup/main-process-quit.ts | 19 ++- ...-process-ready-persistence-cleanup.test.ts | 15 +- src/main/startup/main-process-ready.ts | 2 + src/main/startup/main-process-state.ts | 6 + .../startup/main-window-core-services.test.ts | 7 +- src/main/startup/main-window-core-services.ts | 1 - src/main/updater.quit-and-install.test.ts | 16 +- src/main/updater/updater-install-support.ts | 18 ++- src/main/updater/updater-state.ts | 2 + 66 files changed, 1630 insertions(+), 355 deletions(-) create mode 100644 src/main/orca-profiles/profile-persistence-deadline.test.ts create mode 100644 src/main/persistence/loading-store/primary-state-write-json.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-access-identity.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-temporary-files.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 10b35b9c7079..df86f859f851 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -67,6 +67,12 @@ "../src/main/persistence/profile-state/profile-state-active-location.ts", "../src/main/persistence/profile-state/profile-state-access.ts", "../src/main/persistence/profile-state/profile-state-access-owner.ts", + "../src/main/persistence/profile-state/profile-state-access-identity.ts", + "../src/main/daemon/daemon-process-start-time.ts", + "../src/main/daemon/daemon-process-identity-query.ts", + "../src/main/startup/startup-diagnostics.ts", + "../src/main/persistence/profile-state/profile-state-versioned-export.ts", + "../src/main/persistence/profile-state/profile-state-backup-temporary-files.ts", "../src/main/persistence/profile-state/profile-state-database-quarantine.ts", "../src/main/persistence/profile-state/profile-state-storage-classification.ts", "../src/main/durable-file-write.ts", diff --git a/src/main/index.ts b/src/main/index.ts index a55707700c50..d4de6cac7e91 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -120,10 +120,9 @@ if (preflightReady) { handleMacAppActivation }) } catch (error) { - const message = formatProfileStateStartupFailure(error) - if (message === undefined) { - throw error - } + const message = + formatProfileStateStartupFailure(error) ?? + `Orca could not finish starting: ${error instanceof Error ? error.message : String(error)}` const failureClass = profileStateStartupFailureClass(error) if (failureClass !== undefined) { recordDurableCrashBreadcrumb('profile_state_startup_failed', { @@ -135,7 +134,7 @@ if (preflightReady) { try { await presentProfileStateStartupRecoveryDialog({ message, - ...(failureClass === 'recovery-required' + ...(failureClass === 'recovery-required' || failureClass === 'ambiguous-authority' ? { recoveryCommand: 'orca profile state exports' } : {}), showMessageBox: (options) => dialog.showMessageBox(options), diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index 3ae540275015..2a8b24851d3a 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -231,7 +231,7 @@ describe('registerOrcaProfileHandlers', () => { handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' }) ) const rejection = expect(switchProfile).rejects.toThrow('orca_profile_persistence_timeout') - await vi.advanceTimersByTimeAsync(20_000) + await vi.advanceTimersByTimeAsync(60_000) await rejection expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() @@ -385,6 +385,23 @@ describe('registerOrcaProfileHandlers', () => { expect(appQuitMock).toHaveBeenCalledOnce() }) + it('keeps the active profile writable during a transfer between inactive profiles', async () => { + const store = makeStoreMock() + getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'active', profiles: [] }) + transferOrcaProfileProjectMock.mockReturnValue({ status: 'transferred', mode: 'copy' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the Store operations exercised by the handlers. + registerOrcaProfileHandlers(store as never) + await handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }) + expect(store.beginProfileMaintenance).not.toHaveBeenCalled() + expect(store.freezeWrites).not.toHaveBeenCalled() + expect(store.flushPendingOrThrowAsync).toHaveBeenCalledBefore(transferOrcaProfileProjectMock) + }) + it('rejects transfers that would mutate the active target profile offline', async () => { getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'work', diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index 18d4310dc4bd..21e8c0fb5bb9 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -57,14 +57,10 @@ type RegisterOrcaProfileHandlersOptions = { } function profileIdFromArgs(args: unknown): string { - if ( - !args || - typeof args !== 'object' || - typeof (args as SwitchOrcaProfileArgs).profileId !== 'string' - ) { - throw new Error('invalid_orca_profile_id') - } - const profileId = (args as SwitchOrcaProfileArgs).profileId.trim() + const profileId = + args && typeof args === 'object' && 'profileId' in args && typeof args.profileId === 'string' + ? args.profileId.trim() + : '' if (!profileId) { throw new Error('invalid_orca_profile_id') } @@ -254,6 +250,10 @@ export function registerOrcaProfileHandlers( } return result } + if (args.sourceProfileId !== current.activeProfileId) { + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + return transferOrcaProfileProject(args, getProfileUserDataPath()) + } const maintenance = await flushActiveProfileBeforeFileMutation(store) try { return transferOrcaProfileProject(args, getProfileUserDataPath()) diff --git a/src/main/orca-profiles/profile-persistence-deadline.test.ts b/src/main/orca-profiles/profile-persistence-deadline.test.ts new file mode 100644 index 000000000000..87e0507c5072 --- /dev/null +++ b/src/main/orca-profiles/profile-persistence-deadline.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProfileStateMaintenance } from '../persistence/loading-store/profile-state-authority' +import type { ProfileStateMaintenanceOptions } from '../persistence/loading-store/profile-state-maintenance' +import { flushActiveProfileBeforeFileMutation } from './profile-persistence-deadline' + +afterEach(() => vi.useRealTimers()) + +describe('profile persistence deadline', () => { + it('allows the writer request deadline to finish before imposing maintenance cancellation', async () => { + vi.useFakeTimers() + const result = Promise.withResolvers() + const beginProfileMaintenance = vi.fn( + (_options?: ProfileStateMaintenanceOptions) => result.promise + ) + const pending = flushActiveProfileBeforeFileMutation({ beginProfileMaintenance }) + await vi.advanceTimersByTimeAsync(30_000) + expect(beginProfileMaintenance.mock.calls[0]?.[0]?.signal?.aborted).not.toBe(true) + const handle = { resume: vi.fn(async () => {}) } + result.resolve(handle) + await expect(pending).resolves.toBe(handle) + expect(handle.resume).not.toHaveBeenCalled() + }) + + it('resumes a clean pause that finishes after the caller times out', async () => { + vi.useFakeTimers() + const result = Promise.withResolvers() + const beginProfileMaintenance = vi.fn(() => result.promise) + const pending = flushActiveProfileBeforeFileMutation({ beginProfileMaintenance }) + const rejected = expect(pending).rejects.toThrow('orca_profile_persistence_timeout') + await vi.advanceTimersByTimeAsync(60_000) + await rejected + const handle = { resume: vi.fn(async () => {}) } + result.resolve(handle) + await vi.advanceTimersByTimeAsync(0) + expect(handle.resume).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orca-profiles/profile-persistence-deadline.ts b/src/main/orca-profiles/profile-persistence-deadline.ts index 3d02aacaf96d..ba6277162420 100644 --- a/src/main/orca-profiles/profile-persistence-deadline.ts +++ b/src/main/orca-profiles/profile-persistence-deadline.ts @@ -2,7 +2,7 @@ import type { Store } from '../persistence' import type { ProfileStateMaintenance } from '../persistence/loading-store/profile-state-authority' import type { ProfileStateMaintenanceOptions } from '../persistence/loading-store/profile-state-maintenance' -const PROFILE_PERSISTENCE_TIMEOUT_MS = 20_000 +const PROFILE_PERSISTENCE_TIMEOUT_MS = 60_000 export async function flushActiveProfileBeforeFileMutation( store: Pick, @@ -18,7 +18,15 @@ export async function flushActiveProfileBeforeFileMutation( }) try { return await Promise.race([ - store.beginProfileMaintenance({ ...options, signal: controller.signal }), + store + .beginProfileMaintenance({ ...options, signal: controller.signal }) + .then(async (handle) => { + if (controller.signal.aborted && options.flush !== false) { + await handle.resume() + throw new Error('orca_profile_persistence_timeout') + } + return handle + }), deadline ]) } finally { diff --git a/src/main/orcad/orcad-entry.test.ts b/src/main/orcad/orcad-entry.test.ts index c40bdf447aa7..63ec99ca6276 100644 --- a/src/main/orcad/orcad-entry.test.ts +++ b/src/main/orcad/orcad-entry.test.ts @@ -15,7 +15,9 @@ describe('orcad profile-state shutdown', () => { await flushOrcadProfileStoreForShutdown(store) - expect(store.flushFinalOrThrowAsync).toHaveBeenCalledOnce() + expect(store.flushFinalOrThrowAsync).toHaveBeenCalledExactlyOnceWith({ + exportJsonCompatibility: true + }) expect(store.freezeWritesAsync).toHaveBeenCalledOnce() expect(events).toEqual(['flush', 'freeze']) }) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index c5085931b77f..70a028419ce8 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -15,21 +15,16 @@ import process from 'node:process' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import { setSecretStore, type SecretStore } from '../../shared/secret-store' import type { ServeReadiness } from '../server/serve-readiness' -import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { resolveOrcadBrowserProvider } from './orcad-browser-provider' import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' import { describeOrcadBindExposure, OrcadBindAddressError, resolveOrcadBindHost } from './orcad-bind-address' -import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock' -import { flushOrcadProfileStoreForShutdown, startOrcadWithLifecycle } from './orcad-lifecycle' +import { OrcadInstanceLockError } from './orcad-instance-lock' +import { flushOrcadProfileStoreForShutdown, startOrcadWithHost } from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' -import { - acquireProfileStateRuntimeAdmission, - ProfileStateAccessError -} from '../persistence/profile-state/profile-state-access' +import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access' import { changedAiVaultSearchSettings, type AiVaultSearchSettings @@ -114,29 +109,10 @@ export type OrcadHandle = { */ export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() - const userDataPath = resolveUserDataPath() - // Process lifetime covers workers even when a failed shutdown cannot finish their teardown. - acquireProfileStateRuntimeAdmission(userDataPath) - // Why before anything else touches the root: the profile index, the store and the daemon - // runtime dir all live under it, and two orcads sharing them corrupt state silently. This - // is also the last point at which refusing costs nothing. - const instanceLock = acquireOrcadInstanceLock(userDataPath) - const browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) - setRuntimeBrowserCommandsFactory(browserProvider?.factory ?? null, { - headless: browserProvider !== null, - ...(browserProvider ? { isAvailable: () => browserProvider.isAvailable() } : {}) - }) - return startOrcadWithLifecycle( + return startOrcadWithHost( + resolveUserDataPath(), (registerCleanup) => startOrcadRuntime(options, registerCleanup), - async () => { - try { - await browserProvider?.stop() - } finally { - setRuntimeBrowserCommandsFactory(null) - runOrcadQuitHandlers() - instanceLock.release() - } - } + () => runOrcadQuitHandlers() ) } diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts index f75e4379ea00..30d805f63fac 100644 --- a/src/main/orcad/orcad-launch-contract.test.ts +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -61,7 +61,7 @@ describe('orcad lifecycle cleanup', () => { ).rejects.toThrow('startup failed') expect(cleanupRuntime).toHaveBeenCalledOnce() - expect(cleanupHost).toHaveBeenCalledOnce() + expect(cleanupHost).toHaveBeenCalledExactlyOnceWith(true) }) it('preserves the startup error when rollback also fails', async () => { @@ -100,4 +100,17 @@ describe('orcad lifecycle cleanup', () => { expect(cleanupRuntime).toHaveBeenCalledOnce() expect(cleanupHost).toHaveBeenCalledOnce() }) + + it('keeps the host aware of failed runtime teardown so it cannot release profile admission', async () => { + const failure = new Error('profile writer still running') + const cleanupHost = vi.fn(async () => {}) + const handle = await startOrcadWithLifecycle(async (registerCleanup) => { + registerCleanup(async () => { + throw failure + }) + return {} + }, cleanupHost) + await expect(handle.stop()).rejects.toBe(failure) + expect(cleanupHost).toHaveBeenCalledExactlyOnceWith(false) + }) }) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index ce3001d67089..208b805ec79b 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -1,3 +1,11 @@ +import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' +import { acquireOrcadInstanceLock } from './orcad-instance-lock' +import { + acquireProfileStateRuntimeAdmission, + type ProfileStateRuntimeAdmission +} from '../persistence/profile-state/profile-state-access' + function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promise { let completion: Promise | null = null return () => { @@ -8,14 +16,16 @@ function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promi export async function startOrcadWithLifecycle( start: (registerRuntimeCleanup: (cleanup: () => Promise) => void) => Promise, - cleanupHost: () => Promise + cleanupHost: (runtimeCleanupSucceeded: boolean) => Promise ): Promise }> { let cleanupRuntime = async (): Promise => {} const cleanup = createIdempotentOrcadCleanup(async () => { + let runtimeCleanupSucceeded = false try { await cleanupRuntime() + runtimeCleanupSucceeded = true } finally { - await cleanupHost() + await cleanupHost(runtimeCleanupSucceeded) } }) try { @@ -34,12 +44,51 @@ export async function startOrcadWithLifecycle( } } +/** Keep profile admission until every runtime writer has stopped. */ +export async function startOrcadWithHost( + userDataPath: string, + start: (registerCleanup: (cleanup: () => Promise) => void) => Promise, + runQuitHandlers: () => void +): Promise }> { + const instanceLock = acquireOrcadInstanceLock(userDataPath) + let admission: ProfileStateRuntimeAdmission | undefined + let browserProvider: Awaited> | undefined + return startOrcadWithLifecycle( + async (registerCleanup) => { + admission = acquireProfileStateRuntimeAdmission(userDataPath) + browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) + const provider = browserProvider + setRuntimeBrowserCommandsFactory(provider?.factory ?? null, { + headless: provider !== null, + ...(provider ? { isAvailable: () => provider.isAvailable() } : {}) + }) + return start(registerCleanup) + }, + async (runtimeCleanupSucceeded) => { + try { + await browserProvider?.stop() + } finally { + setRuntimeBrowserCommandsFactory(null) + runQuitHandlers() + try { + // Failed teardown excludes recovery until the process actually exits. + if (runtimeCleanupSucceeded) { + admission?.release() + } + } finally { + instanceLock.release() + } + } + } + ) +} + export async function flushOrcadProfileStoreForShutdown(store: { - flushFinalOrThrowAsync(): Promise + flushFinalOrThrowAsync(options?: { exportJsonCompatibility?: boolean }): Promise freezeWritesAsync(): Promise }): Promise { try { - await store.flushFinalOrThrowAsync() + await store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) } finally { await store.freezeWritesAsync() } diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 326f94af1dc4..21ad507c0164 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' @@ -7,6 +7,7 @@ import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-n import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' import { createPushHostKeypair } from '../runtime/push/push-host-challenge-fixtures' import { acquireProfileStateMaintenance } from '../persistence/profile-state/profile-state-access' +import { profileStateAccessPaths } from '../persistence/profile-state/profile-state-access-owner' const state = vi.hoisted(() => ({ root: '', @@ -168,8 +169,19 @@ it('starts push after RPC identity is available and stops dispatch on shutdown', } finally { await host.stop() } + expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) + acquireProfileStateMaintenance(state.root).release() expect(state.controller.getListenerCount()).toBe(0) expect(await state.controller.registerPushDevice({} as never)).toMatchObject({ registered: false }) }) + +it('releases admission when host setup fails before a runtime exists', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-setup-failure-')) + state.browserProvider.mockRejectedValueOnce(new Error('browser setup failed')) + const { startOrcad } = await import('./orcad-entry') + await expect(startOrcad()).rejects.toThrow('browser setup failed') + expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) + acquireProfileStateMaintenance(state.root).release() +}) diff --git a/src/main/persistence/loading-store/primary-state-write-json.ts b/src/main/persistence/loading-store/primary-state-write-json.ts new file mode 100644 index 000000000000..78130afbb1f1 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-json.ts @@ -0,0 +1,87 @@ +import { mkdir, open, rm } from 'node:fs/promises' +import { dirname } from 'node:path' +import { durableWriteTempPath, renameDurable } from '../../durable-file-write' +import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' +import { + canReuseDurableProfileState, + markPrimaryStateWriteDurable +} from './primary-state-write-runtime' + +export async function writeJsonProfileState( + { runtime, serialization, backups }: PrimaryStateWriteOperationsContext, + gen: number +): Promise { + const built = serialization.buildStateToSave() + const { stateHash, protectedSecretUpdates } = built + // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. + if (canReuseDurableProfileState(runtime, stateHash)) { + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + markPrimaryStateWriteDurable(runtime, gen) + return true + } + const dataFile = runtime.dataFile + const payload = built.payload + const dir = dirname(dataFile) + await mkdir(dir, { recursive: true }).catch(() => {}) + const tmpFile = durableWriteTempPath(dataFile) + + // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. + let renamed = false + try { + // Why: fsync before rename, then fsync the directory; see writeFileDurable. + const handle = await open(tmpFile, 'w') + try { + // Already UTF-8 bytes: passing the string here would re-encode the whole state on the main thread. + await handle.writeFile(payload) + await handle.sync() + } finally { + await handle.close() + } + // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. + if (runtime.writeGeneration !== gen) { + return false + } + runtime.inFlightAsyncTmpFile = tmpFile + try { + await renameDurable(tmpFile, dataFile) + renamed = true + } catch (error) { + if ( + !(error instanceof Error && 'code' in error && error.code === 'ENOENT') || + runtime.writeGeneration === gen + ) { + throw error + } + } finally { + if (runtime.inFlightAsyncTmpFile === tmpFile) { + runtime.inFlightAsyncTmpFile = null + } + } + // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. + if (renamed && runtime.writeGeneration === gen) { + runtime.lastWrittenStateHash = stateHash + runtime.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) + } else if (renamed) { + runtime.lastWrittenStateHash = null + } + if (renamed) { + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + markPrimaryStateWriteDurable(runtime, gen) + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (!renamed) { + return false + } + // Why (#1158): rotate only after the primary rename while this write still owns its generation. + if (runtime.writeGeneration !== gen) { + return true + } + await backups.rotateBackupsAsync(dataFile) + return true +} diff --git a/src/main/persistence/loading-store/primary-state-write-runtime.ts b/src/main/persistence/loading-store/primary-state-write-runtime.ts index de2f768cbf7a..2c79fa2b073f 100644 --- a/src/main/persistence/loading-store/primary-state-write-runtime.ts +++ b/src/main/persistence/loading-store/primary-state-write-runtime.ts @@ -7,6 +7,7 @@ export type PrimaryStateWriteOperationsRuntime = Pick< | 'dataFile' | 'dirtyProfileStateDomains' | 'durableMutationPhase' + | 'fatalMutationError' | 'flushOrThrow' | 'runDurableMutation' | 'firstPendingSaveAt' @@ -51,3 +52,18 @@ export function canReuseDurableProfileState( authority?.assertCurrentRevision?.() return true } + +export async function stopAfterFailedPrimaryStateMutation( + runtime: PrimaryStateWriteOperationsRuntime, + error: unknown +): Promise { + // A throwing callback never returns its rollback; do not persist a partial edit. + runtime.writesFrozen = true + runtime.quitFlushStarted = true + runtime.fatalMutationError = error instanceof Error ? error : new Error(String(error)) + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + await runtime.profileStateAuthority?.close?.() +} diff --git a/src/main/persistence/loading-store/primary-state-write-sync.ts b/src/main/persistence/loading-store/primary-state-write-sync.ts index afc7302dfcc6..fd598b06158f 100644 --- a/src/main/persistence/loading-store/primary-state-write-sync.ts +++ b/src/main/persistence/loading-store/primary-state-write-sync.ts @@ -13,6 +13,9 @@ export function writeToDiskSync( opts: { force?: boolean; skipBackupRotation?: boolean; expectedGeneration?: number } = {} ): boolean { const { runtime, serialization, backups } = context + if (runtime.fatalMutationError) { + throw runtime.fatalMutationError + } if (runtime.writesFrozen) { return false } diff --git a/src/main/persistence/loading-store/primary-state-write-worker.ts b/src/main/persistence/loading-store/primary-state-write-worker.ts index 2d21b9e91b52..71862bc3b443 100644 --- a/src/main/persistence/loading-store/primary-state-write-worker.ts +++ b/src/main/persistence/loading-store/primary-state-write-worker.ts @@ -44,6 +44,13 @@ export async function writeProfileStateInWorker( runtime.lastWrittenStateHash = runtime.writeGeneration === generation ? prepared.stateHash : null markPrimaryStateWriteDurable(runtime, generation) + if (runtime.writeGeneration === generation) { + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.firstPendingSaveAt = null + } } catch (error) { restoreIntent() throw error diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index d8aca8d7e3c2..6ce60b9e2c42 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -1,14 +1,10 @@ import { unlinkSync } from 'node:fs' -import { mkdir, open, rm } from 'node:fs/promises' -import { durableWriteTempPath, renameDurable } from '../../durable-file-write' -import { dirname } from 'node:path' import { parseCodexResetCreditAttemptLedger, type CodexResetCreditAttemptLedger } from '../../../shared/codex-reset-credit-attempt-ledger' import { - canReuseDurableProfileState, - markPrimaryStateWriteDurable, + stopAfterFailedPrimaryStateMutation, type PrimaryStateWriteOperationsRuntime } from './primary-state-write-runtime' import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' @@ -16,6 +12,7 @@ import type { BackupRecoveryRotationOperations } from './backup-recovery-rotatio import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' import { writeToDiskSync } from './primary-state-write-sync' import { writeProfileStateInWorker } from './primary-state-write-worker' +import { writeJsonProfileState } from './primary-state-write-json' import type { DurableProfileStateMutation } from './store-runtime-state' import { profileStateWriterFailureOutcome } from '../profile-state/profile-state-writer-errors' @@ -82,7 +79,13 @@ export class PrimaryStateWriteOperations { if (runtime.profileStateAuthority?.asynchronous) { runtime.profileStateAuthority.assertWritable() } - const mutation = this.runAdmittedMutationCallback('mutate', mutate) + let mutation: DurableProfileStateMutation + try { + mutation = this.runAdmittedMutationCallback('mutate', mutate) + } catch (error) { + await stopAfterFailedPrimaryStateMutation(runtime, error) + throw error + } if ( mutation.persist === false || (mutation.persist === 'if-dirty' && @@ -104,7 +107,12 @@ export class PrimaryStateWriteOperations { } catch (error) { if (profileStateWriterFailureOutcome(error) !== 'indeterminate') { if (mutation.rollback) { - this.runAdmittedMutationCallback('rollback', mutation.rollback) + try { + this.runAdmittedMutationCallback('rollback', mutation.rollback) + } catch (rollbackError) { + await stopAfterFailedPrimaryStateMutation(runtime, rollbackError) + throw rollbackError + } } } throw error @@ -153,7 +161,7 @@ export class PrimaryStateWriteOperations { export function enqueueWrite( owner: PrimaryStateWriteOperations, - options: { fullCheckpoint?: boolean; signal?: AbortSignal } = {} + options: { fullCheckpoint?: boolean; skipIfClean?: boolean; signal?: AbortSignal } = {} ): Promise { return enqueuePrimaryStateOperation(owner, async () => { const { runtime } = owner[primaryStateWriteOperationsContext] @@ -161,6 +169,14 @@ export function enqueueWrite( if (signal?.aborted) { throw new Error('Persistence flush aborted') } + if ( + options.skipIfClean && + runtime.dirtyProfileStateDomains?.size === 0 && + runtime.pendingAutomationRunsAfter === undefined && + runtime.lastDurableWriteGeneration >= runtime.writeGeneration + ) { + return + } // A queued predecessor can clear dirty domains before this checkpoint runs. if (options.fullCheckpoint) { runtime.dirtyProfileStateDomains = null @@ -209,7 +225,10 @@ export function enqueuePrimaryStateOperation( } export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { - const { runtime, serialization, backups } = owner[primaryStateWriteOperationsContext] + const { runtime } = owner[primaryStateWriteOperationsContext] + if (runtime.fatalMutationError) { + throw runtime.fatalMutationError + } if (runtime.writesFrozen) { return false } @@ -224,79 +243,7 @@ export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Prom // SQL commits are synchronous so both entry points share the same generation fence. return writeToDiskSync(owner[primaryStateWriteOperationsContext], { expectedGeneration: gen }) } - const built = serialization.buildStateToSave() - const { stateHash, protectedSecretUpdates } = built - // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. - if (canReuseDurableProfileState(runtime, stateHash)) { - runtime.dirtyProfileStateDomains = new Set() - runtime.pendingAutomationRunsAfter = undefined - markPrimaryStateWriteDurable(runtime, gen) - return true - } - const dataFile = runtime.dataFile - const payload = built.payload - const dir = dirname(dataFile) - await mkdir(dir, { recursive: true }).catch(() => {}) - const tmpFile = durableWriteTempPath(dataFile) - - // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. - let renamed = false - try { - // Why: fsync before rename, then fsync the directory; see writeFileDurable. - const handle = await open(tmpFile, 'w') - try { - // Already UTF-8 bytes: passing the string here would re-encode the whole state on the main thread. - await handle.writeFile(payload) - await handle.sync() - } finally { - await handle.close() - } - // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. - if (runtime.writeGeneration !== gen) { - return false - } - runtime.inFlightAsyncTmpFile = tmpFile - try { - await renameDurable(tmpFile, dataFile) - renamed = true - } catch (error) { - if ( - !(error instanceof Error && 'code' in error && error.code === 'ENOENT') || - runtime.writeGeneration === gen - ) { - throw error - } - } finally { - if (runtime.inFlightAsyncTmpFile === tmpFile) { - runtime.inFlightAsyncTmpFile = null - } - } - // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. - if (renamed && runtime.writeGeneration === gen) { - runtime.lastWrittenStateHash = stateHash - runtime.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) - } else if (renamed) { - runtime.lastWrittenStateHash = null - } - if (renamed) { - runtime.dirtyProfileStateDomains = new Set() - runtime.pendingAutomationRunsAfter = undefined - markPrimaryStateWriteDurable(runtime, gen) - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (!renamed) { - return false - } - // Why (#1158): rotate only after the primary rename while this write still owns its generation. - if (runtime.writeGeneration !== gen) { - return true - } - await backups.rotateBackupsAsync(dataFile) - return true + return writeJsonProfileState(owner[primaryStateWriteOperationsContext], gen) } export function installPrimaryStateWriteOperationsContext( diff --git a/src/main/persistence/loading-store/profile-state-authority.ts b/src/main/persistence/loading-store/profile-state-authority.ts index 355df722e94f..0ced5ba32485 100644 --- a/src/main/persistence/loading-store/profile-state-authority.ts +++ b/src/main/persistence/loading-store/profile-state-authority.ts @@ -44,7 +44,7 @@ export type ProfileStateAuthority = { scheduleBackup?: () => void /** Drain owned backup handles before shutdown or profile file mutations. */ - drainBackups?: () => Promise + drainBackups?: (cancel?: boolean) => Promise /** Optionally publish a durable JSON export for rollback or a compatibility runtime. */ writeJsonExport?: (targetPath: string) => number diff --git a/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts new file mode 100644 index 000000000000..94e36fcdcfea --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts @@ -0,0 +1,61 @@ +import { existsSync, readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { createWorkerMaintenanceFixture } from './profile-state-maintenance-fixture' +import { profileStateJsonExportPaths } from '../profile-state/profile-state-export-path' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('maintenance compatibility checkpoint', () => { + it('exports the current worker state before a clean profile switch releases its writer', async () => { + const { store, dataFile, readState } = await createWorkerMaintenanceFixture() + expect(existsSync(dataFile)).toBe(false) + store.updateSettings({ theme: 'dark' }) + store.getWorkspaceSession().activeTabId = 'latest-tab' + const maintenance = await store.beginProfileMaintenance() + const snapshot = JSON.parse(readFileSync(dataFile, 'utf8')) + expect(snapshot).toEqual(readState()) + expect(snapshot.settings.theme).toBe('dark') + expect(snapshot.workspaceSession.activeTabId).toBe('latest-tab') + const [retained] = profileStateJsonExportPaths(dataFile) + expect(JSON.parse(readFileSync(retained, 'utf8'))).toEqual(snapshot) + await maintenance.resume() + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + }) + + it('does not publish compatibility files for a recovery pause', async () => { + const { store, dataFile } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'dark' }) + await store.beginProfileMaintenance({ flush: false }) + expect(existsSync(dataFile)).toBe(false) + expect(profileStateJsonExportPaths(dataFile)).toEqual([]) + }) + + it('resumes admission after a known export failure while preserving the committed state', async () => { + const { store, authority, dataFile, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(authority, 'writeJsonCompatibilityExportAsync').mockRejectedValueOnce( + new Error('export disk refused') + ) + store.updateSettings({ theme: 'dark' }) + await expect(store.beginProfileMaintenance()).rejects.toThrow('export disk refused') + expect(existsSync(dataFile)).toBe(false) + expect(readState().settings.theme).toBe('dark') + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + await (await store.beginProfileMaintenance()).resume() + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts new file mode 100644 index 000000000000..ed260caee2a4 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from 'vitest' +import * as backupWorker from '../profile-state/profile-state-backup-worker' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('failed maintenance recovery', () => { + it.each(['maintenance', 'final', 'freeze'] as const)( + '%s cancels a backup awaited by an earlier admitted flush', + async (kind) => { + const { store, authority } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const canceled = maintenanceBarrier() + vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( + async (_job, options) => { + started.resolve() + await new Promise((resolve) => + options?.signal?.addEventListener('abort', () => resolve(), { once: true }) + ) + canceled.resolve() + throw new Error('backup aborted') + } + ) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await started.promise + const draining = maintenanceBarrier() + const drain = authority.drainBackups.bind(authority) + vi.spyOn(authority, 'drainBackups').mockImplementationOnce((cancel) => { + draining.resolve() + return drain(cancel) + }) + const earlier = store.flushPendingOrThrowAsync() + await draining.promise + const stop = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'final' + ? store.flushFinalOrThrowAsync() + : store.freezeWritesAsync() + await canceled.promise + await Promise.all([earlier, stop]) + } + ) + + it('cancels between checkpoints without aborting an acknowledged write', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeCompleteSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce( + async (domains) => { + started.resolve() + await release.promise + await write(domains) + } + ) + const abort = vi.spyOn(authority, 'abort') + const controller = new AbortController() + store.updateSettings({ theme: 'dark' }) + const pending = store.beginProfileMaintenance({ signal: controller.signal }) + const rejected = expect(pending).rejects.toThrow('aborted') + await started.promise + controller.abort() + release.resolve() + await rejected + expect(abort).not.toHaveBeenCalled() + store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'after-cancellation' }) + await store.flushPendingOrThrowAsync() + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'after-cancellation' } + }) + }) + + it('restores the writer and snapshot admission after a known failed checkpoint', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce( + new Error('disk refused') + ) + store.updateSettings({ theme: 'dark' }) + await expect(store.beginProfileMaintenance()).rejects.toThrow('disk refused') + + store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'after-failure' }) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'after-failure' } + }) + await (await store.beginProfileMaintenance()).resume() + }) + + it('keeps changed storage fenced when recovering a known failure', async () => { + const { store, authority, peer, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.updateSettings({ theme: 'dark' }) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce(async () => { + const writer = peer() + writer.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) + writer.close() + throw new Error('disk refused') + }) + await expect(store.beginProfileMaintenance()).rejects.toThrow('disk refused') + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) + expect(readState().peer).toEqual({ preserved: true }) + }) + + it('does not extend the maintenance checkpoint for unadmitted saves', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'dark' }) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeCompleteSerializedDomains.bind(authority) + const checkpoint = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + const pending = store.beginProfileMaintenance() + await started.promise + for (let terminalFontSize = 12; terminalFontSize < 32; terminalFontSize++) { + store.updateSettings({ terminalFontSize }) + } + release.resolve() + const maintenance = await pending + expect(checkpoint).toHaveBeenCalledOnce() + await maintenance.resume() + await store.flushPendingOrThrowAsync() + expect(readState().settings.terminalFontSize).toBe(31) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance.test.ts b/src/main/persistence/loading-store/profile-state-maintenance.test.ts index 16246cb29d71..f5dcb451243b 100644 --- a/src/main/persistence/loading-store/profile-state-maintenance.test.ts +++ b/src/main/persistence/loading-store/profile-state-maintenance.test.ts @@ -158,16 +158,20 @@ describe('profile maintenance admission', () => { } ) - it('waits for the backup worker before closing and releasing maintenance', async () => { + it('cancels a backup and waits for its worker to exit before releasing maintenance', async () => { const { store, authority, databaseFile } = await createWorkerMaintenanceFixture() const started = maintenanceBarrier() + const canceled = maintenanceBarrier() const release = maintenanceBarrier() - const run = backupWorker.runProfileStateBackupWorker vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( - async (...args) => { + async (_job, options) => { started.resolve() + await new Promise((resolve) => + options?.signal?.addEventListener('abort', () => resolve(), { once: true }) + ) + canceled.resolve() await release.promise - await run(...args) + throw new Error('backup aborted') } ) await store.runDurableMutation(() => { @@ -181,13 +185,13 @@ describe('profile maintenance admission', () => { void paused.then(() => { done = true }) - await new Promise((resolve) => setImmediate(resolve)) + await canceled.promise expect(done).toBe(false) expect(close).not.toHaveBeenCalled() release.resolve() await paused expect(close).toHaveBeenCalledOnce() - expect(profileStateDatabaseBackups(databaseFile)).toHaveLength(1) + expect(profileStateDatabaseBackups(databaseFile)).toHaveLength(0) }) it('drains an accepted flush before rejecting canceled maintenance', async () => { diff --git a/src/main/persistence/loading-store/profile-state-maintenance.ts b/src/main/persistence/loading-store/profile-state-maintenance.ts index ef7da5b5a637..2d65d50a85a0 100644 --- a/src/main/persistence/loading-store/profile-state-maintenance.ts +++ b/src/main/persistence/loading-store/profile-state-maintenance.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { dirname } from 'node:path' import { profileStateDatabaseFile } from '../../../shared/profile-state-storage-paths' +import { profileStateWriterFailureOutcome } from '../profile-state/profile-state-writer-errors' import { hasProfileStateDatabaseFiles } from '../profile-state/profile-state-storage-classification' import type { ProfileStateMaintenance } from './profile-state-authority' import type { StoreDomains } from './store-domain-composition' @@ -61,6 +62,13 @@ export function beginProfileStateMaintenance( runtime.writeTimer = null } const resumePreference = runtime.activeViewPreference.pauseForMaintenance() + const resumeScheduling = () => { + runtime.writesFrozen = false + runtime.profileMaintenancePending = false + runtime.pendingProfileMaintenance = null + resumePreference() + scheduleSave(domains.scheduling) + } const paused = pauseProfileState(runtime, domains, options).then((authority) => { let consumed = false return { @@ -74,17 +82,50 @@ export function beginProfileStateMaintenance( await runtime.profileStateAuthority?.close?.() throw new Error('Profile persistence finalized during maintenance admission') } - runtime.writesFrozen = false - runtime.profileMaintenancePending = false - runtime.pendingProfileMaintenance = null - resumePreference() - scheduleSave(domains.scheduling) + resumeScheduling() } } }) - runtime.pendingProfileMaintenance = paused.then(() => {}) + const recoverable = paused.catch(async (error: unknown) => { + if (await canResumeFailedMaintenance(runtime, options, error)) { + resumeScheduling() + } else { + runtime.writesFrozen = true + await runtime.profileStateAuthority?.close?.() + } + throw error + }) + runtime.pendingProfileMaintenance = recoverable.then(() => {}) void runtime.pendingProfileMaintenance.catch(() => {}) - return paused + return recoverable +} + +async function canResumeFailedMaintenance( + runtime: StoreRuntimeState, + options: ProfileStateMaintenanceOptions, + error: unknown +): Promise { + try { + await drainProfileFileWork(runtime) + if ( + options.flush === false || + runtime.writesFrozen || + runtime.quitFlushStarted || + profileStateWriterFailureOutcome(error) === 'indeterminate' + ) { + return false + } + const authority = runtime.profileStateAuthority + if (authority?.asynchronous) { + authority.assertWritable() + } + await (authority?.pauseForMaintenance + ? (await authority.pauseForMaintenance()).resume() + : authority?.assertCurrentRevision?.()) + return !runtime.quitFlushStarted + } catch { + return false + } } async function pauseProfileState( @@ -93,42 +134,33 @@ async function pauseProfileState( { signal, flush = true }: ProfileStateMaintenanceOptions ): Promise { const authority = runtime.profileStateAuthority - try { - if (signal?.aborted) { - throw new Error('Profile maintenance aborted') - } - await drainProfileStateOperations(runtime.pendingProfileFlushes) - await (flush - ? flushCurrentStateAsync(domains.flushBarriers, false, signal, true, true, true) - : drainProfileFileWork(runtime)) - runtime.writesFrozen = true - if (!flush) { - await authority?.drainBackups?.() - await authority?.close?.() - return { - resume: async () => { - throw new Error('Recovery maintenance requires reloading the profile') - } - } - } - if (authority?.pauseForMaintenance) { - return await authority.pauseForMaintenance() - } - await authority?.drainBackups?.() + signal?.throwIfAborted() + await drainProfileFileWork(runtime) + signal?.throwIfAborted() + if (flush) { + // Cancel between commands so a dispatched commit retains a known outcome. + await flushCurrentStateAsync(domains.flushBarriers, false, undefined, true, true, true) + signal?.throwIfAborted() + await authority?.writeJsonCompatibilityExportAsync?.(runtime.dataFile) + } + signal?.throwIfAborted() + runtime.writesFrozen = true + if (!flush) { await authority?.close?.() - if (authority) { - throw new Error('Profile authority cannot safely resume from maintenance') - } - return await pauseJsonProfile(runtime.dataFile) - } catch (error) { - try { - await drainProfileFileWork(runtime) - } finally { - runtime.writesFrozen = true - await authority?.close?.() + return { + resume: async () => { + throw new Error('Recovery maintenance requires reloading the profile') + } } - throw error } + if (authority?.pauseForMaintenance) { + return authority.pauseForMaintenance() + } + await authority?.close?.() + if (authority) { + throw new Error('Profile authority cannot safely resume from maintenance') + } + return pauseJsonProfile(runtime.dataFile) } async function drainProfileFileWork(runtime: StoreRuntimeState): Promise { @@ -137,7 +169,10 @@ async function drainProfileFileWork(runtime: StoreRuntimeState): Promise { runtime.pendingWrite, runtime.pendingSnapshotFileWork, runtime.pendingGithubCacheWrite, - runtime.activeViewPreference.waitForPendingWrite() + runtime.activeViewPreference.waitForPendingWrite(), + runtime.profileStateAuthority?.drainBackups?.( + runtime.profileMaintenancePending || runtime.quitFlushStarted + ) ]) } diff --git a/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts index 66af237ccdf2..3fe645629977 100644 --- a/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts +++ b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts @@ -141,8 +141,9 @@ describe.each([ }) describe.each(['mutate', 'rollback'] as const)('admitted %s scope', (phase) => { - it('closes after a throwing callback and refuses new snapshots during finalization', async () => { - const { store, authority } = await createWorkerMaintenanceFixture() + it('preserves durable state and refuses later saves after a callback partially mutates then throws', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const original = readState() vi.spyOn(console, 'error').mockImplementation(() => {}) const failure = new Error('callback failed') if (phase === 'rollback') { @@ -150,10 +151,10 @@ describe.each(['mutate', 'rollback'] as const)('admitted %s scope', (phase) => { } await expect( store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) if (phase === 'mutate') { throw failure } - store.updateSettings({ theme: 'dark' }) return { value: undefined, rollback: () => { @@ -162,24 +163,37 @@ describe.each(['mutate', 'rollback'] as const)('admitted %s scope', (phase) => { } }) ).rejects.toBe(failure) - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const write = authority.writeCompleteSerializedDomains.bind(authority) - vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce( - async (domains) => { - started.resolve() - await release.promise - await write(domains) - } + assertNewSnapshotsRefused(store) + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' ) + await expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) + expect(readState()).toEqual(original) + }) +}) + +it('rejects an already admitted final flush after a queued callback partially mutates then throws', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + store.updateSettings({ theme: 'dark' }) + const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await started.promise + const failure = new Error('partial edit failed') + const mutation = store.runDurableMutation(() => { store.updateSettings({ theme: 'light' }) - const stopping = store.flushFinalOrThrowAsync() - try { - await started.promise - assertNewSnapshotsRefused(store) - } finally { - release.resolve() - await stopping - } + throw failure }) + const rejectedMutation = expect(mutation).rejects.toBe(failure) + const rejectedFinal = expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) + release.resolve() + await Promise.all([previous, rejectedMutation, rejectedFinal]) + expect(readState().settings.theme).toBe('dark') }) diff --git a/src/main/persistence/loading-store/profile-state-store-backups.test.ts b/src/main/persistence/loading-store/profile-state-store-backups.test.ts index c7ed2f7d07fa..185b449c5920 100644 --- a/src/main/persistence/loading-store/profile-state-store-backups.test.ts +++ b/src/main/persistence/loading-store/profile-state-store-backups.test.ts @@ -161,7 +161,7 @@ describe('Store automatic SQLite recovery snapshots', () => { let settled = false const barrier = ( kind === 'quit' - ? state.store.flushAsync() + ? state.store.flushAsync({ exportJsonCompatibility: true }) : flushActiveProfileBeforeFileMutation(state.store) ).then(() => { settled = true @@ -178,7 +178,9 @@ describe('Store automatic SQLite recovery snapshots', () => { expect(backups).toHaveLength(2) expect(readSnapshot(backups[0].path).state.settings.theme).toBe('dark') expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) - expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) + expect(JSON.parse(readFileSync(state.dataFile, 'utf8'))).toEqual( + readSnapshot(state.databasePath).state + ) } ) diff --git a/src/main/persistence/loading-store/profile-state-update-quit.test.ts b/src/main/persistence/loading-store/profile-state-update-quit.test.ts index 2e7f07138d6f..ec2506c32b9f 100644 --- a/src/main/persistence/loading-store/profile-state-update-quit.test.ts +++ b/src/main/persistence/loading-store/profile-state-update-quit.test.ts @@ -113,15 +113,22 @@ describe('SQLite profile state during an update quit', () => { } }) - it('keeps ordinary quits free of compatibility JSON writes', async () => { + it('exports a normal quit for an older build after sessions and settings changed', async () => { const { store, dataFile, databasePath } = await fixture() - const retainedJson = readFileSync(dataFile, 'utf8') + store.updateSettings({ theme: 'dark' }) store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') - await store.flushAsync() + await store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) - expect(readFileSync(dataFile, 'utf8')).toBe(retainedJson) - expect(persistedState(databasePath).json).not.toBe(retainedJson) + const json = readFileSync(dataFile, 'utf8') + expect(json).toBe(persistedState(databasePath).json) + const legacy = new Store({ dataFile }) + try { + expect(legacy.getSettings().theme).toBe('dark') + expect(legacy.getSshRemotePtyLeases(TARGET_ID)[0]?.state).toBe('detached') + } finally { + legacy.freezeWrites() + } }) it('does not publish or accept a snapshot when final persistence fails', async () => { diff --git a/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts index 3af43a808f85..1e17948cd6aa 100644 --- a/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts +++ b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts @@ -11,6 +11,57 @@ vi.mock('../../ssh/ssh-config-parser', () => ({ })) describe('worker-owned Store writes', () => { + it('consumes the debounce timer and avoids full checkpoints after a selective durable write', async () => { + const { store, authority, readState } = await fixture() + const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const selective = vi.spyOn(authority, 'writeSerializedDomains') + vi.useFakeTimers() + try { + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await vi.advanceTimersByTimeAsync(6_000) + await store.waitForPendingWrite() + expect(selective).toHaveBeenCalledOnce() + expect(full).not.toHaveBeenCalled() + expect(readState().settings.theme).toBe('dark') + } finally { + vi.useRealTimers() + } + }) + + it('still captures direct durable mutations that do not identify dirty domains', async () => { + const { store, readState } = await fixture() + await store.runDurableMutation(() => { + store.getWorkspaceSession().activeTabId = 'direct-mutation' + return { value: undefined } + }) + expect(readState().workspaceSession.activeTabId).toBe('direct-mutation') + }) + + it('skips a debounce callback already queued behind the write that consumed its changes', async () => { + const { store, authority } = await fixture() + const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const gate = authority.pause() + vi.useFakeTimers() + try { + const durable = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + await vi.advanceTimersByTimeAsync(1_000) + gate.finish.resolve() + await durable + await store.waitForPendingWrite() + expect(full).not.toHaveBeenCalled() + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + it('handles a rejected debounced save and retains it for an explicit retry', async () => { const { store, authority, readState } = await fixture() const log = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index ab9683e39e1f..ade6bccdbea1 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -52,6 +52,7 @@ export class StoreRuntimeState { inFlightAsyncTmpFile: string | null = null backupRotationInFlight = false writesFrozen = false + fatalMutationError: Error | null = null durableMutationPhase: 'mutate' | 'rollback' | null = null profileMaintenancePending = false pendingProfileMaintenance: Promise | null = null diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 14588aa4a408..2ebf42ff63a0 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -211,7 +211,7 @@ export class Store { return writeVersionedProfileStateExport(this.runtime.dataFile, writeExport) } - /** Publish canonical JSON for a pre-update older-build compatibility window. */ + /** Publish recovery and canonical JSON checkpoints for older builds. */ writeLatestProfileStateJsonCompatibilityExport(): number | undefined { const authority = this.runtime.profileStateAuthority if (authority?.asynchronous) { diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts index bf1b2c8a1674..f066f47188a0 100644 --- a/src/main/persistence/loading-store/write-flush-barriers.ts +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -89,9 +89,10 @@ export class WriteFlushBarrierOperations { runtime.quitFlushPromise = ( maintenance ? Promise.resolve(runtime.pendingProfileMaintenance) - : drainProfileStateOperations(runtime.pendingProfileFlushes).then(() => - flushCurrentStateAsync(this, true) - ) + : drainProfileStateOperations([ + ...runtime.pendingProfileFlushes, + runtime.profileStateAuthority?.drainBackups?.(true) + ]).then(() => flushCurrentStateAsync(this, true)) ) .then(async () => { if (options.exportJsonCompatibility && !maintenance) { @@ -190,7 +191,9 @@ export async function flushCurrentStateAsync( : runtime.activeViewPreference.flushPendingAsync(signal)) await writeGithubCacheSnapshotAsync(owner, final, signal) if (final || drainToStableGeneration) { - await runtime.profileStateAuthority?.drainBackups?.() + await runtime.profileStateAuthority?.drainBackups?.( + final || runtime.profileMaintenancePending + ) } } if (signal?.aborted) { diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts index 820334dfd7e9..30019c5c4562 100644 --- a/src/main/persistence/loading-store/write-scheduling.ts +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -53,11 +53,11 @@ export function scheduleSave( } } // A timer admitted after the final snapshot could outlive the awaited shutdown work. - if (runtime.quitFlushStarted) { + if (runtime.quitFlushStarted || runtime.profileMaintenancePending) { return } runtime.writeGeneration += 1 - if (runtime.writesFrozen || runtime.profileMaintenancePending) { + if (runtime.writesFrozen) { return } const now = Date.now() @@ -70,7 +70,7 @@ export function scheduleSave( runtime.writeTimer = setTimeout(() => { runtime.writeTimer = null runtime.firstPendingSaveAt = null - void enqueueWrite(writes).catch(() => {}) + void enqueueWrite(writes, { skipIfClean: true }).catch(() => {}) }, delay) } diff --git a/src/main/persistence/profile-state/profile-state-access-identity.ts b/src/main/persistence/profile-state/profile-state-access-identity.ts new file mode 100644 index 000000000000..f7d8d898d804 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-identity.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'node:fs' +import { runProcessSync } from '../../../shared/child-process/run-process' +import { + getProcessStartedAtMs, + parseLinuxProcStartTicks +} from '../../daemon/daemon-process-start-time' + +let bootIdentity: string | null | undefined +let ownProcessIdentity: string | null | undefined + +/** A kernel boot UUID survives hostname changes without conflating machines sharing a profile. */ +export function profileStateAccessBootIdentity(): string | null { + if (bootIdentity !== undefined) { + return bootIdentity + } + bootIdentity = null + try { + if (process.platform === 'linux') { + bootIdentity = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() || null + } else if (process.platform === 'darwin') { + const result = runProcessSync({ + program: '/usr/sbin/sysctl', + args: ['-n', 'kern.bootsessionuuid'], + timeoutMs: 1_000, + maxOutputBytes: 1024 + }) + bootIdentity = result.code === 0 ? result.stdout.trim() || null : null + } + } catch { + // Unavailable identity leaves the conservative hostname and PID checks in force. + } + return bootIdentity +} + +export function profileStateAccessProcessIdentity(pid: number): string | null { + if (pid !== process.pid) { + return readProcessIdentity(pid) + } + if (ownProcessIdentity === undefined) { + ownProcessIdentity = readProcessIdentity(pid) + } + return ownProcessIdentity +} + +function readProcessIdentity(pid: number): string | null { + if (process.platform === 'linux') { + try { + const ticks = parseLinuxProcStartTicks(readFileSync(`/proc/${pid}/stat`, 'utf8')) + return Number.isSafeInteger(ticks) && ticks >= 0 ? `linux-start-ticks:${ticks}` : null + } catch { + return null + } + } + const startedAtMs = getProcessStartedAtMs(pid) + return startedAtMs === null ? null : `wall-time-ms:${startedAtMs}` +} diff --git a/src/main/persistence/profile-state/profile-state-access-owner.ts b/src/main/persistence/profile-state/profile-state-access-owner.ts index 6c9952001a95..94f30ea43523 100644 --- a/src/main/persistence/profile-state/profile-state-access-owner.ts +++ b/src/main/persistence/profile-state/profile-state-access-owner.ts @@ -13,6 +13,10 @@ import { } from 'node:fs' import { hostname } from 'node:os' import { join } from 'node:path' +import { + profileStateAccessBootIdentity, + profileStateAccessProcessIdentity +} from './profile-state-access-identity' export class ProfileStateAccessError extends Error { readonly code = 'profile-state-access-refused' as const @@ -48,9 +52,11 @@ type AccessOwner = { host: string platform: string pidNamespace: string | null + bootIdentity?: string | null + processStartIdentity?: string | null } -function currentPidNamespace(): string | null { +export function profileStateAccessPidNamespace(): string | null { if (process.platform !== 'linux') { return null } @@ -90,7 +96,18 @@ function readOwner(path: string): AccessOwner | undefined { pid: owner.pid, host: owner.host, platform: owner.platform, - pidNamespace: owner.pidNamespace + pidNamespace: owner.pidNamespace, + bootIdentity: + 'bootIdentity' in owner && typeof owner.bootIdentity === 'string' + ? owner.bootIdentity + : null, + processStartIdentity: + 'processStartIdentity' in owner && + typeof owner.processStartIdentity === 'string' && + /^(?:linux-start-ticks|wall-time-ms):\d+$/.test(owner.processStartIdentity) && + Number.isSafeInteger(Number(owner.processStartIdentity.split(':')[1])) + ? owner.processStartIdentity + : null } } } catch (error) { @@ -103,22 +120,33 @@ function readOwner(path: string): AccessOwner | undefined { } function ownerExited(owner: AccessOwner): boolean { - if (owner.host !== hostname() || owner.platform !== process.platform) { + const sameBoot = Boolean( + owner.bootIdentity && owner.bootIdentity === profileStateAccessBootIdentity() + ) + if ((!sameBoot && owner.host !== hostname()) || owner.platform !== process.platform) { return false } // Windows/WSL and Linux PID namespaces cannot establish each other's process absence. if ( process.platform === 'linux' && - (owner.pidNamespace === null || owner.pidNamespace !== currentPidNamespace()) + (owner.pidNamespace === null || owner.pidNamespace !== profileStateAccessPidNamespace()) ) { return false } try { process.kill(owner.pid, 0) - return false } catch (error) { return hasCode(error, 'ESRCH') } + const recordedStart = owner.processStartIdentity + const actualStart = + !sameBoot || recordedStart == null ? null : profileStateAccessProcessIdentity(owner.pid) + return ( + actualStart !== null && + recordedStart != null && + actualStart.split(':')[0] === recordedStart.split(':')[0] && + actualStart !== recordedStart + ) } export function hasCode(error: unknown, code: string): boolean { @@ -170,7 +198,7 @@ export function reclaimExitedOwner(path: string): void { } if (owner.token !== token || !ownerExited(owner)) { throw new ProfileStateAccessError( - `Profile state is in use or its owner is unverifiable: ${path}` + `Profile state is in use or its owner is unverifiable: ${path}. Stop Orca and orcad on every host using this profile, then retry. If this remains, verify PID ${owner.pid} on ${owner.host} has exited before removing its owner entry ${join(path, entry)}.` ) } removeOwnerEntry(join(path, entry)) @@ -194,7 +222,9 @@ export function publishAccessOwner(paths: ProfileStateAccessPaths, exclusive: bo pid: process.pid, host: hostname(), platform: process.platform, - pidNamespace: currentPidNamespace() + pidNamespace: profileStateAccessPidNamespace(), + bootIdentity: profileStateAccessBootIdentity(), + processStartIdentity: profileStateAccessProcessIdentity(process.pid) }), { flag: 'wx', diff --git a/src/main/persistence/profile-state/profile-state-access.test.ts b/src/main/persistence/profile-state/profile-state-access.test.ts index 1b1fee209f4b..ff4f76bcc817 100644 --- a/src/main/persistence/profile-state/profile-state-access.test.ts +++ b/src/main/persistence/profile-state/profile-state-access.test.ts @@ -10,8 +10,16 @@ import { type ProfileStateMaintenance } from './profile-state-access' import { profileStateAccessPaths } from './profile-state-access-owner' +import * as identity from './profile-state-access-identity' +import * as processStart from '../../daemon/daemon-process-start-time' vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()) })) +vi.mock('../../daemon/daemon-process-start-time', async (importOriginal) => ({ + ...(await importOriginal()) +})) +vi.mock('./profile-state-access-identity', async (importOriginal) => ({ + ...(await importOriginal()) +})) const roots: string[] = [] function root(): string { @@ -130,7 +138,12 @@ describe('profile state admission and maintenance', () => { ) }) -function staleGate(path: string, pid = 12345, host = hostname()): string { +function staleGate( + path: string, + pid = 12345, + host = hostname(), + extra: { bootIdentity?: string; startedAtMs?: number; processStartIdentity?: string } = {} +): string { const gate = profileStateAccessPaths(path).maintenance fs.mkdirSync(gate) const token = randomUUID() @@ -142,13 +155,136 @@ function staleGate(path: string, pid = 12345, host = hostname()): string { pid, host, platform: process.platform, - pidNamespace: process.platform === 'linux' ? fs.readlinkSync('/proc/self/ns/pid') : null + pidNamespace: process.platform === 'linux' ? fs.readlinkSync('/proc/self/ns/pid') : null, + ...extra }) ) return record } describe('profile state owner reclamation', () => { + it('reclaims a reused PID only when its recorded process start differs on the same boot', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue( + 'linux-start-ticks:2000' + ) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity: 'linux-start-ticks:1000' + }) + acquireProfileStateMaintenance(path).release() + expect(fs.existsSync(owner)).toBe(false) + }) + + it('cannot reclaim a live Linux owner when wall-clock time changes', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + if (!platform) { + throw new Error('Missing platform descriptor') + } + const path = root() + const read = fs.readFileSync + const fields = Array.from({ length: 20 }, () => '0') + fields[0] = 'S' + fields[19] = '987654' + let clock = 1_700_000_000_000 + const wallStart = vi + .spyOn(processStart, 'getProcessStartedAtMs') + .mockImplementation(() => clock) + vi.spyOn(fs, 'readFileSync').mockImplementation((file, options) => { + if (file === '/proc/12345/stat') { + return `12345 (orca daemon) ${fields.join(' ')}` + } + return read(file, options) + }) + vi.spyOn(fs, 'readlinkSync').mockReturnValue('pid:[same-namespace]') + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(process, 'kill').mockReturnValue(true) + try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const processStartIdentity = identity.profileStateAccessProcessIdentity(12345) + expect(processStartIdentity).toBe('linux-start-ticks:987654') + if (processStartIdentity === null) { + throw new Error('Expected process identity') + } + const owner = staleGate(path, 12345, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity + }) + clock += 60_000 + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + expect(wallStart).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', platform) + } + }) + + it('does not compare legacy epoch timestamps with raw process identity', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue( + 'linux-start-ticks:2000' + ) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + startedAtMs: 1000 + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + }) + + it.each([ + 'wall-time-ms:1000', + 'linux-start-ticks:invalid', + 'linux-start-ticks:99999999999999999' + ])('does not compare incompatible or malformed identity %s', (recorded) => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue( + 'linux-start-ticks:2000' + ) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity: recorded + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('does not interpret an unavailable process start as proof of PID reuse', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue(null) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity: 'linux-start-ticks:1000' + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('recognizes an exited owner after a hostname change on the same kernel boot', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + const owner = staleGate(path, 12345, 'previous-hostname', { bootIdentity: 'same-boot' }) + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('exited'), { code: 'ESRCH' }) + }) + acquireProfileStateMaintenance(path).release() + expect(fs.existsSync(owner)).toBe(false) + }) + + it('keeps a differently booted host unverifiable after a hostname change', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('different-boot') + const owner = staleGate(path, 12345, 'previous-hostname', { bootIdentity: 'owner-boot' }) + const kill = vi.spyOn(process, 'kill') + expect(() => acquireProfileStateMaintenance(path)).toThrow('verify PID 12345') + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(true) + }) + it('does not infer exit from a PID in another platform on a shared root', () => { const path = root() const owner = staleGate(path) diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts index 59d57d6eb6c3..48b46b1505ac 100644 --- a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts @@ -382,6 +382,42 @@ describe('profile state authority bootstrap', () => { expect(existsSync(options.databaseFile)).toBe(false) }) + it.each([0, 1, 2, 3, 4])( + 'migrates usable legacy backup slot %s after a corrupt primary', + (slot) => { + const options = paths(createDirectory()) + const damaged = '{ malformed primary' + const recovered = JSON.stringify({ settings: { theme: 'dark' }, retainedBackup: slot }) + writeFileSync(options.dataFile, damaged) + for (let index = 0; index < slot; index += 1) { + writeFileSync(`${options.dataFile}.bak.${index}`, '{ damaged backup') + } + writeFileSync(`${options.dataFile}.bak.${slot}`, recovered) + + const result = bootstrapProfileStateAuthority(options) + expect(result.migrated).toBe(true) + expect(JSON.parse(result.authority?.readSerializedState() ?? '{}')).toMatchObject({ + settings: { theme: 'dark' }, + retainedBackup: slot + }) + expect(readFileSync(options.dataFile, 'utf8')).toBe(damaged) + expect(readFileSync(`${options.dataFile}.bak.${slot}`, 'utf8')).toBe(recovered) + expect(bootstrapProfileStateAuthority(options).migrated).toBe(false) + } + ) + + it('preserves every source when legacy primary and backups are unusable', () => { + const options = paths(createDirectory()) + writeFileSync(options.dataFile, '{ malformed primary') + writeFileSync(`${options.dataFile}.bak.0`, '{ malformed backup') + expect(() => bootstrapProfileStateAuthority(options)).toThrow( + ProfileStateAuthorityBootstrapError + ) + expect(readFileSync(options.dataFile, 'utf8')).toBe('{ malformed primary') + expect(readFileSync(`${options.dataFile}.bak.0`, 'utf8')).toBe('{ malformed backup') + expect(existsSync(options.databaseFile)).toBe(false) + }) + it('cleans a temporary database when import fails after opening SQLite', () => { const directory = createDirectory() const options = paths(directory) diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts index bcafeab6366e..23fddbba2b08 100644 --- a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts @@ -10,6 +10,10 @@ import { Store } from '../loading-store/store' import { isProfileStateSqliteAvailable, openProfileStateDatabase } from './profile-state-database' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' import { migrateProfileStateToSqlite } from './profile-state-migration' +import { + PROFILE_STATE_LEGACY_BACKUP_COUNT, + profileStateLegacyBackupPath +} from './profile-state-legacy-backup-path' import { assertProfileStateCanInitialize, ProfileStateAuthorityBootstrapError, @@ -134,15 +138,7 @@ function migrateJsonOnlyProfile( options: ProfileStateAuthorityBootstrapOptions ): ProfileStateAuthorityBootstrapResult { const rawJson = readFileSync(options.dataFile, 'utf8') - // serializedState makes malformed input fail closed and prevents backup - // recovery or a normalization write from changing the legacy source. - const unboundPaneAliases: ProfileStateStartupPaneAlias[] = [] - const store = new Store({ - dataFile: options.dataFile, - serializedState: rawJson, - collectUnboundPaneAlias: (entry) => unboundPaneAliases.push(entry) - }) - const prepared = store.prepareProfileStateExport() + const { prepared, unboundPaneAliases } = prepareLegacyProfileState(options.dataFile, rawJson) const migrated = migrateProfileStateToSqlite({ ...options, expectedLegacyJson: rawJson, @@ -156,3 +152,37 @@ function migrateJsonOnlyProfile( migrated: true } } + +function prepareLegacyProfileState(dataFile: string, rawJson: string) { + try { + return prepareLegacySnapshot(dataFile, rawJson) + } catch (error) { + // Import the first usable legacy backup without overwriting the damaged source. + for (let index = 0; index < PROFILE_STATE_LEGACY_BACKUP_COUNT; index += 1) { + const path = profileStateLegacyBackupPath(dataFile, index) + if (!existsSync(path)) { + continue + } + try { + const prepared = prepareLegacySnapshot(dataFile, readFileSync(path, 'utf8')) + console.warn(`[profile-state] Recovered legacy state from ${path}`) + return prepared + } catch { + // A corrupt backup must not prevent trying the remaining legacy ring. + } + } + throw new ProfileStateAuthorityBootstrapError( + `Failed to load imported profile state or its legacy backups: ${dataFile}. ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +function prepareLegacySnapshot(dataFile: string, serializedState: string) { + const unboundPaneAliases: ProfileStateStartupPaneAlias[] = [] + const store = new Store({ + dataFile, + serializedState, + collectUnboundPaneAlias: (entry) => unboundPaneAliases.push(entry) + }) + return { prepared: store.prepareProfileStateExport(), unboundPaneAliases } +} diff --git a/src/main/persistence/profile-state/profile-state-authority-exports.ts b/src/main/persistence/profile-state/profile-state-authority-exports.ts index 5b40486da691..2db7d2a550f0 100644 --- a/src/main/persistence/profile-state/profile-state-authority-exports.ts +++ b/src/main/persistence/profile-state/profile-state-authority-exports.ts @@ -12,6 +12,7 @@ import { acceptProfileStateJsonCompatibility } from './profile-state-documents' import type Database from '../../sqlite/sync-database' +import { writeVersionedProfileStateExport } from './profile-state-versioned-export' import { ProfileStateRevisionConflictError } from './profile-state-document-validation' function readExportSnapshot(db: Database.Database, expectedRevision?: number) { @@ -44,6 +45,7 @@ export function writeProfileStateAuthorityCompatibilityExport( if (snapshot.revision === 0) { return undefined } + writeCompatibilityRecoveryExport(targetPath, snapshot) const retained = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : undefined stageProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision, retained) mkdirSync(dirname(targetPath), { recursive: true }) @@ -61,6 +63,7 @@ export async function writeProfileStateAuthorityCompatibilityExportAsync( if (snapshot.revision === 0) { return undefined } + writeCompatibilityRecoveryExport(targetPath, snapshot) const retained = await readFile(targetPath, 'utf8').catch((error: unknown) => { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return undefined @@ -73,3 +76,14 @@ export async function writeProfileStateAuthorityCompatibilityExportAsync( acceptProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision) return snapshot.revision } + +function writeCompatibilityRecoveryExport( + dataFile: string, + snapshot: { json: string; revision: number } +): void { + writeVersionedProfileStateExport(dataFile, (path) => { + mkdirSync(dirname(path), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(path), path, snapshot.json) + return snapshot.revision + }) +} diff --git a/src/main/persistence/profile-state/profile-state-backup-job.ts b/src/main/persistence/profile-state/profile-state-backup-job.ts index ef6e068aa7db..f10ef15368c8 100644 --- a/src/main/persistence/profile-state/profile-state-backup-job.ts +++ b/src/main/persistence/profile-state/profile-state-backup-job.ts @@ -6,6 +6,7 @@ export type ProfileStateBackupJob = { databasePath: string profileId: string targetPath: string + temporaryPath?: string } /** Own every connection until the copy and its strict validation finish. */ @@ -13,6 +14,7 @@ export async function writeProfileStateBackup(job: ProfileStateBackupJob): Promi const opened = openProfileStateDatabaseReadOnly(job.databasePath, job.profileId) try { await writeProfileStateDatabaseSnapshotAsync(opened.db, job.targetPath, { + temporaryPath: job.temporaryPath, validateStagedSnapshot: (stagingPath) => validateProfileStateBackup(stagingPath, job.profileId) }) diff --git a/src/main/persistence/profile-state/profile-state-backup-rotation.ts b/src/main/persistence/profile-state/profile-state-backup-rotation.ts index 3e7449cf9f89..a9b301f044e2 100644 --- a/src/main/persistence/profile-state/profile-state-backup-rotation.ts +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.ts @@ -5,6 +5,7 @@ import { profileStateDatabaseBackups } from './profile-state-backup-path' import { runProfileStateBackup } from './profile-state-backup-worker' +import { removeAbandonedProfileStateBackupFiles } from './profile-state-backup-temporary-files' const BACKUP_COUNT = 5 const BACKUP_INTERVAL_MS = 60 * 60 * 1000 @@ -14,6 +15,7 @@ const BACKUP_RETRY_MS = 60 * 1000 export class ProfileStateBackupRotation { private pending: Promise | undefined private stopped = false + private readonly cancellation = new AbortController() private nextAttemptAt = 0 constructor( @@ -31,7 +33,7 @@ export class ProfileStateBackupRotation { .then(() => this.rotate()) .catch((error: unknown) => { this.nextAttemptAt = this.now() + BACKUP_RETRY_MS - if (this.stopped && isMissingPath(error)) { + if (this.stopped && (this.cancellation.signal.aborted || isMissingPath(error))) { return } console.error('[persistence] Failed to back up profile state database:', error) @@ -50,6 +52,7 @@ export class ProfileStateBackupRotation { stop(): void { this.stopped = true + this.cancellation.abort() } assertIdle(): void { @@ -63,6 +66,7 @@ export class ProfileStateBackupRotation { return } const now = this.now() + await removeAbandonedProfileStateBackupFiles(this.databasePath, now) const latest = (await this.regularBackups())[0] if (this.stopped) { return @@ -75,11 +79,14 @@ export class ProfileStateBackupRotation { this.databasePath, createProfileStateDatabaseBackupId(now) ) - await this.runBackup({ - databasePath: this.databasePath, - profileId: this.profileId, - targetPath: target - }) + await this.runBackup( + { + databasePath: this.databasePath, + profileId: this.profileId, + targetPath: target + }, + this.cancellation.signal + ) this.nextAttemptAt = this.now() + BACKUP_INTERVAL_MS for (const backup of (await this.regularBackups()).slice(BACKUP_COUNT)) { await rm(backup.path, { force: true }) diff --git a/src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts b/src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts new file mode 100644 index 000000000000..a37de695fa5b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts @@ -0,0 +1,91 @@ +import { mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as identity from './profile-state-access-identity' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { + profileStateBackupTemporaryPath, + removeAbandonedProfileStateBackupFiles +} from './profile-state-backup-temporary-files' + +vi.mock('./profile-state-access-identity', () => ({ + profileStateAccessBootIdentity: () => 'test-boot' +})) +vi.mock('./profile-state-access-owner', () => ({ + profileStateAccessPidNamespace: () => 'test-namespace' +})) + +const roots: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('abandoned profile database backups', () => { + it('does not infer ownership when kernel identity is unavailable', async () => { + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue(null) + const probe = vi.spyOn(process, 'kill') + await removeAbandonedProfileStateBackupFiles('/missing/profile-state.db', Date.now()) + expect(probe).not.toHaveBeenCalled() + expect(profileStateBackupTemporaryPath('/profile/backup.db')).not.toContain('.owner-') + }) + + it('removes only old temporary files whose owner is proven exited', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-backup-orphans-')) + roots.push(root) + const databasePath = join(root, 'profile-state.db') + const backup = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + const now = Date.now() + const old = new Date(now - 2 * 60 * 60_000) + const temporary = profileStateBackupTemporaryPath(backup) + const orphan = temporary.replace(`.${process.pid}.`, '.12345.') + const live = temporary.replace(`.${process.pid}.`, '.12346.') + const unknown = temporary.replace(`.${process.pid}.`, '.12347.') + const recent = profileStateBackupTemporaryPath(backup).replace(`.${process.pid}.`, '.12345.') + const remote = orphan.replace(/owner-[a-f0-9]{64}/, `owner-${'0'.repeat(64)}`) + const legacy = `${backup}.12345.${now}.ab12.tmp` + const unrelated = `${databasePath}.unrelated.tmp` + for (const path of [ + backup, + orphan, + `${orphan}-wal`, + `${orphan}-shm`, + `${orphan}-journal`, + live, + unknown, + recent, + remote, + legacy, + unrelated + ]) { + writeFileSync(path, 'retained') + if (path !== recent) { + utimesSync(path, old, old) + } + } + vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === 12345) { + throw Object.assign(new Error('exited'), { code: 'ESRCH' }) + } + if (pid === 12347) { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + } + return true + }) + await removeAbandonedProfileStateBackupFiles(databasePath, now) + expect(readdirSync(root).sort()).toEqual( + [backup, live, unknown, recent, remote, legacy, unrelated] + .map((path) => basename(path)) + .sort() + ) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-temporary-files.ts b/src/main/persistence/profile-state/profile-state-backup-temporary-files.ts new file mode 100644 index 000000000000..eb0424abbc29 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-temporary-files.ts @@ -0,0 +1,71 @@ +import { createHash } from 'node:crypto' +import { lstat, readdir, rm } from 'node:fs/promises' +import { durableWriteTempPath } from '../../durable-file-write' +import { profileStateAccessBootIdentity } from './profile-state-access-identity' +import { profileStateAccessPidNamespace } from './profile-state-access-owner' +import { basename, dirname, join } from 'node:path' + +const ORPHAN_AGE_MS = 60 * 60_000 +const BACKUP_TEMP_PATTERN = + /^([1-9]\d*)-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.db\.owner-([a-f0-9]{64})\.([1-9]\d*)\.\d+\.[0-9a-f]+\.tmp(?:-(?:wal|shm|journal))?$/ + +export function profileStateBackupTemporaryPath(targetPath: string): string { + const scope = currentProcessScope() + return durableWriteTempPath(scope ? `${targetPath}.owner-${scope}` : targetPath) +} + +function currentProcessScope(): string | undefined { + const boot = profileStateAccessBootIdentity() + const namespace = profileStateAccessPidNamespace() + if (!boot || (process.platform === 'linux' && namespace === null)) { + return undefined + } + return createHash('sha256') + .update(JSON.stringify([process.platform, boot, namespace])) + .digest('hex') +} + +/** A dead process proves the temporary database and its journals cannot still be in use. */ +export async function removeAbandonedProfileStateBackupFiles( + databasePath: string, + now: number +): Promise { + const scope = currentProcessScope() + if (!scope) { + return + } + const directory = dirname(databasePath) + const prefix = `${basename(databasePath)}.backup.` + for (const name of await readdir(directory)) { + if (!name.startsWith(prefix)) { + continue + } + const match = BACKUP_TEMP_PATTERN.exec(name.slice(prefix.length)) + if (!match || match[2] !== scope || !ownerExited(Number(match[3]))) { + continue + } + const path = join(directory, name) + try { + const info = await lstat(path) + if (info.isFile() && now - info.mtimeMs >= ORPHAN_AGE_MS) { + await rm(path, { force: true }) + } + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { + throw error + } + } + } +} + +function ownerExited(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) { + return false + } + try { + process.kill(pid, 0) + return false + } catch (error) { + return error instanceof Error && 'code' in error && error.code === 'ESRCH' + } +} diff --git a/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts b/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts index cfcb8f75fdea..9213e4e00420 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts @@ -11,7 +11,8 @@ if ( !isRecord(request) || typeof request.databasePath !== 'string' || typeof request.profileId !== 'string' || - typeof request.targetPath !== 'string' + typeof request.targetPath !== 'string' || + (request.temporaryPath !== undefined && typeof request.temporaryPath !== 'string') ) { throw new Error('Invalid profile state backup request') } @@ -19,7 +20,8 @@ if ( void writeProfileStateBackup({ databasePath: request.databasePath, profileId: request.profileId, - targetPath: request.targetPath + targetPath: request.targetPath, + temporaryPath: request.temporaryPath }) .then( () => port.postMessage({ ok: true }), diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts index ad4f91477533..310c14714201 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -162,6 +162,39 @@ describe('profile state backup worker', () => { rmSync(directory, { recursive: true }) }) + it.each(['timeout', 'cancel'] as const)( + 'cleans a terminated %s worker only after exit', + async (mode) => { + const { directory, job } = fixture() + const ready = join(directory, 'ready') + const worker = script( + directory, + ` + const { workerData } = require('node:worker_threads') + const fs = require('node:fs') + for (const suffix of ['', '-wal', '-shm', '-journal']) fs.writeFileSync(workerData.temporaryPath + suffix, 'incomplete') + fs.writeFileSync(${JSON.stringify(ready)}, 'ready') + setInterval(() => {}, 1000) + ` + ) + const cancellation = new AbortController() + const pending = runProfileStateBackupWorker(job, { + workerPath: worker, + timeoutMs: 500, + signal: cancellation.signal + }) + const failed = expect(pending).rejects.toThrow(mode === 'cancel' ? 'cancelled' : 'timed out') + await vi.waitFor(() => expect(existsSync(ready)).toBe(true)) + expect(readdirSync(directory).filter((name) => name.startsWith('backup.db.'))).toHaveLength(4) + if (mode === 'cancel') { + cancellation.abort() + } + await failed + expect(readdirSync(directory).filter((name) => name.startsWith('backup.db.'))).toEqual([]) + expect(existsSync(job.databasePath)).toBe(true) + } + ) + it('reports a missing bundle and leaves the primary untouched', async () => { const { directory, job } = fixture() const before = readFileSync(job.databasePath) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.ts b/src/main/persistence/profile-state/profile-state-backup-worker.ts index 8593d7dd496b..287ca2ce34bb 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker.ts @@ -1,4 +1,7 @@ import { existsSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { profileStateBackupTemporaryPath } from './profile-state-backup-temporary-files' +import { profileStateDatabaseFiles } from './profile-state-storage-classification' import { dirname, join } from 'node:path' import { Worker } from 'node:worker_threads' import { @@ -18,47 +21,67 @@ export function resolveProfileStateBackupWorkerPath(moduleDir = __dirname): stri } /** Desktop validation runs off the UI thread; plain-Node backups retain the native async path. */ -export function runProfileStateBackup(job: ProfileStateBackupJob): Promise { - return process.versions.electron ? runProfileStateBackupWorker(job) : writeProfileStateBackup(job) +export function runProfileStateBackup( + job: ProfileStateBackupJob, + signal?: AbortSignal +): Promise { + return process.versions.electron + ? runProfileStateBackupWorker(job, { signal }) + : writeProfileStateBackup(job) } -export function runProfileStateBackupWorker( +export async function runProfileStateBackupWorker( job: ProfileStateBackupJob, - options: { workerPath?: string; timeoutMs?: number } = {} + options: { workerPath?: string; timeoutMs?: number; signal?: AbortSignal } = {} ): Promise { - return new Promise((resolve, reject) => { - const workerPath = options.workerPath ?? resolveProfileStateBackupWorkerPath() - const worker = new Worker(workerPath, { workerData: job, execArgv: [] }) - let completed = false - let failure: Error | undefined - const timer = setTimeout(() => { - failure = new Error('Profile state backup worker timed out') - void worker.terminate().catch((error: unknown) => { + options.signal?.throwIfAborted() + const temporaryPath = profileStateBackupTemporaryPath(job.targetPath) + try { + await new Promise((resolve, reject) => { + const workerPath = options.workerPath ?? resolveProfileStateBackupWorkerPath() + const worker = new Worker(workerPath, { workerData: { ...job, temporaryPath }, execArgv: [] }) + let completed = false + let failure: Error | undefined + const terminate = (reason: Error): void => { + failure ??= reason + void worker.terminate().catch((error: unknown) => { + failure = error instanceof Error ? error : new Error(String(error)) + }) + } + const abort = (): void => terminate(new Error('Profile state backup cancelled')) + options.signal?.addEventListener('abort', abort, { once: true }) + const timer = setTimeout( + () => terminate(new Error('Profile state backup worker timed out')), + options.timeoutMs ?? BACKUP_TIMEOUT_MS + ) + worker.on('message', (response: unknown) => { + if (!isRecord(response) || typeof response.ok !== 'boolean') { + failure = new Error('Invalid profile state backup worker response') + } else if (!response.ok) { + failure = new Error(String(response.error)) + } else { + completed = true + } + }) + worker.on('error', (error) => { failure = error instanceof Error ? error : new Error(String(error)) }) - }, options.timeoutMs ?? BACKUP_TIMEOUT_MS) - worker.on('message', (response: unknown) => { - if (!isRecord(response) || typeof response.ok !== 'boolean') { - failure = new Error('Invalid profile state backup worker response') - } else if (!response.ok) { - failure = new Error(String(response.error)) - } else { - completed = true - } - }) - worker.on('error', (error) => { - failure = error instanceof Error ? error : new Error(String(error)) - }) - // Even an error response leaves handles open until the worker actually exits. - worker.once('exit', (code) => { - clearTimeout(timer) - if (failure || code !== 0 || !completed) { - reject( - failure ?? new Error(`Profile state backup worker exited without completion (${code})`) - ) - } else { - resolve() - } + // Even an error response leaves handles open until the worker actually exits. + worker.once('exit', (code) => { + clearTimeout(timer) + options.signal?.removeEventListener('abort', abort) + if (failure || code !== 0 || !completed) { + reject( + failure ?? new Error(`Profile state backup worker exited without completion (${code})`) + ) + } else { + resolve() + } + }) }) - }) + } finally { + await Promise.all( + profileStateDatabaseFiles(temporaryPath).map((path) => rm(path, { force: true })) + ) + } } diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts index 77218e392caf..da0ebcf7ac57 100644 --- a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts @@ -86,6 +86,16 @@ describe('asynchronous profile-state database snapshots', () => { expectNoTemporaryFiles(directory) }) + it('never removes an existing staging file when exclusive creation fails', async () => { + const { db, databasePath, targetPath, originalJson } = fixture() + await expect( + writeProfileStateDatabaseSnapshotAsync(db, targetPath, { temporaryPath: databasePath }) + ).rejects.toThrow() + expect(existsSync(databasePath)).toBe(true) + expect(exportProfileStateJson(db)).toBe(originalJson) + expect(existsSync(targetPath)).toBe(false) + }) + it.each(['same connection', 'another connection'] as const)( 'keeps a consistent complete revision while writes occur from %s', async (connection) => { diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.ts index 67b00f894221..9cc89d58b59c 100644 --- a/src/main/persistence/profile-state/profile-state-database-snapshot.ts +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.ts @@ -9,7 +9,10 @@ import { durableWriteTempPath, renameDurable } from '../../durable-file-write' export async function writeProfileStateDatabaseSnapshotAsync( db: Database.Database, targetPath: string, - options: { validateStagedSnapshot?: (stagingPath: string) => Promise | void } = {} + options: { + temporaryPath?: string + validateStagedSnapshot?: (stagingPath: string) => Promise | void + } = {} ): Promise { if (targetPath.length === 0 || targetPath.includes('\0')) { throw new Error('Profile state snapshot path is invalid') @@ -20,11 +23,13 @@ export async function writeProfileStateDatabaseSnapshotAsync( await mkdir(dirname(targetPath), { recursive: true }) await assertSnapshotTargetIsSeparate(db, targetPath) await assertNoSnapshotSidecars(targetPath) - const temporaryPath = durableWriteTempPath(targetPath) + const temporaryPath = options.temporaryPath ?? durableWriteTempPath(targetPath) let published = false + let created = false try { // Pre-create privately: the native backup otherwise creates a world-readable temporary file. const temporary = await open(temporaryPath, 'wx', 0o600) + created = true await temporary.close() await db.backup(temporaryPath) // The native copy preserves WAL mode; snapshots must not create sidecars when opened read-only. @@ -48,12 +53,16 @@ export async function writeProfileStateDatabaseSnapshotAsync( await renameDurable(temporaryPath, targetPath) published = true } finally { - if (!published) { + if (created && !published) { await rm(temporaryPath, { force: true }) } - await Promise.all( - ['-wal', '-shm', '-journal'].map((suffix) => rm(`${temporaryPath}${suffix}`, { force: true })) - ) + if (created) { + await Promise.all( + ['-wal', '-shm', '-journal'].map((suffix) => + rm(`${temporaryPath}${suffix}`, { force: true }) + ) + ) + } } } diff --git a/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts b/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts index e72f08a145e7..bdef12726518 100644 --- a/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts +++ b/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts @@ -100,12 +100,19 @@ describe.each(['sync', 'async'] as const)('%s compatibility export recovery', (m async (phase) => { const state = fixture() if (phase === 'publication') { - vi.spyOn( - durableFiles, - mode === 'sync' ? 'writeFileDurableSync' : 'writeFileDurable' - ).mockImplementationOnce(() => { - throw new Error('injected publication failure') - }) + if (mode === 'sync') { + const write = durableFiles.writeFileDurableSync + vi.spyOn(durableFiles, 'writeFileDurableSync').mockImplementation((...args) => { + if (args[1] === state.paths.dataFile) { + throw new Error('injected publication failure') + } + write(...args) + }) + } else { + vi.spyOn(durableFiles, 'writeFileDurable').mockRejectedValueOnce( + new Error('injected publication failure') + ) + } } else { state.withDatabase((db) => db.exec( @@ -128,6 +135,7 @@ describe.each(['sync', 'async'] as const)('%s compatibility export recovery', (m acceptedRevision: 2 }) } + vi.restoreAllMocks() state.withDatabase((db) => db.exec('DROP TRIGGER IF EXISTS reject_acceptance')) state.authority.writeSerializedState(Buffer.from('{"settings":{"theme":"system"}}')) @@ -154,9 +162,11 @@ describe.each(['sync', 'async'] as const)('%s compatibility export recovery', (m } if (mode === 'sync') { const write = durableFiles.writeFileDurableSync - vi.spyOn(durableFiles, 'writeFileDurableSync').mockImplementationOnce((...args) => { + vi.spyOn(durableFiles, 'writeFileDurableSync').mockImplementation((...args) => { write(...args) - compete() + if (args[1] === state.paths.dataFile) { + compete() + } }) } else { const write = durableFiles.writeFileDurable diff --git a/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts index f2ee4d88d1e4..56c2cd2b0725 100644 --- a/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts @@ -8,8 +8,10 @@ import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixtur import type { Store } from '../loading-store/store' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' import { createLiveProfileStateStore } from './profile-state-live-store-factory' -import { profileStateJsonExportPath } from './profile-state-export-path' -import { profileStateDatabaseBackups } from './profile-state-backup-path' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './profile-state-export-path' vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ @@ -205,7 +207,7 @@ describe('live profile authority admission', () => { store.updateSettings({ theme: 'light' }) await store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) expect(JSON.parse(readFileSync(input.dataFile, 'utf8')).settings.theme).toBe('light') - expect(profileStateDatabaseBackups(input.databaseFile).length).toBeGreaterThan(0) + expect(profileStateJsonExportPaths(input.dataFile).length).toBeGreaterThan(0) expect(readState(input).settings.theme).toBe('light') await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') const reopened = await open(input) diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.test.ts b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts index 8e7adec98841..24361cd13b5f 100644 --- a/src/main/persistence/profile-state/profile-state-startup-failure.test.ts +++ b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts @@ -28,14 +28,17 @@ describe('profile-state startup failure formatting', () => { expect(profileStateStartupFailureClass(error)).toBe('recovery-required') }) - it('formats authority ambiguity without suggesting a destructive recovery', () => { + it('explains ambiguity and offers explicit inspection before choosing a recovery point', () => { const message = formatProfileStateStartupFailure( new ProfileStateAuthorityBootstrapError('both profile stores are present') ) - expect(message).toBe( + expect(message).toContain( 'Orca cannot safely choose a profile-state authority: both profile stores are present' ) + expect(message).toContain('neither is selected automatically') + expect(message).toContain('orca profile state exports') + expect(message).toContain('orca profile state rollback --backup ') expect( profileStateStartupFailureClass(new ProfileStateAuthorityBootstrapError('ambiguous')) ).toBe('ambiguous-authority') diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.ts b/src/main/persistence/profile-state/profile-state-startup-failure.ts index 47805310bad2..7189e25c4ee6 100644 --- a/src/main/persistence/profile-state/profile-state-startup-failure.ts +++ b/src/main/persistence/profile-state/profile-state-startup-failure.ts @@ -63,7 +63,13 @@ export function formatProfileStateStartupFailure(error: unknown): string | undef } if (isProfileStateAuthorityFailure(error)) { - return `Orca cannot safely choose a profile-state authority: ${error.message}` + return [ + `Orca cannot safely choose a profile-state authority: ${error.message}`, + 'An older build may have changed the JSON file. Both copies are preserved; neither is selected automatically.', + 'Stop Orca and copy the profile directory before choosing which state to keep.', + 'Run `orca profile state exports` to inspect retained recovery points.', + 'Use `orca profile state rollback --backup ` or `orca profile state rollback --revision ` only after selecting the state you want to restore.' + ].join('\n') } const failureClass = profileStateStartupFailureClass(error) diff --git a/src/main/persistence/profile-state/profile-state-versioned-export.test.ts b/src/main/persistence/profile-state/profile-state-versioned-export.test.ts index 492c96a2bca0..c74781ed96fb 100644 --- a/src/main/persistence/profile-state/profile-state-versioned-export.test.ts +++ b/src/main/persistence/profile-state/profile-state-versioned-export.test.ts @@ -39,6 +39,38 @@ describe('immutable versioned profile exports', () => { expect(fs.readdirSync(root)).toEqual([basename(target)]) }) + it('retains only five successfully published exports and leaves unrelated entries alone', () => { + const { root, write } = fixture() + for (let revision = 1; revision <= 7; revision++) { + expect(write(revision)).toBe(revision) + } + const reservedDirectory = join(root, 'orca-data.json.sqlite-export.1.json') + fs.mkdirSync(reservedDirectory) + const unrelated = join(root, 'orca-data.json.sqlite-export.notes.json') + fs.writeFileSync(unrelated, 'keep') + write(8) + expect(fs.readdirSync(root).sort()).toEqual([ + 'orca-data.json.sqlite-export.1.json', + ...[4, 5, 6, 7, 8].map((revision) => `orca-data.json.sqlite-export.${revision}.json`), + 'orca-data.json.sqlite-export.notes.json' + ]) + expect(fs.lstatSync(reservedDirectory).isDirectory()).toBe(true) + }) + + it('preserves every recovery point when the next export fails', () => { + const { root, write } = fixture() + for (let revision = 1; revision <= 5; revision++) { + write(revision) + } + const retained = fs.readdirSync(root) + expect(() => + writeVersionedProfileStateExport(join(root, 'orca-data.json'), () => { + throw new Error('disk full') + }) + ).toThrow('disk full') + expect(fs.readdirSync(root)).toEqual(retained) + }) + it('does not retain an empty profile export', () => { const { root, write } = fixture() expect(write(0)).toBeUndefined() diff --git a/src/main/persistence/profile-state/profile-state-versioned-export.ts b/src/main/persistence/profile-state/profile-state-versioned-export.ts index 1aaf705213d3..70cbee1d72e8 100644 --- a/src/main/persistence/profile-state/profile-state-versioned-export.ts +++ b/src/main/persistence/profile-state/profile-state-versioned-export.ts @@ -1,8 +1,11 @@ -import { mkdirSync, readFileSync, rmSync } from 'node:fs' +import { lstatSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname } from 'node:path' import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' import { durableWriteTempPath, publishFileDurableSync } from '../../durable-file-write' -import { profileStateJsonExportPath } from './profile-state-export-path' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './profile-state-export-path' /** An existing revision must never be replaced with different content. */ export function writeVersionedProfileStateExport( @@ -28,8 +31,22 @@ export function writeVersionedProfileStateExport( fsyncFileSync(targetPath) bestEffortFsyncDirectorySync(dirname(targetPath)) } + pruneProfileStateJsonExports(dataFile) return revision } finally { rmSync(stagingPath, { force: true }) } } + +function pruneProfileStateJsonExports(dataFile: string): void { + try { + const regularExports = profileStateJsonExportPaths(dataFile).filter((path) => + lstatSync(path).isFile() + ) + for (const path of regularExports.slice(5)) { + rmSync(path, { force: true }) + } + } catch (error) { + console.warn('[persistence] Failed to prune retained JSON exports:', error) + } +} diff --git a/src/main/persistence/profile-state/profile-state-worker-authority.test.ts b/src/main/persistence/profile-state/profile-state-worker-authority.test.ts index 29cf4c6dfa7a..49ea0150cb56 100644 --- a/src/main/persistence/profile-state/profile-state-worker-authority.test.ts +++ b/src/main/persistence/profile-state/profile-state-worker-authority.test.ts @@ -26,7 +26,7 @@ describe('worker authority close admission', () => { const closing = authority.close() expect(authority.close()).toBe(closing) - expect(closeWriter).not.toHaveBeenCalled() + expect(closeWriter).toHaveBeenCalledOnce() expect(() => authority.assertWritable()).toThrow('closing') await expect( authority.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) diff --git a/src/main/persistence/profile-state/profile-state-worker-authority.ts b/src/main/persistence/profile-state/profile-state-worker-authority.ts index f45b92041bbd..1c219623d7c3 100644 --- a/src/main/persistence/profile-state/profile-state-worker-authority.ts +++ b/src/main/persistence/profile-state/profile-state-worker-authority.ts @@ -90,7 +90,10 @@ export class ProfileStateWorkerAuthority implements AsyncProfileStateAuthority { } } - drainBackups(): Promise { + drainBackups(cancel = false): Promise { + if (cancel) { + this.backups.stop() + } return this.backups.drain() } @@ -140,11 +143,12 @@ export class ProfileStateWorkerAuthority implements AsyncProfileStateAuthority { } private async finishClose(): Promise { - try { - await this.backups.drain() - } finally { - this.backups.stop() - await this.writer.close() + this.backups.stop() + const settled = await Promise.allSettled([this.backups.drain(), this.writer.close()]) + for (const result of settled) { + if (result.status === 'rejected') { + throw result.reason + } } } @@ -153,7 +157,8 @@ export class ProfileStateWorkerAuthority implements AsyncProfileStateAuthority { this.initialization.databasePath, this.initialization.profileId, Date.now, - (job) => runProfileStateBackupWorker(job, { workerPath: this.options.backupWorkerPath }) + (job, signal) => + runProfileStateBackupWorker(job, { workerPath: this.options.backupWorkerPath, signal }) ) } } diff --git a/src/main/persistence/profile-state/profile-state-write-transaction.test.ts b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts index 97b515a8dc0b..2f4dade139cf 100644 --- a/src/main/persistence/profile-state/profile-state-write-transaction.test.ts +++ b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts @@ -6,6 +6,26 @@ import { } from './profile-state-write-transaction' describe('profile state write transaction ownership', () => { + it('preserves SQLITE_FULL after SQLite rolls back the transaction itself', () => { + const db = new Database(':memory:') + try { + db.exec('PRAGMA page_size=512; CREATE TABLE writes (data BLOB); PRAGMA max_page_count=2') + const rollback = vi.spyOn(db, 'exec') + expect(() => + withProfileStateWriteTransaction(db, () => { + db.exec('INSERT INTO writes VALUES (zeroblob(4096))') + }) + ).toThrow(/database or disk is full/) + expect(db.isTransaction).toBe(false) + expect(rollback).not.toHaveBeenCalledWith('ROLLBACK') + expect(db.prepare('SELECT COUNT(*) AS count FROM writes').get()).toMatchObject({ count: 0 }) + withProfileStateWriteTransaction(db, () => db.exec("INSERT INTO writes VALUES ('small')")) + expect(db.prepare('SELECT COUNT(*) AS count FROM writes').get()).toMatchObject({ count: 1 }) + } finally { + db.close() + } + }) + it('rolls back a failed deferred commit and leaves the connection usable', () => { const db = new Database(':memory:') try { diff --git a/src/main/persistence/profile-state/profile-state-write-transaction.ts b/src/main/persistence/profile-state/profile-state-write-transaction.ts index 314d75ff5c81..1dbf10d5bed1 100644 --- a/src/main/persistence/profile-state/profile-state-write-transaction.ts +++ b/src/main/persistence/profile-state/profile-state-write-transaction.ts @@ -18,11 +18,20 @@ export function withProfileStateWriteTransaction(db: Database.Database, write throw new Error('Profile state write requires an idle database connection') } db.exec('BEGIN IMMEDIATE') + let committing = false try { const result = write() + committing = true db.exec('COMMIT') return result } catch (error) { + if (!db.isTransaction) { + // SQLite can roll back a failed statement itself; a failed COMMIT is ambiguous. + if (committing) { + throw new ProfileStateIndeterminateWriteError(error, undefined) + } + throw error + } try { db.exec('ROLLBACK') } catch (rollbackError) { diff --git a/src/main/startup/browser-process-user-agent-ordering.test.ts b/src/main/startup/browser-process-user-agent-ordering.test.ts index 3fbc9ba32c82..148781d0ff8f 100644 --- a/src/main/startup/browser-process-user-agent-ordering.test.ts +++ b/src/main/startup/browser-process-user-agent-ordering.test.ts @@ -30,7 +30,17 @@ const mocks = vi.hoisted(() => { events.push(`set-name:${name}`) }) } - return { app, events, userAgent: () => userAgent, admission: vi.fn() } + return { + app, + events, + userAgent: () => userAgent, + admission: vi.fn(), + lock: vi.fn(() => true), + afterIdentity: vi.fn((): void => { + throw new Error('preflight-test-stop') + }), + recoverMoves: vi.fn() + } }) vi.mock('electron', () => ({ @@ -83,10 +93,7 @@ vi.mock('./dev-instance-identity', () => ({ })) vi.mock('./renderer-heap-headroom') vi.mock('./startup-diagnostics', () => ({ - isStartupDiagnosticsEnabled: () => { - mocks.events.push('continued-after-browser-identity') - throw new Error('preflight-test-stop') - }, + isStartupDiagnosticsEnabled: () => false, logStartupDiagnostic: vi.fn() })) vi.mock('./event-loop-stall-probe') @@ -100,8 +107,11 @@ vi.mock('./serve-desktop-activation', () => ({ })) vi.mock('./single-instance-lock', () => ({ shouldBypassSingleInstanceLock: () => false, - shouldSkipSingleInstanceLock: () => true, - acquireSingleInstanceLock: vi.fn(), + shouldSkipSingleInstanceLock: () => false, + acquireSingleInstanceLock: () => { + mocks.events.push('single-instance-lock') + return mocks.lock() + }, logSingleInstanceLockBypass: vi.fn(), logSingleInstanceLockFailure: vi.fn(), SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE: 1 @@ -109,7 +119,12 @@ vi.mock('./single-instance-lock', () => ({ vi.mock('../../shared/app-environment', () => ({ setAppEnvironment: vi.fn() })) vi.mock('../host/electron-app-environment', () => ({ ElectronAppEnvironment: class {} })) vi.mock('../own-chromium-tree-kill-guard') -vi.mock('../../shared/secret-store') +vi.mock('../../shared/secret-store', () => ({ + setSecretStore: () => { + mocks.events.push('continued-after-browser-identity') + mocks.afterIdentity() + } +})) vi.mock('../host/electron-secret-store') vi.mock('../ipc/pty-host-bindings') vi.mock('../host/electron-runtime-desktop-surface') @@ -135,7 +150,13 @@ vi.mock('../persistence/profile-state/profile-state-access', () => ({ })) vi.mock('../macos-press-and-hold-default') vi.mock('../ai-vault/session-parse-cache-persistence') -vi.mock('../orca-profiles/profile-index-store') +vi.mock('../orca-profiles/profile-index-store', () => ({ initOrcaProfilePaths: vi.fn() })) +vi.mock('../orca-profiles/profile-storage-paths', () => ({ + getProfileUserDataPath: () => '/canonical-user-data' +})) +vi.mock('../orca-profiles/profile-project-move-intent', () => ({ + recoverPendingProfileProjectMoves: mocks.recoverMoves +})) vi.mock('../stats/collector') vi.mock('../claude-usage/store') vi.mock('../codex-usage/store') @@ -169,17 +190,30 @@ vi.mock('../browser/browser-identity-mode-store', () => ({ })) describe('browser process user-agent startup ordering', () => { + it('does not acquire profile admission for a duplicate launch', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + mocks.events.length = 0 + mocks.lock.mockReturnValueOnce(false) + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(mocks.events).not.toContain('admission:/canonical-user-data') + expect(mocks.events).not.toContain('read-mode:/canonical-user-data') + expect(mocks.app.exit).toHaveBeenCalledWith(1) + mocks.events.length = 0 + }) + it('executes after the dev app name and before later preflight work', async () => { const { getBrowserProcessUserAgentIdentity } = await import('../browser/browser-process-user-agent') const { runMainProcessPreflight } = await import('./main-process-preflight') - expect(() => + expect( runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) - ).toThrow('preflight-test-stop') + ).toBe(false) const nameIndex = mocks.events.indexOf('set-name:Orca Development') const modeIndex = mocks.events.indexOf('read-mode:/canonical-user-data') @@ -214,7 +248,12 @@ describe('browser process user-agent startup ordering', () => { try { expect(runMainProcessPreflight({ focusExistingWindow, requestDesktopActivation })).toBe(false) expect(mocks.app.exit).toHaveBeenCalledWith(1) - expect(mocks.events).toEqual(['init-data-path', 'admission:/canonical-user-data']) + expect(mocks.events).toEqual([ + 'init-data-path', + 'set-name:Orca Development', + 'single-instance-lock', + 'admission:/canonical-user-data' + ]) expect(focusExistingWindow).not.toHaveBeenCalled() expect(requestDesktopActivation).not.toHaveBeenCalled() } finally { @@ -222,3 +261,22 @@ describe('browser process user-agent startup ordering', () => { } }) }) + +it('exits and releases admission after pending profile move recovery fails', async () => { + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => undefined) + mocks.recoverMoves.mockImplementationOnce(() => { + throw new Error('unreadable move journal') + }) + const { runMainProcessPreflight } = await import('./main-process-preflight') + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(mocks.recoverMoves).toHaveBeenCalledWith('/canonical-user-data') + expect(release).toHaveBeenCalledOnce() + expect(mocks.app.exit).toHaveBeenCalledWith(1) +}) diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 8f435cbb2c5b..40d20d73b03f 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -108,7 +108,7 @@ describe('startup ordering', () => { const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') expect(entrySource).toContain('formatProfileStateStartupFailure') - expect(entrySource).toContain('const message = formatProfileStateStartupFailure(error)') + expect(entrySource).toContain('formatProfileStateStartupFailure(error) ??') expect(entrySource).toContain('presentProfileStateStartupRecoveryDialog') expect(entrySource).toContain('!state.isServeMode && !isBackgroundLaunch()') expect(entrySource).toContain( diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index b6e3237695e0..2b9133af55e0 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -101,6 +101,21 @@ export type MainProcessPreflightOptions = { /** Performs all module-scope work that must happen before Electron's ready event. */ export function runMainProcessPreflight(options: MainProcessPreflightOptions): boolean { + try { + return initializeMainProcessPreflight(options) + } catch (error) { + console.error('[startup] Preflight failed:', error) + try { + state.profileStateAdmission?.release() + state.profileStateAdmission = undefined + } finally { + app.exit(1) + } + return false + } +} + +function initializeMainProcessPreflight(options: MainProcessPreflightOptions): boolean { if (runProfileStateRecoveryPreflight()) { return false } @@ -188,23 +203,11 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b // Why captured now: after the dev/E2E override above, and before app.setName('Orca') (whenReady) // changes how userData resolves on a case-sensitive filesystem. See persistence.ts:20-28. initDataPath() - // Keep admission until process death, including outstanding backup workers and final flushes. - try { - acquireProfileStateRuntimeAdmission(getCanonicalUserDataPath()) - } catch (error) { - console.error('[profile-state] Startup refused:', error) - app.exit(1) - return false - } // Why: Electron resolves the macOS safeStorage Keychain service name from the app name before // ready. Dev pins userData above, so applying its name here cannot shift the captured path. if (state.devInstanceIdentity && shouldApplyPreReadyAppName(state.devInstanceIdentity)) { app.setName(state.devInstanceIdentity.appName) } - // Why: renderer and worker defaults are process-global and must be fixed before any session exists. - initializeBrowserProcessUserAgent( - initializeBrowserIdentityModeStore(getCanonicalUserDataPath()).appliedMode - ) state.startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() if (state.startupDiagnosticsEnabled) { logStartupDiagnostic('before-single-instance-lock', { @@ -244,6 +247,11 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) return false } + state.profileStateAdmission = acquireProfileStateRuntimeAdmission(getCanonicalUserDataPath()) + // Renderer and worker defaults must be fixed before any session exists. + initializeBrowserProcessUserAgent( + initializeBrowserIdentityModeStore(getCanonicalUserDataPath()).appliedMode + ) // Why first in this block: the accessor throws until installed and everything below may read a // credential. The constructor does not touch `safeStorage` — it resolves lazily per call — so // installing here changes no timing, in particular not the pre-ready Keychain service-name diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 557c995f4aea..ad4548db7bd4 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -195,14 +195,25 @@ function installWillQuitHandler(): void { browserManager.setBrowserGuestStateChangedListener(null) const emulatorShutdown = state.runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() - // Why immediately before store.flushAsync() with no await in between: beginSshShutdown() marks every + // Why immediately before the final store flush with no await in between: beginSshShutdown() marks every // active SSH lease detached in memory synchronously, and that flush is what persists it. const sshShutdown = beginSshShutdown() killAllPty() const watcherShutdown = shutdownWatchersOnce() - const storeFlush = - state.store?.flushAsync({ exportJsonCompatibility: updateQuitInProgress }) ?? - Promise.resolve() + const finalStore = state.store + const storeFlush = (async () => { + if (!finalStore) { + return + } + try { + await finalStore.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + await finalStore.freezeWritesAsync() + state.profileStateAdmission?.release() + state.profileStateAdmission = undefined + } catch (error) { + console.error('[persistence] Failed to finalize profile state:', error) + } + })() // Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a // scan completion would drop the final snapshot. Captured before any await; joins the barrier below. const usageCacheFlush = Promise.all([ diff --git a/src/main/startup/main-process-ready-persistence-cleanup.test.ts b/src/main/startup/main-process-ready-persistence-cleanup.test.ts index 7fc2f63d16e1..988c78f1c127 100644 --- a/src/main/startup/main-process-ready-persistence-cleanup.test.ts +++ b/src/main/startup/main-process-ready-persistence-cleanup.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { state, foundation, runtime, i18n, launch } = vi.hoisted(() => ({ state: { store: { freezeWritesAsync: vi.fn(async () => {}) }, + profileStateAdmission: initialAdmission(), mainProcessI18nReady: Promise.resolve() }, foundation: vi.fn(async () => {}), @@ -11,6 +12,10 @@ const { state, foundation, runtime, i18n, launch } = vi.hoisted(() => ({ launch: vi.fn(async () => {}) })) +function initialAdmission(): { release(): void } | undefined { + return undefined +} + vi.mock('./main-process-state', () => ({ mainProcessState: state })) vi.mock('./main-process-ready-foundation', () => ({ initializeReadyFoundation: foundation })) vi.mock('./main-process-ready-runtime', () => ({ initializeReadyRuntimeServices: runtime })) @@ -26,11 +31,15 @@ const options = { handleMacAppActivation: () => {} } -beforeEach(() => vi.clearAllMocks()) +beforeEach(() => { + vi.clearAllMocks() + state.profileStateAdmission = { release: vi.fn() } +}) describe('startup persistence lifetime', () => { it('awaits writer release after a later startup phase fails', async () => { const failure = new Error('runtime startup failed') + const admission = state.profileStateAdmission runtime.mockRejectedValueOnce(failure) let release = () => {} state.store.freezeWritesAsync.mockImplementationOnce( @@ -42,8 +51,11 @@ describe('startup persistence lifetime', () => { const ready = initializeMainProcessReady(options) const rejected = expect(ready).rejects.toBe(failure) await vi.waitFor(() => expect(state.store.freezeWritesAsync).toHaveBeenCalledOnce()) + expect(admission?.release).not.toHaveBeenCalled() release() await rejected + expect(admission?.release).toHaveBeenCalledOnce() + expect(state.profileStateAdmission).toBeUndefined() expect(launch).not.toHaveBeenCalled() }) @@ -74,6 +86,7 @@ describe('startup persistence lifetime', () => { try { await expect(initializeMainProcessReady(options)).rejects.toBe(failure) expect(log).toHaveBeenCalledOnce() + expect(state.profileStateAdmission?.release).not.toHaveBeenCalled() } finally { log.mockRestore() } diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts index ec9191b12249..a6dcf83a4305 100644 --- a/src/main/startup/main-process-ready.ts +++ b/src/main/startup/main-process-ready.ts @@ -30,6 +30,8 @@ export async function initializeMainProcessReady( } catch (error) { try { await state.store?.freezeWritesAsync() + state.profileStateAdmission?.release() + state.profileStateAdmission = undefined } catch (closeError) { console.error( '[persistence] Failed to close profile persistence after startup failure:', diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index c0df4ad05336..81919505fd6e 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -47,6 +47,7 @@ import type { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-d import { createWebContentsTimedFlag } from './web-contents-timed-flag' import type { ProfileStateStorageClassification } from '../persistence/profile-state/profile-state-storage-classification' import type { ProfileStateStoreAuthorityMode } from '../persistence/profile-state/profile-state-store-factory' +import type { ProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' export type ProfileStateStartupMetadata = { backend: 'json' | 'sqlite' @@ -60,6 +61,10 @@ function createInitialProfileStateStartup(): ProfileStateStartupMetadata | null return null } +function createInitialProfileStateAdmission(): ProfileStateRuntimeAdmission | undefined { + return undefined +} + /** Mutable composition-root state shared by startup, window, serve, and quit phases. */ export const mainProcessState = { mainWindow: null as BrowserWindow | null, @@ -67,6 +72,7 @@ export const mainProcessState = { isQuitting: false, store: null as Store | null, profileStateStartup: createInitialProfileStateStartup(), + profileStateAdmission: createInitialProfileStateAdmission(), stats: null as StatsCollector | null, claudeUsage: null as ClaudeUsageStore | null, codexUsage: null as CodexUsageStore | null, diff --git a/src/main/startup/main-window-core-services.test.ts b/src/main/startup/main-window-core-services.test.ts index 9910dffcc551..50d0bd49db53 100644 --- a/src/main/startup/main-window-core-services.test.ts +++ b/src/main/startup/main-window-core-services.test.ts @@ -81,7 +81,7 @@ describe('main window profile-state update preparation', () => { vi.clearAllMocks() }) - it('publishes rollback and compatibility exports before an update quit', async () => { + it('publishes both recovery forms with one profile checkpoint before an update quit', async () => { const window = { webContents: { id: 17 } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: mocked BrowserWindow only needs webContents for this composition-root wiring test. @@ -107,11 +107,8 @@ describe('main window profile-state update preparation', () => { claudeRuntimeAuth: state.claudeRuntimeAuth, store }) - expect(store.writeLatestProfileStateJsonExportAsync).toHaveBeenCalledOnce() + expect(store.writeLatestProfileStateJsonExportAsync).not.toHaveBeenCalled() expect(store.writeLatestProfileStateJsonCompatibilityExportAsync).toHaveBeenCalledOnce() expect(options).toHaveProperty('onBeforeUpdateQuitFailure', 'abort') - expect(store.writeLatestProfileStateJsonExportAsync.mock.invocationCallOrder[0]).toBeLessThan( - store.writeLatestProfileStateJsonCompatibilityExportAsync.mock.invocationCallOrder[0] - ) }) }) diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 28dd6bbbb20d..c292a4455c7c 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -128,7 +128,6 @@ export function attachMainWindowCoreServices( onPtyExit: handlePtyExit, onBeforeUpdateQuit: async () => { await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) - await store.writeLatestProfileStateJsonExportAsync() await store.writeLatestProfileStateJsonCompatibilityExportAsync() }, onBeforeUpdateQuitFailure: 'abort', diff --git a/src/main/updater.quit-and-install.test.ts b/src/main/updater.quit-and-install.test.ts index 25c356593ac2..d5ba376e02ea 100644 --- a/src/main/updater.quit-and-install.test.ts +++ b/src/main/updater.quit-and-install.test.ts @@ -229,6 +229,20 @@ describe('updater', () => { expect(killAllPtyMock).toHaveBeenCalledTimes(1) }) + it('allows a required profile export to finish beyond the optional cleanup budget', async () => { + vi.useFakeTimers() + const onBeforeQuit = vi.fn(() => new Promise((resolve) => setTimeout(resolve, 5_000))) + const mainWindow = { webContents: { send: vi.fn() } } + const { setupAutoUpdater, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: updater only reads the mocked webContents.send in this lifecycle test. + setupAutoUpdater(mainWindow as never, { onBeforeQuit, onBeforeQuitFailure: 'abort' }) + quitAndInstall() + await vi.advanceTimersByTimeAsync(2_600) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(2_500) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce() + }) + it('aborts native install when required pre-quit cleanup times out', async () => { vi.useFakeTimers() @@ -246,7 +260,7 @@ describe('updater', () => { await vi.advanceTimersByTimeAsync(100) expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(2_500) + await vi.advanceTimersByTimeAsync(90_000) expect(onBeforeQuit).toHaveBeenCalledTimes(1) expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() diff --git a/src/main/updater/updater-install-support.ts b/src/main/updater/updater-install-support.ts index c493d14eb73e..2da273e152e6 100644 --- a/src/main/updater/updater-install-support.ts +++ b/src/main/updater/updater-install-support.ts @@ -10,7 +10,7 @@ import { disarmUpdateInstallExitWatchdog } from '../update-install-exit-watchdog import { resetMacInstallState } from '../updater-mac-install' import type { LinuxPackageInstallRecovery, UpdateStatus } from '../../shared/update-status-types' import { compareVersions } from '../updater-fallback' -import { PRE_QUIT_CLEANUP_TIMEOUT_MS } from './updater-state' +import { PRE_QUIT_CLEANUP_TIMEOUT_MS, REQUIRED_PRE_QUIT_CLEANUP_TIMEOUT_MS } from './updater-state' import { UpdaterCheckState } from './updater-check-state' export abstract class UpdaterInstallSupport extends UpdaterCheckState { @@ -137,6 +137,10 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState { return } + const timeoutMs = + this.onBeforeQuitFailure === 'abort' + ? REQUIRED_PRE_QUIT_CLEANUP_TIMEOUT_MS + : PRE_QUIT_CLEANUP_TIMEOUT_MS let timeout: ReturnType | null = null const cleanup = Promise.resolve() .then(() => this.onBeforeQuitCleanup?.()) @@ -157,7 +161,7 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState { } }) const timeoutResult = new Promise<'timeout'>((resolve) => { - timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS) + timeout = setTimeout(() => resolve('timeout'), timeoutMs) }) try { @@ -165,19 +169,17 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState { if (result === 'timeout') { recordUpdaterLifecycle( 'pre_quit_cleanup_timeout', - { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, + { timeoutMs }, { level: 'warn', message: this.onBeforeQuitFailure === 'abort' - ? `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; aborting update install` - : `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` + ? `Pre-quit cleanup exceeded ${timeoutMs}ms; aborting update install` + : `Pre-quit cleanup exceeded ${timeoutMs}ms; continuing update install` } ) if (this.onBeforeQuitFailure === 'abort') { - throw new Error( - `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms before update install` - ) + throw new Error(`Pre-quit cleanup exceeded ${timeoutMs}ms before update install`) } } } finally { diff --git a/src/main/updater/updater-state.ts b/src/main/updater/updater-state.ts index 2fbe2b6e25f5..32177e3183c1 100644 --- a/src/main/updater/updater-state.ts +++ b/src/main/updater/updater-state.ts @@ -13,6 +13,8 @@ export const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 export const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 export const QUIT_AND_INSTALL_DELAY_MS = 100 export const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500 +// Required profile exports may each wait for a bounded writer request. +export const REQUIRED_PRE_QUIT_CLEANUP_TIMEOUT_MS = 90_000 export const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000 export const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000 From f46d73aaa9a8a2cee3fddba39ce84ea7d32eb0c9 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 19:45:35 -0700 Subject: [PATCH 21/43] fix: await durable automation writes before dispatch and acknowledgement --- .../automation-dispatch-request.ts | 154 +++++++++++ .../automations/automation-run-writer.test.ts | 92 ++++--- src/main/automations/automation-run-writer.ts | 35 ++- .../automation-worker-durability.test.ts | 245 ++++++++++++++++++ ...automation-zero-grace-tick-latency.test.ts | 6 +- src/main/automations/dispatch-refusal.test.ts | 15 +- src/main/automations/dispatch-refusal.ts | 36 +-- .../headless-dispatch-durability.test.ts | 107 ++++++++ .../automations/headless-dispatch-runner.ts | 86 +++--- src/main/automations/refused-manual-run.ts | 4 +- .../run-completion-watcher.test.ts | 4 +- src/main/automations/service.ts | 138 +++++----- .../automation-change-publication.test.ts | 31 ++- .../orca-runtime-automation-operations.ts | 9 +- .../runtime/orca-runtime-automations.test.ts | 2 +- .../runtime/runtime-automation-controller.ts | 13 +- 16 files changed, 780 insertions(+), 197 deletions(-) create mode 100644 src/main/automations/automation-dispatch-request.ts create mode 100644 src/main/automations/automation-worker-durability.test.ts create mode 100644 src/main/automations/headless-dispatch-durability.test.ts diff --git a/src/main/automations/automation-dispatch-request.ts b/src/main/automations/automation-dispatch-request.ts new file mode 100644 index 000000000000..348a1b9ecee9 --- /dev/null +++ b/src/main/automations/automation-dispatch-request.ts @@ -0,0 +1,154 @@ +import type { WebContents } from 'electron' +import { isDeepStrictEqual } from 'node:util' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { Store } from '../persistence' +import type { AutomationRunWriter } from './automation-run-writer' +import type { HeadlessAutomationDispatcher } from './headless-dispatch' +import type { HeadlessAutomationDispatchContext } from './headless-dispatch-runner' +import { runHeadlessAutomationDispatch } from './headless-dispatch-runner' +import type { AutomationRunTargetResult } from './run-target-resolution' +import { createAutomationDispatchToken } from './dispatch-tokens' +import { NO_DISPATCH_HOST, sendRendererDispatch } from './dispatch-refusal' + +export type AutomationRendererChannel = Pick + +export class AutomationDispatchCancelledError extends Error {} + +type DispatchContext = Pick< + HeadlessAutomationDispatchContext, + 'runPrecheck' | 'markDispatchResult' | 'watchRun' +> & { + store: Store + runs: AutomationRunWriter + isActive(): boolean + getRenderer(): AutomationRendererChannel | null + headlessDispatcher: HeadlessAutomationDispatcher | null + resolveTarget(automation: Automation): AutomationRunTargetResult +} + +function definition(automation: Automation) { + const { lastRunAt: _last, updatedAt: _updated, nextRunAt: _next, ...configured } = automation + return configured +} + +function destination(target: Extract) { + return { + cwd: target.cwd, + repoId: target.repo.id, + repoPath: target.repo.path, + host: getRepoExecutionHostId(target.repo), + setupId: target.setup?.id + } +} + +/** Claim durably, then recheck everything that an acknowledgement wait can invalidate. */ +export async function requestAutomationDispatch( + ctx: DispatchContext, + automation: Automation, + run: AutomationRun, + expectedTarget: AutomationRunTargetResult +): Promise { + const expectedDefinition = structuredClone(definition(automation)) + const expectedDestination = expectedTarget.ok ? destination(expectedTarget) : undefined + const readRun = (): AutomationRun => { + if (!ctx.isActive()) { + throw new AutomationDispatchCancelledError( + 'Orca stopped before this automation could launch.' + ) + } + const current = ctx.store.listAutomationRuns(automation.id).find((entry) => entry.id === run.id) + if (!current || !ctx.store.listAutomations().some((entry) => entry.id === automation.id)) { + throw new AutomationDispatchCancelledError( + 'The automation was removed before it could launch.' + ) + } + return current + } + const resolveCurrentTarget = (): AutomationRunTargetResult => { + const current = ctx.store.listAutomations().find((entry) => entry.id === automation.id) + if (!current || !isDeepStrictEqual(expectedDefinition, definition(current))) { + return { ok: false, error: 'The automation changed before this run could launch.' } + } + const target = ctx.resolveTarget(current) + if ( + target.ok && + expectedDestination && + !isDeepStrictEqual(expectedDestination, destination(target)) + ) { + return { + ok: false, + error: 'The automation destination changed before this run could launch.' + } + } + return target + } + const refuse = (error: string) => + ctx.runs.updateRun({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + error + }) + const returnDurable = async (current: AutomationRun) => { + await ctx.store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + return current + } + + run = readRun() + if (run.status !== 'pending') { + return returnDurable(run) + } + let target = resolveCurrentTarget() + if (!target.ok || (!ctx.getRenderer() && !ctx.headlessDispatcher)) { + return refuse(target.ok ? NO_DISPATCH_HOST : target.error) + } + await ctx.runs.updateRun({ + runId: run.id, + status: 'dispatching', + workspaceId: automation.workspaceId, + error: null + }) + + run = readRun() + if (run.status !== 'dispatching') { + return returnDurable(run) + } + target = resolveCurrentTarget() + if (!target.ok) { + return refuse(target.error) + } + const renderer = ctx.getRenderer() + if (renderer) { + return sendRendererDispatch( + renderer, + { + automation, + run, + dispatchToken: createAutomationDispatchToken(automation.id, run.id) + }, + ctx.runs, + run + ) + } + const dispatcher = ctx.headlessDispatcher + if (!dispatcher) { + return refuse(NO_DISPATCH_HOST) + } + return runHeadlessAutomationDispatch({ + ...ctx, + automation, + run, + target, + dispatcher: (request) => { + if (readRun().status !== 'dispatching') { + throw new AutomationDispatchCancelledError('The run changed before its agent could launch.') + } + const latestTarget = resolveCurrentTarget() + if (!latestTarget.ok) { + throw new AutomationDispatchCancelledError(latestTarget.error) + } + return dispatcher({ ...request, target: latestTarget }) + } + }) +} diff --git a/src/main/automations/automation-run-writer.test.ts b/src/main/automations/automation-run-writer.test.ts index a149f28ea382..f6130fbf2234 100644 --- a/src/main/automations/automation-run-writer.test.ts +++ b/src/main/automations/automation-run-writer.test.ts @@ -5,20 +5,27 @@ */ import { describe, expect, it, vi } from 'vitest' import { createAutomationRunWriter } from './automation-run-writer' -import { AutomationService } from './service' +import { collectAutomationRunUsage } from './run-usage-collection' +import { buildProfileStateCutoverFixture } from '../persistence/profile-state-cutover-fixture' import type { Store } from '../persistence' -import type { Automation, AutomationRun } from '../../shared/automations-types' const SSH_SELECTOR = { kind: 'ssh', targetId: 'ssh-1' } as const +const data = buildProfileStateCutoverFixture('/fixture') +const automation = { ...data.automations[0], id: 'auto-1' } +const run = { ...data.automationRuns[0], id: 'run-1', automationId: automation.id } + function writerWith(selector: ReturnType) { const publish = vi.fn() const automationChangeSelector = vi.fn(() => selector) const store = { - createAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun), - updateAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun), + flushPendingOrThrowAsync: vi.fn().mockResolvedValue(undefined), + createAutomationRun: vi.fn(() => run), + updateAutomationRun: vi.fn(() => run), + recordRepeatedAutomationSkip: vi.fn(() => null), + advanceAutomationNextRun: vi.fn(() => automation), automationChangeSelector - } as unknown as Store + } return { publish, automationChangeSelector, @@ -28,61 +35,68 @@ function writerWith(selector: ReturnType) { } describe('automation run writer publications', () => { - it('names the host a created run belongs to', () => { + it('waits for durable acknowledgement before publishing or returning a run', async () => { + const { store, writer, publish } = writerWith(SSH_SELECTOR) + const acknowledgement = Promise.withResolvers() + vi.spyOn(store, 'flushPendingOrThrowAsync').mockReturnValue(acknowledgement.promise) + const completed = vi.fn() + const pending = writer.updateRun({ runId: 'run-1', status: 'dispatching' }).then(completed) + await Promise.resolve() + expect(publish).not.toHaveBeenCalled() + expect(completed).not.toHaveBeenCalled() + acknowledgement.resolve() + await pending + expect(publish).toHaveBeenCalledOnce() + expect(completed).toHaveBeenCalledOnce() + }) + + it('rejects a failed write without publishing success', async () => { + const { store, writer, publish } = writerWith(SSH_SELECTOR) + vi.spyOn(store, 'flushPendingOrThrowAsync').mockRejectedValue(new Error('disk full')) + await expect(writer.updateRun({ runId: 'run-1', status: 'completed' })).rejects.toThrow( + 'disk full' + ) + expect(publish).not.toHaveBeenCalled() + }) + + it('names the host a created run belongs to', async () => { const { writer, publish } = writerWith(SSH_SELECTOR) - writer.createRun({ id: 'auto-1' } as Automation, 0, 'scheduled') + await writer.createRun(automation, 0, 'scheduled') expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR }) }) - it('resolves the host from the written run, which is all a dispatch result names', () => { + it('resolves the host from the written run, which is all a dispatch result names', async () => { const { writer, publish, automationChangeSelector } = writerWith(SSH_SELECTOR) - writer.updateRun({ runId: 'run-1', status: 'completed', usage: null }) + await writer.updateRun({ runId: 'run-1', status: 'completed', usage: null }) expect(automationChangeSelector).toHaveBeenCalledWith('auto-1') expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR }) }) - it('keeps the usage reason on a usage-bearing write', () => { + it('keeps the usage reason on a usage-bearing write', async () => { const { writer, publish } = writerWith({ kind: 'self' }) - writer.updateRun({ + await writer.updateRun({ runId: 'run-1', status: 'completed', - usage: { status: 'known' } as AutomationRun['usage'] + usage: await collectAutomationRunUsage({ + automation, + run, + claudeUsage: null, + codexUsage: null + }) }) expect(publish).toHaveBeenCalledWith({ reason: 'usage', selector: { kind: 'self' } }) }) // Over-broad beats silent: a subscriber must still hear that something changed. - it('falls back to the whole authority when the record can no longer be named', () => { + it('falls back to the whole authority when the record can no longer be named', async () => { const { writer, publish } = writerWith(null) - writer.createRun({ id: 'auto-1' } as Automation, 0, 'scheduled') + await writer.createRun(automation, 0, 'scheduled') expect(publish).toHaveBeenCalledWith({ reason: 'run' }) }) - it('does not project a selector nobody will hear', () => { - const automationChangeSelector = vi.fn(() => SSH_SELECTOR) - const store = { - createAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun), - automationChangeSelector - } as unknown as Store - createAutomationRunWriter(store, null).createRun({ id: 'auto-1' } as Automation, 0, 'scheduled') + it('does not project a selector nobody will hear', async () => { + const { store, automationChangeSelector } = writerWith(SSH_SELECTOR) + await createAutomationRunWriter(store, null).createRun(automation, 0, 'scheduled') expect(automationChangeSelector).not.toHaveBeenCalled() }) - - // Why the renderer dispatch path no longer emits its own: the scoped event is - // published during the write, so it is queued before the reply the caller awaits. - it('publishes before markDispatchResult settles', async () => { - const publish = vi.fn() - const store = { - updateAutomationRun: vi.fn( - () => ({ id: 'run-1', automationId: 'auto-1', status: 'dispatched' }) as AutomationRun - ), - automationChangeSelector: vi.fn(() => SSH_SELECTOR) - } as unknown as Store - const service = new AutomationService(store, { onAutomationsChanged: publish }) - - const settled = service.markDispatchResult({ runId: 'run-1', status: 'dispatched' }) - - expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR }) - await settled - }) }) diff --git a/src/main/automations/automation-run-writer.ts b/src/main/automations/automation-run-writer.ts index ef08c84de49a..1f813a62d0cc 100644 --- a/src/main/automations/automation-run-writer.ts +++ b/src/main/automations/automation-run-writer.ts @@ -2,18 +2,31 @@ import type { Store } from '../persistence' import type { PublishAutomationsChanged } from '../../shared/runtime-client-events' import type { AutomationDispatchResult, AutomationRun } from '../../shared/automations-types' +type DurableWrite unknown> = ( + ...args: Parameters +) => Promise> + export type AutomationRunWriter = { - createRun: Store['createAutomationRun'] - updateRun: Store['updateAutomationRun'] + createRun: DurableWrite + updateRun: DurableWrite /** Null when nothing could be folded — the caller then writes an ordinary run. */ - repeatSkip: Store['recordRepeatedAutomationSkip'] + repeatSkip: DurableWrite + advanceNextRun: DurableWrite } /** Wraps run persistence so every committed write announces itself. Clients with * the Automations page closed — or none attached at all — have no other way to * learn that a run progressed, so the event must follow the write, not a render. */ export function createAutomationRunWriter( - store: Store, + store: Pick< + Store, + | 'createAutomationRun' + | 'updateAutomationRun' + | 'recordRepeatedAutomationSkip' + | 'advanceAutomationNextRun' + | 'automationChangeSelector' + | 'flushPendingOrThrowAsync' + >, publish: PublishAutomationsChanged | null ): AutomationRunWriter { // A run write never moves the record, so its own host is the whole publication. @@ -26,19 +39,27 @@ export function createAutomationRunWriter( publish({ reason, ...(selector ? { selector } : {}) }) } return { - createRun: (automation, scheduledFor, trigger): AutomationRun => { + advanceNextRun: async (id, now) => { + const automation = store.advanceAutomationNextRun(id, now) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + return automation + }, + createRun: async (automation, scheduledFor, trigger): Promise => { const run = store.createAutomationRun(automation, scheduledFor, trigger) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) announce(automation.id, 'run') return run }, - updateRun: (result: AutomationDispatchResult): AutomationRun => { + updateRun: async (result: AutomationDispatchResult): Promise => { const run = store.updateAutomationRun(result) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) announce(run.automationId, result.usage ? 'usage' : 'run') return run }, - repeatSkip: (automationId, error, scheduledFor): AutomationRun | null => { + repeatSkip: async (automationId, error, scheduledFor): Promise => { const run = store.recordRepeatedAutomationSkip(automationId, error, scheduledFor) if (run) { + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) announce(automationId, 'run') } return run diff --git a/src/main/automations/automation-worker-durability.test.ts b/src/main/automations/automation-worker-durability.test.ts new file mode 100644 index 000000000000..22d13899e3b6 --- /dev/null +++ b/src/main/automations/automation-worker-durability.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from 'vitest' +import { AutomationService } from './service' +import type { Store } from '../persistence' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from '../persistence/loading-store/profile-state-maintenance-fixture' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +async function fixture() { + const fixture = await createWorkerMaintenanceFixture() + for (const automation of fixture.store.listAutomations()) { + fixture.store.updateAutomation(automation.id, { enabled: false }) + } + const automation = fixture.store.createAutomation({ + name: 'Durable run', + prompt: 'Check the project', + agentId: 'claude', + projectId: 'repo-local', + workspaceMode: 'existing', + workspaceId: 'repo-local::/fixture/local', + timezone: 'UTC', + rrule: 'FREQ=HOURLY;BYMINUTE=0', + dtstart: Date.now() - 60_000 + }) + await fixture.store.flushPendingOrThrowAsync() + return { ...fixture, automation } +} + +const launch = { workspaceId: 'repo-local::/fixture/local', terminalSessionId: 'run-tab' } + +function blockAcknowledgement(store: Store, index: number) { + const gate = maintenanceBarrier() + const blocked = maintenanceBarrier() + const flush = store.flushPendingOrThrowAsync.bind(store) + let calls = 0 + vi.spyOn(store, 'flushPendingOrThrowAsync').mockImplementation(async (options) => { + calls += 1 + if (calls === index) { + blocked.resolve() + await gate.promise + } + await flush(options) + }) + return { blocked: blocked.promise, release: gate.resolve } +} + +describe('automation background writer durability', () => { + it('claims a shared occurrence once when callers await the same pending row', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000) + const { store, automation } = await fixture() + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + const runs = await Promise.all([service.runNow(automation.id), service.runNow(automation.id)]) + expect(new Set(runs.map((run) => run.id)).size).toBe(1) + expect(dispatcher).toHaveBeenCalledOnce() + } finally { + service.stop() + } + }) + + it('persists dispatch intent before starting external work', async () => { + const { store, automation, readState } = await fixture() + const gate = maintenanceBarrier() + const flush = store.flushPendingOrThrowAsync.bind(store) + vi.spyOn(store, 'flushPendingOrThrowAsync').mockImplementationOnce(async (options) => { + await gate.promise + await flush(options) + }) + const dispatcher = vi.fn(async () => { + expect(readState().automationRuns).toContainEqual( + expect.objectContaining({ automationId: automation.id, status: 'dispatching' }) + ) + return launch + }) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + const run = service.runNow(automation.id) + await Promise.resolve() + expect(dispatcher).not.toHaveBeenCalled() + gate.resolve() + await expect(run).resolves.toMatchObject({ status: 'dispatched' }) + expect(dispatcher).toHaveBeenCalledOnce() + } finally { + service.stop() + } + }) + + it('never dispatches when the writer refuses the durable acknowledgement', async () => { + const { store, automation } = await fixture() + vi.spyOn(store, 'flushPendingOrThrowAsync').mockRejectedValueOnce(new Error('disk full')) + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + await expect(service.runNow(automation.id)).rejects.toThrow('disk full') + expect(dispatcher).not.toHaveBeenCalled() + } finally { + service.stop() + } + }) + + it.each([ + ['dispatching', 1], + ['dispatched', 1], + ['completed', 1], + ['dispatching', 120_001], + ['dispatched', 120_001], + ['completed', 120_001] + ] as const)( + 'preserves a durable %s occurrence after %s ms when next-run advancement was interrupted', + async (status, lateness) => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000) + const { store, automation, readState } = await fixture() + store.updateAutomation(automation.id, { missedRunGraceMinutes: 0 }) + const dueAt = automation.nextRunAt + const run = store.createAutomationRun(automation, dueAt) + store.updateAutomationRun({ runId: run.id, status }) + await store.flushPendingOrThrowAsync() + clock.mockReturnValue(dueAt + lateness) + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + service.start() + await vi.waitFor(() => { + const stored = readState().automations.find( + (entry: { id: string }) => entry.id === automation.id + ) + expect(stored.nextRunAt).toBeGreaterThan(dueAt + lateness) + }) + expect(dispatcher).not.toHaveBeenCalled() + expect(store.listAutomationRuns(automation.id)).toHaveLength(1) + expect(store.listAutomationRuns(automation.id)[0].status).toBe(status) + } finally { + service.stop() + } + } + ) + + it.each([1, 2])('cancels a pending dispatch stopped during acknowledgement %s', async (index) => { + const { store, automation } = await fixture() + const acknowledgement = blockAcknowledgement(store, index) + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + const pending = service.runNow(automation.id) + const rejected = expect(pending).rejects.toThrow('stopped before') + await acknowledgement.blocked + service.stop() + acknowledgement.release() + await rejected + expect(dispatcher).not.toHaveBeenCalled() + }) + + it('does not resurrect or dispatch an automation deleted during its intent acknowledgement', async () => { + const { store, automation, readState } = await fixture() + const acknowledgement = blockAcknowledgement(store, 2) + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + const pending = service.runNow(automation.id) + const rejected = expect(pending).rejects.toThrow('removed before') + await acknowledgement.blocked + store.deleteAutomation(automation.id) + acknowledgement.release() + await rejected + expect(dispatcher).not.toHaveBeenCalled() + expect( + readState().automationRuns.some( + (run: { automationId: string }) => run.automationId === automation.id + ) + ).toBe(false) + } finally { + service.stop() + } + }) + + it.each([1, 2])('refuses changed instructions during acknowledgement %s', async (index) => { + const { store, automation } = await fixture() + const acknowledgement = blockAcknowledgement(store, index) + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + const pending = service.runNow(automation.id) + await acknowledgement.blocked + store.updateAutomation(automation.id, { prompt: 'Different instructions' }) + acknowledgement.release() + await expect(pending).resolves.toMatchObject({ + status: 'skipped_unavailable', + error: expect.stringContaining('changed before') + }) + expect(dispatcher).not.toHaveBeenCalled() + } finally { + service.stop() + } + }) + + it.each([1, 2])('refuses a moved execution host during acknowledgement %s', async (index) => { + const { store, automation } = await fixture() + const acknowledgement = blockAcknowledgement(store, index) + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + const pending = service.runNow(automation.id) + await acknowledgement.blocked + store.updateRepo('repo-local', { executionHostId: 'runtime:other-host' }) + acknowledgement.release() + await expect(pending).resolves.toMatchObject({ status: 'skipped_unavailable' }) + expect(dispatcher).not.toHaveBeenCalled() + } finally { + service.stop() + } + }) + + it.each(['disconnect', 'replacement'] as const)( + 'refuses an unready renderer after %s during acknowledgement', + async (change) => { + const { store, automation } = await fixture() + const acknowledgement = blockAcknowledgement(store, 2) + const renderer = { isDestroyed: () => false, send: vi.fn() } + const replacement = { isDestroyed: () => false, send: vi.fn() } + const service = new AutomationService(store) + service.setWebContents(renderer) + service.setRendererReady() + try { + const pending = service.runNow(automation.id) + await acknowledgement.blocked + service.setWebContents(change === 'disconnect' ? null : replacement) + acknowledgement.release() + await expect(pending).resolves.toMatchObject({ status: 'skipped_unavailable' }) + expect(renderer.send).not.toHaveBeenCalled() + expect(replacement.send).not.toHaveBeenCalled() + } finally { + service.stop() + } + } + ) +}) diff --git a/src/main/automations/automation-zero-grace-tick-latency.test.ts b/src/main/automations/automation-zero-grace-tick-latency.test.ts index 4639a8728875..d6d7cadd1187 100644 --- a/src/main/automations/automation-zero-grace-tick-latency.test.ts +++ b/src/main/automations/automation-zero-grace-tick-latency.test.ts @@ -76,7 +76,11 @@ describe('AutomationService zero-grace tick latency', () => { service.setWebContents({ isDestroyed: () => false, send: vi.fn() }) service.start() service.setRendererReady() - await vi.advanceTimersByTimeAsync(0) + await vi.waitFor(() => { + if (store.listAutomations().some((automation) => automation.nextRunAt <= at)) { + throw new Error('Automation evaluation is still saving its next occurrence') + } + }) service.stop() } diff --git a/src/main/automations/dispatch-refusal.test.ts b/src/main/automations/dispatch-refusal.test.ts index 233deb3e7827..27179f1cf56d 100644 --- a/src/main/automations/dispatch-refusal.test.ts +++ b/src/main/automations/dispatch-refusal.test.ts @@ -59,13 +59,14 @@ function makeRunWriter(foldsRepeat: boolean): { const created: string[] = [] const updated: { status: string; error?: string | null }[] = [] const writer: AutomationRunWriter = { - repeatSkip: () => (foldsRepeat ? makeRun('folded') : null), - createRun: () => { + advanceNextRun: async () => brokenAutomation, + repeatSkip: async () => (foldsRepeat ? makeRun('folded') : null), + createRun: async () => { const run = makeRun(`run-${created.length + 1}`) created.push(run.id) return run }, - updateRun: (args) => { + updateRun: async (args) => { updated.push({ status: args.status, error: args.error }) return { ...makeRun(args.runId), status: args.status, error: args.error ?? null } } @@ -78,11 +79,11 @@ describe('recordUnevaluableAutomation', () => { vi.restoreAllMocks() }) - it('writes one run and logs once when the record is newly broken', () => { + it('writes one run and logs once when the record is newly broken', async () => { const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) const { writer, created, updated } = makeRunWriter(false) - recordUnevaluableAutomation({ + await recordUnevaluableAutomation({ runs: writer, automation: brokenAutomation, error: new Error('Invalid cron day of month.') @@ -95,12 +96,12 @@ describe('recordUnevaluableAutomation', () => { // The record is retried every tick on purpose, so a repaired schedule resumes on its own. // The fold is what keeps that from writing a row, and logging, once per tick forever. - it('stays silent on a record it has already reported', () => { + it('stays silent on a record it has already reported', async () => { const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) const { writer, created } = makeRunWriter(true) for (let tick = 0; tick < 5; tick += 1) { - recordUnevaluableAutomation({ + await recordUnevaluableAutomation({ runs: writer, automation: brokenAutomation, error: new Error('Invalid cron day of month.') diff --git a/src/main/automations/dispatch-refusal.ts b/src/main/automations/dispatch-refusal.ts index 4a5fffa1b16b..286abb2f2528 100644 --- a/src/main/automations/dispatch-refusal.ts +++ b/src/main/automations/dispatch-refusal.ts @@ -43,17 +43,17 @@ export function describeScheduledRefusal(input: { * and doc:94 asks for both. Never dispatches: the reason is the one the * scheduler would have written for the same record. */ -export function recordRefusedAutomationRun(input: { +export async function recordRefusedAutomationRun(input: { store: Store runs: AutomationRunWriter automation: Automation allowRemoteHostScheduling: boolean -}): void { +}): Promise { const target = resolveAutomationRunTarget(input.store, input.automation, { allowRemoteHostScheduling: input.allowRemoteHostScheduling }) - const run = input.runs.createRun(input.automation, Date.now(), 'manual') - input.runs.updateRun({ + const run = await input.runs.createRun(input.automation, Date.now(), 'manual') + await input.runs.updateRun({ runId: run.id, status: 'skipped_unavailable', workspaceId: input.automation.workspaceId, @@ -66,21 +66,24 @@ export function recordRefusedAutomationRun(input: { * stalled. Folds on the fixed sentence and the unchanged nextRunAt, so a record that stays * broken writes one row rather than one per tick, and never throws back into the tick. */ -export function recordUnevaluableAutomation(input: { +export async function recordUnevaluableAutomation(input: { runs: AutomationRunWriter automation: Automation error: unknown -}): void { +}): Promise { const { automation } = input try { // nextRunAt deliberately stays put: the record is retried so a repaired schedule resumes // on its own. The fold is what keeps that from writing a row — and logging — every tick. - if (input.runs.repeatSkip(automation.id, UNEVALUABLE_SCHEDULE, automation.nextRunAt)) { + if (await input.runs.repeatSkip(automation.id, UNEVALUABLE_SCHEDULE, automation.nextRunAt)) { return } console.error('[automations] failed to evaluate automation:', automation.id, input.error) - const run = input.runs.createRun(automation, automation.nextRunAt) - input.runs.updateRun({ + const run = await input.runs.createRun(automation, automation.nextRunAt) + if (run.status !== 'pending') { + return + } + await input.runs.updateRun({ runId: run.id, status: 'skipped_unavailable', workspaceId: automation.workspaceId, @@ -101,12 +104,12 @@ export function recordUnevaluableAutomation(input: { * Sends the dispatch request through the renderer channel, closing the run out as * `dispatch_failed` when the send throws — a failed send is not an unreadable schedule. */ -export function sendRendererDispatch( +export async function sendRendererDispatch( channel: Pick | null, payload: AutomationDispatchRequest, runs: AutomationRunWriter, run: AutomationRun -): AutomationRun { +): Promise { try { channel?.send('automations:dispatchRequested', payload) return run @@ -153,13 +156,16 @@ export function missedBeyondGrace(input: { return input.now - input.scheduledFor > graceMs + jitterMs } -export function recordMissedRun(input: { +export async function recordMissedRun(input: { runs: AutomationRunWriter automation: Automation scheduledFor: number -}): void { - const missed = input.runs.createRun(input.automation, input.scheduledFor) - input.runs.updateRun({ +}): Promise { + const missed = await input.runs.createRun(input.automation, input.scheduledFor) + if (missed.status !== 'pending') { + return + } + await input.runs.updateRun({ runId: missed.id, status: 'skipped_missed', workspaceId: input.automation.workspaceId, diff --git a/src/main/automations/headless-dispatch-durability.test.ts b/src/main/automations/headless-dispatch-durability.test.ts new file mode 100644 index 000000000000..ea5403801e0f --- /dev/null +++ b/src/main/automations/headless-dispatch-durability.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildProfileStateCutoverFixture } from '../persistence/profile-state-cutover-fixture' +import type { AutomationRun } from '../../shared/automations-types' +import type { HeadlessAutomationDispatchLaunch } from './headless-dispatch' +import { runHeadlessAutomationDispatch } from './headless-dispatch-runner' + +function fixture(launch: HeadlessAutomationDispatchLaunch) { + const state = buildProfileStateCutoverFixture('/fixture') + const automation = { ...state.automations[0], precheck: null } + const run: AutomationRun = { + ...state.automationRuns[0], + automationId: automation.id, + status: 'dispatching' + } + const dispatched: AutomationRun = { ...run, ...launch, status: 'dispatched' } + return { + automation, + run, + target: { ok: true as const, cwd: state.repos[0].path, repo: state.repos[0] }, + dispatcher: vi.fn(async () => launch), + runs: { + createRun: vi.fn(async () => run), + updateRun: vi.fn(async () => dispatched), + repeatSkip: vi.fn(async () => null), + advanceNextRun: vi.fn(async () => automation) + }, + runPrecheck: vi.fn(async () => null), + markDispatchResult: vi.fn(async () => dispatched), + watchRun: vi.fn() + } +} + +const terminal = { + workspaceId: 'launched-workspace', + terminalSessionId: 'launched-tab', + terminalPaneKey: 'launched-pane', + terminalPtyId: 'launched-pty' +} + +describe('headless automation observation during persistence', () => { + it('handles an early completion rejection while the dispatched write is stalled', async () => { + const completion = Promise.withResolvers() + const acknowledgement = Promise.withResolvers() + const context = fixture({ ...terminal, completion: completion.promise }) + context.runs.updateRun.mockReturnValueOnce(acknowledgement.promise) + const pending = runHeadlessAutomationDispatch(context) + await vi.waitFor(() => expect(context.runs.updateRun).toHaveBeenCalledOnce()) + completion.reject(new Error('agent exited early')) + await vi.waitFor(() => + expect(context.markDispatchResult).toHaveBeenCalledWith({ + runId: context.run.id, + status: 'dispatch_failed', + ...terminal, + workspaceDisplayName: null, + error: 'agent exited early' + }) + ) + acknowledgement.resolve({ ...context.run, ...terminal, status: 'dispatched' }) + await pending + }) + + it('starts terminal observation even when the dispatched write fails after launch', async () => { + const context = fixture(terminal) + const failure = new Error('disk full') + context.runs.updateRun.mockRejectedValueOnce(failure) + await expect(runHeadlessAutomationDispatch(context)).rejects.toBe(failure) + expect(context.watchRun).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + ...terminal, + status: 'dispatched' + }) + ) + expect(context.runs.updateRun).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + ...terminal, + status: 'dispatched' + }) + ) + }) + + it('keeps receiving completion after the launched run fails to persist', async () => { + const completion = Promise.withResolvers<{ status: 'completed' }>() + const context = fixture({ ...terminal, completion: completion.promise }) + context.runs.updateRun.mockRejectedValueOnce(new Error('writer unavailable')) + await expect(runHeadlessAutomationDispatch(context)).rejects.toThrow('writer unavailable') + completion.resolve({ status: 'completed' }) + await vi.waitFor(() => + expect(context.markDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ ...terminal, status: 'completed' }) + ) + ) + expect(context.runs.updateRun).toHaveBeenCalledOnce() + }) + + it('records an actual launch rejection as dispatch failure', async () => { + const context = fixture(terminal) + context.dispatcher.mockRejectedValueOnce(new Error('shell unavailable')) + await runHeadlessAutomationDispatch(context) + expect(context.runs.updateRun).toHaveBeenCalledExactlyOnceWith({ + runId: context.run.id, + status: 'dispatch_failed', + workspaceId: context.automation.workspaceId, + error: 'shell unavailable' + }) + expect(context.watchRun).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/automations/headless-dispatch-runner.ts b/src/main/automations/headless-dispatch-runner.ts index 8d4e3aed290d..5a0ed60582cd 100644 --- a/src/main/automations/headless-dispatch-runner.ts +++ b/src/main/automations/headless-dispatch-runner.ts @@ -8,7 +8,10 @@ import { didAutomationPrecheckPass, formatAutomationPrecheckFailure } from '../../shared/automation-precheck' -import type { HeadlessAutomationDispatcher } from './headless-dispatch' +import type { + HeadlessAutomationDispatcher, + HeadlessAutomationDispatchLaunch +} from './headless-dispatch' import type { AutomationRunTargetResult } from './run-target-resolution' import type { AutomationRunWriter } from './automation-run-writer' @@ -42,47 +45,9 @@ export async function runHeadlessAutomationDispatch( error: formatAutomationPrecheckFailure(precheckResult) }) } + let launch: HeadlessAutomationDispatchLaunch try { - const launch = await ctx.dispatcher({ automation, run, target }) - const launchRunTarget = { - workspaceId: launch.workspaceId, - workspaceDisplayName: launch.workspaceDisplayName ?? null, - terminalSessionId: launch.terminalSessionId, - terminalPaneKey: launch.terminalPaneKey ?? null, - terminalPtyId: launch.terminalPtyId ?? null - } - const updated = runs.updateRun({ - runId: run.id, - status: 'dispatched', - ...launchRunTarget, - error: null - }) - if (!launch.completion) { - // Why: a dispatcher that reports no completion promise would otherwise - // leave the run at 'dispatched' for the process lifetime. - ctx.watchRun(updated) - return updated - } - void launch.completion - .then((completion) => - ctx.markDispatchResult({ - runId: run.id, - status: completion.status, - ...launchRunTarget, - precheckResult, - outputSnapshot: completion.outputSnapshot ?? null, - error: completion.error ?? null - }) - ) - .catch((error) => - ctx.markDispatchResult({ - runId: run.id, - status: 'dispatch_failed', - ...launchRunTarget, - error: describeDispatchError(error) - }) - ) - return updated + launch = await ctx.dispatcher({ automation, run, target }) } catch (error) { return runs.updateRun({ runId: run.id, @@ -91,4 +56,43 @@ export async function runHeadlessAutomationDispatch( error: describeDispatchError(error) }) } + const launchRunTarget = { + workspaceId: launch.workspaceId, + workspaceDisplayName: launch.workspaceDisplayName ?? null, + terminalSessionId: launch.terminalSessionId, + terminalPaneKey: launch.terminalPaneKey ?? null, + terminalPtyId: launch.terminalPtyId ?? null + } + const updated = runs.updateRun({ + runId: run.id, + status: 'dispatched', + ...launchRunTarget, + error: null + }) + // Observe the launched agent even while persistence is stalled or rejects its acknowledgement. + if (!launch.completion) { + ctx.watchRun({ ...run, ...launchRunTarget, status: 'dispatched', error: null }) + } else { + void launch.completion + .then( + (completion) => + ctx.markDispatchResult({ + runId: run.id, + status: completion.status, + ...launchRunTarget, + precheckResult, + outputSnapshot: completion.outputSnapshot ?? null, + error: completion.error ?? null + }), + (error) => + ctx.markDispatchResult({ + runId: run.id, + status: 'dispatch_failed', + ...launchRunTarget, + error: describeDispatchError(error) + }) + ) + .catch((error) => console.error('[automations] Failed to persist run completion:', error)) + } + return updated } diff --git a/src/main/automations/refused-manual-run.ts b/src/main/automations/refused-manual-run.ts index f6a103408c2a..b3b607866549 100644 --- a/src/main/automations/refused-manual-run.ts +++ b/src/main/automations/refused-manual-run.ts @@ -19,7 +19,7 @@ import { type RefusableAutomationService = { runNow: (automationId: string) => Promise - recordRefusedRun: (automationId: string) => void + recordRefusedRun: (automationId: string) => void | Promise } export async function runAutomationNowFenced(input: { @@ -34,7 +34,7 @@ export async function runAutomationNowFenced(input: { error instanceof AutomationOwnerConflictError && error.code === AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved ) { - input.service.recordRefusedRun(input.automationId) + await input.service.recordRefusedRun(input.automationId) } throw error } diff --git a/src/main/automations/run-completion-watcher.test.ts b/src/main/automations/run-completion-watcher.test.ts index 31c1aea0bb47..2e900634c345 100644 --- a/src/main/automations/run-completion-watcher.test.ts +++ b/src/main/automations/run-completion-watcher.test.ts @@ -341,7 +341,7 @@ describe('automationsChanged publication', () => { }) const run = await service.runNow(automation.id) - expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run']) + expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run', 'run']) expect(seen.at(-1)?.status).toBe('dispatched') await service.markDispatchResult({ @@ -350,7 +350,7 @@ describe('automationsChanged publication', () => { ...LAUNCH_TARGET, error: null }) - expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run', 'run', 'usage']) + expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run', 'run', 'run', 'usage']) expect(seen.at(-1)?.status).toBe('completed') // Every run/usage write names its own host, so one automation's run cannot // invalidate the rest of the authority. diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 227a290783bf..1ff8eafbd6b9 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -1,13 +1,13 @@ -import type { WebContents } from 'electron' - -/** All the service asks of the renderer: is it still there, and take this message. Narrower - * than WebContents so a test can supply the real shape instead of casting one. */ -export type AutomationRendererChannel = Pick +import { + AutomationDispatchCancelledError, + requestAutomationDispatch, + type AutomationRendererChannel +} from './automation-dispatch-request' +export type { AutomationRendererChannel } from './automation-dispatch-request' import type { Store } from '../persistence' import { isFinalAutomationRunStatus, type Automation, - type AutomationDispatchRequest, type AutomationDispatchResult, type AutomationPrecheckResult, type AutomationRun @@ -18,8 +18,7 @@ import { runAutomationPrecheck } from './precheck-runner' import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution' import { writeAutomationRunUsage } from './run-usage-collection' import type { HeadlessAutomationDispatcher } from './headless-dispatch' -import { clearAutomationDispatchTokens, createAutomationDispatchToken } from './dispatch-tokens' -import { runHeadlessAutomationDispatch } from './headless-dispatch-runner' +import { clearAutomationDispatchTokens } from './dispatch-tokens' import { AutomationRunCompletionWatcher, type AutomationRunTerminalObserver @@ -31,9 +30,7 @@ import { missedBeyondGrace, recordMissedRun, recordRefusedAutomationRun, - recordUnevaluableAutomation, - sendRendererDispatch, - NO_DISPATCH_HOST + recordUnevaluableAutomation } from './dispatch-refusal' import type { AutomationsChangedPayload, @@ -49,6 +46,8 @@ export class AutomationService { private webContents: AutomationRendererChannel | null = null private rendererReady = false private evaluating = false + private stopped = false + private dispatchGeneration = 0 private readonly claudeUsage: ClaudeUsageStore | null private readonly codexUsage: CodexUsageStore | null private readonly allowRemoteHostScheduling: boolean @@ -114,6 +113,7 @@ export class AutomationService { if (this.timer) { return } + this.stopped = false this.timer = setInterval(() => { void this.evaluateDueRuns() }, this.tickMs) @@ -130,6 +130,8 @@ export class AutomationService { } stop(): void { + this.stopped = true + this.dispatchGeneration += 1 this.completionWatcher?.dispose() if (!this.timer) { return @@ -139,19 +141,21 @@ export class AutomationService { } async runNow(automationId: string): Promise { + const generation = this.dispatchGeneration const automation = this.store.listAutomations().find((entry) => entry.id === automationId) if (!automation) { throw new Error('Automation not found.') } - const run = this.runs.createRun(automation, Date.now(), 'manual') - return await this.requestDispatch(automation, run, this.resolveTarget(automation)) + const target = this.resolveTarget(automation) + const run = await this.runs.createRun(automation, Date.now(), 'manual') + return await this.requestDispatch(automation, run, target, generation) } /** The run-history row doc:94 pairs with the typed refusal an execute fence throws. */ - recordRefusedRun(automationId: string): void { + async recordRefusedRun(automationId: string): Promise { const automation = this.store.listAutomations().find((entry) => entry.id === automationId) if (automation) { - recordRefusedAutomationRun({ + await recordRefusedAutomationRun({ store: this.store, runs: this.runs, automation, @@ -198,7 +202,7 @@ export class AutomationService { } async markDispatchResult(result: AutomationDispatchResult): Promise { - const run = this.runs.updateRun(result) + const run = await this.runs.updateRun(result) clearAutomationDispatchTokens(run.automationId, run.id) if (!isFinalAutomationRunStatus(run.status)) { if (run.status === 'dispatched') { @@ -224,13 +228,17 @@ export class AutomationService { } private async evaluateDueRuns(): Promise { - if (this.evaluating) { + if (this.evaluating || this.stopped) { return } this.evaluating = true + const generation = this.dispatchGeneration try { const now = Date.now() for (const automation of this.store.listAutomations()) { + if (this.stopped || generation !== this.dispatchGeneration) { + break + } if (!automation.enabled || automation.nextRunAt > now) { continue } @@ -239,7 +247,14 @@ export class AutomationService { try { await this.evaluateAutomation(automation, now) } catch (error) { - recordUnevaluableAutomation({ runs: this.runs, automation, error }) + if ( + !(error instanceof AutomationDispatchCancelledError) && + !this.stopped && + generation === this.dispatchGeneration && + this.store.listAutomations().some((current) => current.id === automation.id) + ) { + await recordUnevaluableAutomation({ runs: this.runs, automation, error }) + } } } } finally { @@ -248,14 +263,15 @@ export class AutomationService { } private async evaluateAutomation(automation: Automation, now: number): Promise { + const generation = this.dispatchGeneration const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now) if (scheduledFor === null) { - this.store.advanceAutomationNextRun(automation.id, now) + await this.runs.advanceNextRun(automation.id, now) return } if (missedBeyondGrace({ automation, scheduledFor, now, tickMs: this.tickMs })) { - recordMissedRun({ runs: this.runs, automation, scheduledFor }) - this.store.advanceAutomationNextRun(automation.id, now) + await recordMissedRun({ runs: this.runs, automation, scheduledFor }) + await this.runs.advanceNextRun(automation.id, now) return } @@ -263,14 +279,20 @@ export class AutomationService { // */5 automation would otherwise write ~288 identical rows a day — past // retention, which would evict the automation's real history. const target = this.resolveTarget(automation) - const refusal = describeScheduledRefusal({ target, canDispatch: this.canDispatch() }) - if (refusal && this.runs.repeatSkip(automation.id, refusal, scheduledFor)) { - this.store.advanceAutomationNextRun(automation.id, now) + const canDispatch = this.canDispatchToRenderer() || Boolean(this.headlessDispatcher) + const refusal = describeScheduledRefusal({ target, canDispatch }) + if (refusal && (await this.runs.repeatSkip(automation.id, refusal, scheduledFor))) { + await this.runs.advanceNextRun(automation.id, now) return } - await this.requestDispatch(automation, this.runs.createRun(automation, scheduledFor), target) - this.store.advanceAutomationNextRun(automation.id, now) + await this.requestDispatch( + automation, + await this.runs.createRun(automation, scheduledFor), + target, + generation + ) + await this.runs.advanceNextRun(automation.id, now) } private resolveTarget(automation: Automation): AutomationRunTargetResult { @@ -284,55 +306,27 @@ export class AutomationService { return Boolean(webContents && !webContents.isDestroyed() && this.rendererReady) } - /** Headless serve counts: it launches runs with no window at all. */ - private canDispatch(): boolean { - return this.canDispatchToRenderer() || Boolean(this.headlessDispatcher) - } - - private async requestDispatch( + private requestDispatch( automation: Automation, run: AutomationRun, - target: AutomationRunTargetResult + target: AutomationRunTargetResult, + generation: number ): Promise { - if (!target.ok) { - return this.runs.updateRun({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - error: target.error - }) - } - if (!this.canDispatchToRenderer()) { - if (this.headlessDispatcher) { - return await runHeadlessAutomationDispatch({ - automation, - run, - target, - dispatcher: this.headlessDispatcher, - runs: this.runs, - runPrecheck: () => this.runPrecheck(automation.id, run.id), - markDispatchResult: (result) => this.markDispatchResult(result), - watchRun: (dispatched) => this.completionWatcher?.watch(dispatched) - }) - } - return this.runs.updateRun({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - error: NO_DISPATCH_HOST - }) - } - const updated = this.runs.updateRun({ - runId: run.id, - status: 'dispatching', - workspaceId: automation.workspaceId, - error: null - }) - const payload: AutomationDispatchRequest = { + return requestAutomationDispatch( + { + store: this.store, + runs: this.runs, + isActive: () => !this.stopped && generation === this.dispatchGeneration, + getRenderer: () => (this.canDispatchToRenderer() ? this.webContents : null), + headlessDispatcher: this.headlessDispatcher, + resolveTarget: (current) => this.resolveTarget(current), + runPrecheck: () => this.runPrecheck(automation.id, run.id), + markDispatchResult: (result) => this.markDispatchResult(result), + watchRun: (dispatched) => this.completionWatcher?.watch(dispatched) + }, automation, - run: updated, - dispatchToken: createAutomationDispatchToken(automation.id, updated.id) - } - return sendRendererDispatch(this.webContents, payload, this.runs, updated) + run, + target + ) } } diff --git a/src/main/runtime/automation-change-publication.test.ts b/src/main/runtime/automation-change-publication.test.ts index 5cde275d5eaf..f9fc8508cab6 100644 --- a/src/main/runtime/automation-change-publication.test.ts +++ b/src/main/runtime/automation-change-publication.test.ts @@ -132,9 +132,36 @@ afterEach(() => { }) describe('scoped automationsChanged publication', () => { + it.each(['update', 'delete'] as const)( + 'waits for durable %s before publishing success', + async (operation) => { + const { store, runtime, published } = await makeRuntime() + const gate = Promise.withResolvers() + vi.spyOn(store, 'flushPendingOrThrowAsync').mockReturnValue(gate.promise) + const pending = + operation === 'update' + ? runtime.updateAutomation('local-1', { name: 'Changed' }) + : runtime.deleteAutomation('local-1') + await vi.waitFor(() => expect(store.flushPendingOrThrowAsync).toHaveBeenCalledOnce()) + expect(published).toEqual([]) + gate.resolve() + await pending + expect(published).toHaveLength(1) + } + ) + + it('rejects a failed durable definition write without publishing success', async () => { + const { store, runtime, published } = await makeRuntime() + vi.spyOn(store, 'flushPendingOrThrowAsync').mockRejectedValue(new Error('disk full')) + await expect(runtime.updateAutomation('local-1', { name: 'Changed' })).rejects.toThrow( + 'disk full' + ) + expect(published).toEqual([]) + }) + it('names the host a delete removed a row from', async () => { const { runtime, published } = await makeRuntime() - runtime.deleteAutomation('ssh-1-a', { + await runtime.deleteAutomation('ssh-1-a', { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }) expect(published).toEqual([ @@ -144,7 +171,7 @@ describe('scoped automationsChanged publication', () => { it('names the orphan bucket when an unowned row is deleted', async () => { const { runtime, published } = await makeRuntime() - runtime.deleteAutomation('orphan-1', { selector: { kind: 'orphan' } }) + await runtime.deleteAutomation('orphan-1', { selector: { kind: 'orphan' } }) expect(published).toEqual([{ reason: 'definition', selector: { kind: 'orphan' } }]) }) diff --git a/src/main/runtime/orca-runtime-automation-operations.ts b/src/main/runtime/orca-runtime-automation-operations.ts index fe65979ed1e4..662ea04c3da0 100644 --- a/src/main/runtime/orca-runtime-automation-operations.ts +++ b/src/main/runtime/orca-runtime-automation-operations.ts @@ -120,12 +120,13 @@ export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForeg deleteAutomation( id: string, expectedOwner?: AutomationOwnerPrecondition - ): { removed: boolean; id: string } { + ): Promise<{ removed: boolean; id: string }> { return this.automation.withExternalProbePriority(() => { const selector = this.automationChangeSelector(id) - const result = this.automation.delete(id, expectedOwner as never) - this.publishAutomationDefinitionChange(selector, selector) - return result + return this.automation.delete(id, expectedOwner).then((result) => { + this.publishAutomationDefinitionChange(selector, selector) + return result + }) }) } diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts index 9c2ea907c912..26cbdec3d136 100644 --- a/src/main/runtime/orca-runtime-automations.test.ts +++ b/src/main/runtime/orca-runtime-automations.test.ts @@ -187,7 +187,7 @@ describe('OrcaRuntimeService automation methods', () => { const runtime = new OrcaRuntimeService(store as never) const updated = await runtime.updateAutomation('auto-1', { enabled: false }) - const removed = runtime.deleteAutomation('auto-1') + const removed = await runtime.deleteAutomation('auto-1') expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { enabled: false }, undefined) expect(updated).toMatchObject({ diff --git a/src/main/runtime/runtime-automation-controller.ts b/src/main/runtime/runtime-automation-controller.ts index d3ac55df4387..20d3725c34e1 100644 --- a/src/main/runtime/runtime-automation-controller.ts +++ b/src/main/runtime/runtime-automation-controller.ts @@ -113,7 +113,7 @@ export class RuntimeAutomationController { if (input.reuseSession && target.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } - return this.store.createAutomation( + const automation = this.store.createAutomation( { creationKey: input.creationKey, name: input.name, @@ -138,6 +138,8 @@ export class RuntimeAutomationController { ? { destination: destination ?? input.destination } : undefined ) + await this.store.flushPendingOrThrowAsync?.({ drainToStableGeneration: false }) + return automation } async update( @@ -179,18 +181,21 @@ export class RuntimeAutomationController { if (!targetChanged && patch.reuseSession && current.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } - return this.store.updateAutomation(id, patch, options) + const automation = this.store.updateAutomation(id, patch, options) + await this.store.flushPendingOrThrowAsync?.({ drainToStableGeneration: false }) + return automation } - delete( + async delete( id: string, expectedOwner?: AutomationOwnerPrecondition - ): { removed: boolean; id: string } { + ): Promise<{ removed: boolean; id: string }> { if (!this.store?.deleteAutomation) { throw new Error('runtime_unavailable') } this.show(id) this.store.deleteAutomation(id, expectedOwner ? { expectedOwner } : undefined) + await this.store.flushPendingOrThrowAsync?.({ drainToStableGeneration: false }) return { removed: true, id } } From 9512a92d970e86ec8230466e38787a506c1a4d7f Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 19:53:08 -0700 Subject: [PATCH 22/43] fix: reconcile manual automation requests interrupted before launch --- src/main/automations/run-completion-watcher.test.ts | 8 ++++++++ src/main/automations/run-completion-watcher.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/automations/run-completion-watcher.test.ts b/src/main/automations/run-completion-watcher.test.ts index 2e900634c345..954c3ee13bb1 100644 --- a/src/main/automations/run-completion-watcher.test.ts +++ b/src/main/automations/run-completion-watcher.test.ts @@ -168,6 +168,9 @@ describe('authority-owned automation run completion', () => { it('reconciles stranded runs on startup without claiming completion', async () => { const store = await createStore() const automation = createAutomation(store) + store.updateAutomation(automation.id, { enabled: false }) + const pendingManual = store.createAutomationRun(automation, 3_000, 'manual') + const pendingScheduled = store.createAutomationRun(automation, 4_000, 'scheduled') const dispatched = store.createAutomationRun(automation, 1_000, 'manual') store.updateAutomationRun({ runId: dispatched.id, @@ -199,6 +202,11 @@ describe('authority-owned automation run completion', () => { expect(readRun(store, automation.id, dispatching.id).status).toBe('dispatch_failed') expect(readRun(store, automation.id, dispatched.id).error).toContain('terminal') expect(readRun(store, automation.id, dispatching.id).error).toContain('agent started') + expect(readRun(store, automation.id, pendingManual.id)).toMatchObject({ + status: 'dispatch_failed', + error: 'Orca stopped before this manual run could launch.' + }) + expect(readRun(store, automation.id, pendingScheduled.id).status).toBe('pending') service.stop() vi.useRealTimers() }) diff --git a/src/main/automations/run-completion-watcher.ts b/src/main/automations/run-completion-watcher.ts index 4ac125082fbf..ab53efaee582 100644 --- a/src/main/automations/run-completion-watcher.ts +++ b/src/main/automations/run-completion-watcher.ts @@ -24,6 +24,9 @@ export type AutomationRunTerminalObserver = { /** Truthful reason for a run this authority can no longer observe; never claims completion. */ export function describeStrandedAutomationRun(run: AutomationRun): string { + if (run.status === 'pending') { + return 'Orca stopped before this manual run could launch.' + } if (run.status === 'dispatching') { return 'Orca stopped before this run reported that its agent started.' } @@ -139,7 +142,12 @@ export class AutomationRunCompletionWatcher { * reported ready and still cannot find it. */ reconcileRetainedRuns(runs: readonly AutomationRun[]): void { this.reconciler.reconcile( - runs.filter((run) => run.status === 'dispatched' || run.status === 'dispatching') + runs.filter( + (run) => + run.status === 'dispatched' || + run.status === 'dispatching' || + (run.status === 'pending' && run.trigger === 'manual') + ) ) } From c6dc3153f6ad280d9d500873aabc7161cb0ad6c7 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 20:12:52 -0700 Subject: [PATCH 23/43] fix: keep profile saving active after terminal close refusals --- .../loading-store/primary-state-writes.ts | 1 + .../acknowledged-terminal-tab-retirement.test.ts | 15 +++++++++++++++ ...-build-headless-mobile-session-browser-tabs.ts | 14 +++++++++----- .../orca-runtime-stop-terminals-for-worktree.ts | 7 +++++-- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index 6ce60b9e2c42..672bf8665468 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -70,6 +70,7 @@ export class PrimaryStateWriteOperations { this[primaryStateWriteOperationsContext].runtime.activeViewPreference.flushOrThrow() } + /** Expected refusals return persist: false; thrown callbacks stop saving to protect partial state. */ runDurableMutation(mutate: () => DurableProfileStateMutation): Promise { const { runtime } = this[primaryStateWriteOperationsContext] if (runtime.writesFrozen || runtime.quitFlushStarted || runtime.profileMaintenancePending) { diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts index 545b8bf53201..179dba29bd18 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts @@ -8,6 +8,7 @@ import { createAcknowledgedTabRetirementFixture } from './acknowledged-terminal-tab-retirement-fixture' import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { delegatedMobileSessionTabClose } from './mobile-session-tab-close-outcome' const fixtures: ReturnType[] = [] afterEach(async () => { @@ -133,6 +134,20 @@ it('rechecks current pins after renderer acknowledgement', async () => { f.acknowledgement.resolve() await expect(pending).rejects.toThrow('terminal_tab_pinned') expect(f.hasTab()).toBe(true) + await expect(f.store.flushPendingOrThrowAsync()).resolves.toBeUndefined() +}) + +it('keeps persistence writable when worktree teardown finds remaining terminal rows', async () => { + const f = fixture() + f.store.updateRepo('repo1', { executionHostId: 'ssh:target' }) + f.store.setWorktreeMeta(ACK_WORKTREE, { hostId: 'ssh:target' }) + f.store.setWorkspaceSession(f.store.getWorkspaceSession(), 'ssh:target') + vi.spyOn(f.runtime, 'closeMobileSessionTab').mockResolvedValue(delegatedMobileSessionTabClose()) + await expect(f.runtime.closeTerminalsForWorktree(`id:${ACK_WORKTREE}`)).rejects.toThrow( + 'terminal_close_incomplete' + ) + expect(f.hasTab()).toBe(true) + await expect(f.store.flushPendingOrThrowAsync()).resolves.toBeUndefined() }) it('preserves dormant SSH kill IDs when the acknowledged tab becomes headless', async () => { diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index b17b536191f6..dda3caa7cff8 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -107,24 +107,24 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu throw new Error('workspace_session_unavailable') } const acknowledgeRetirement = this.captureTerminalTabRetirement(worktreeId, parentTabId) - return this.store.runDurableMutation(() => { + const committed = await this.store.runDurableMutation(() => { if (!acknowledgeRetirement().matches) { - throw new Error('terminal_pane_owner_changed') + return { value: new Error('terminal_pane_owner_changed'), persist: false } } const hostId = this.getWorkspaceSessionHostIdForWorktree(worktreeId) const currentSession = this.store.getWorkspaceSession(hostId) if (!currentSession) { - throw new Error('workspace_session_unavailable') + return { value: new Error('workspace_session_unavailable'), persist: false } } const session = cloneWorkspaceSessionState(currentSession) const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId, { force: options.force }) if (result.pinned) { - throw new Error('terminal_tab_pinned') + return { value: new Error('terminal_tab_pinned'), persist: false } } if (!result.closed && !options.allowMissing) { - throw new Error('tab_not_found') + return { value: new Error('tab_not_found'), persist: false } } const persisted = result.closed ? advanceTerminalTopologyRevision(result.session, worktreeId) @@ -148,6 +148,10 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu } } }) + if (committed instanceof Error) { + throw committed + } + return committed } protected persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { diff --git a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts index 422d2d893d73..747384d21032 100644 --- a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts @@ -122,7 +122,7 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso ) { throw new Error('workspace_session_unavailable') } - await this.store.runDurableMutation(() => { + const refusal = await this.store.runDurableMutation(() => { const session = cloneWorkspaceSessionState(this.store.getWorkspaceSession(hostId)) const sleepingAgentSessionsByPaneKey = Object.fromEntries( Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter( @@ -139,7 +139,7 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso (tab) => tab.contentType === 'terminal' ) if (remainingTerminalRows.length > 0 || remainingUnifiedTerminalTabs.length > 0) { - throw new Error('terminal_close_incomplete') + return { value: new Error('terminal_close_incomplete'), persist: false } } const hasChanges = Object.keys(sleepingAgentSessionsByPaneKey).length !== @@ -167,6 +167,9 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso } } }) + if (refusal) { + throw refusal + } } async stopTerminalsForWorktree( From ab7db3bedddff61f022f657dbcf7c6f3cc42854f Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 20:48:42 -0700 Subject: [PATCH 24/43] test: remove compatibility exports after clean profile shutdown --- ...state-terminal-restart-persistence.spec.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/e2e/profile-state-terminal-restart-persistence.spec.ts b/tests/e2e/profile-state-terminal-restart-persistence.spec.ts index 09146e470d1a..656692e9f681 100644 --- a/tests/e2e/profile-state-terminal-restart-persistence.spec.ts +++ b/tests/e2e/profile-state-terminal-restart-persistence.spec.ts @@ -128,15 +128,19 @@ test.describe('SQLite candidate terminal restart persistence', () => { expect(existsSync(databasePath)).toBe(true) expect(existsSync(legacyProfileState)).toBe(true) + await session.close(firstApp) + firstApp = null + expect(JSON.parse(readFileSync(legacyProfileState, 'utf8'))).toMatchObject({ + automations: expect.arrayContaining([ + expect.objectContaining({ id: automationLifecycle.automationId, name: automationName }) + ]) + }) // Prove the next launch has only the SQLite authority available. rmSync(legacyProfileState, { force: true }) rmSync(legacyRootState, { force: true }) expect(existsSync(legacyProfileState)).toBe(false) expect(existsSync(legacyRootState)).toBe(false) - await session.close(firstApp) - firstApp = null - const secondLaunch = await session.launch() secondApp = secondLaunch.app await bootstrapRestoredLaunch(secondLaunch.page, worktreeId) @@ -275,15 +279,9 @@ test.describe('SQLite candidate terminal restart persistence', () => { const targetJson = path.join(targetProfileDirectory, 'orca-data.json') expect(existsSync(targetDatabase)).toBe(true) - // Seed an independent target-profile document before removing its JSON mirror. + // Seed an independent target-profile document before switching back. await attachRepoAndOpenTerminal(secondLaunch.page, repoPath) await waitForSessionReady(secondLaunch.page) - rmSync(targetJson, { force: true }) - rmSync(defaultJson, { force: true }) - rmSync(rootJson, { force: true }) - expect(existsSync(targetJson)).toBe(false) - expect(existsSync(defaultJson)).toBe(false) - expect(existsSync(rootJson)).toBe(false) await expect( secondLaunch.page.evaluate( @@ -294,6 +292,11 @@ test.describe('SQLite candidate terminal restart persistence', () => { await waitForElectronProcessExit(secondApp) secondApp = null + // Clean maintenance refreshes compatibility JSON before releasing the profile. + expect(existsSync(targetJson)).toBe(true) + for (const legacyPath of [targetJson, defaultJson, rootJson]) { + rmSync(legacyPath, { force: true }) + } const thirdLaunch = await session.launch() thirdApp = thirdLaunch.app await waitForSessionReady(thirdLaunch.page) @@ -446,6 +449,8 @@ test.describe('SQLite candidate terminal restart persistence', () => { await waitForElectronProcessExit(secondApp) secondApp = null + // Switching away refreshes the target export; remove it before the SQL-only source launch. + rmSync(targetJson, { force: true }) const sourceLaunch = await session.launch() thirdApp = sourceLaunch.app await waitForSessionReady(sourceLaunch.page) From c277cdaafbf6ceadc4ae2d71496600d68d43209e Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 20:53:57 -0700 Subject: [PATCH 25/43] test: observe SQLite authority in restart and startup journeys --- ...terminal-parked-scrollback-restart.spec.ts | 52 ++++++++----------- tests/e2e/plugin-startup-budget.spec.ts | 39 +++++++------- 2 files changed, 42 insertions(+), 49 deletions(-) diff --git a/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts b/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts index 5d83bef49f46..608c31111af2 100644 --- a/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts +++ b/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts @@ -21,18 +21,18 @@ * the remote host's partition never received the capture (#21295). This is the test that fails * with the fix reverted. * - * The on-disk reader walks the local `workspaceSession` AND every `workspaceSessionsByHostId` + * The SQLite reader walks the local `workspaceSession` AND every `workspaceSessionsByHostId` * partition, and names the partition each reading came from — the issue's original "onDisk: 0" was a * reader that inspected only the local blob while the capture sat in the runtime partition, a - * reading that could not contradict itself. An empty list means no session file at all (a deleted - * profile), distinguished from an empty buffer. + * reading that could not contradict itself. A missing database fails the read; an empty list means + * no session partitions were persisted, distinguished from an empty buffer. * * Run: * pnpm exec playwright test \ * tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts \ * --config tests/playwright.config.ts --project electron-headless --workers=1 */ -import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' import os from 'node:os' import path from 'node:path' @@ -49,10 +49,12 @@ import { callEnvironment, createPairedHostTerminal, openPairedClientTab, - waitForPairedPaneMarker + waitForPairedPaneMarker, + type PairedHostTerminal } from './helpers/paired-host-terminal' import { focusActiveTerminalInput } from './helpers/terminal' import { waitForTabParked } from './helpers/terminal-hidden-parking' +import { readPersistedProfileState } from './helpers/persisted-profile-state' const PARK_DELAY_MS = 2_000 const PAINT_BUDGET_MS = 30_000 @@ -137,31 +139,21 @@ function stringRecord(value: unknown): Record | undefined { ) } -/** Walks the local `workspaceSession` and every `workspaceSessionsByHostId` partition. An empty - * list means no session file was found at all — a reader problem, not an empty buffer. */ +/** Read committed session partitions without consulting the retained compatibility export. */ function readOnDiskPartitions(userDataDir: string, webTabId: string): OnDiskPartitionReading[] { + const state = readPersistedProfileState(userDataDir) const readings: OnDiskPartitionReading[] = [] - for (const file of globSync(path.join(userDataDir, '**', 'orca-data.json'))) { - try { - const parsed: unknown = JSON.parse(readFileSync(file, 'utf8')) - if (!isRecord(parsed)) { - continue - } - const local = readSessionPartition('local', parsed.workspaceSession, webTabId) - if (local) { - readings.push(local) - } - const partitions = isRecord(parsed.workspaceSessionsByHostId) - ? parsed.workspaceSessionsByHostId - : {} - for (const [hostId, session] of Object.entries(partitions)) { - const reading = readSessionPartition(hostId, session, webTabId) - if (reading) { - readings.push(reading) - } - } - } catch { - // A partially written profile is itself a datapoint; keep scanning the rest. + const local = readSessionPartition('local', state.workspaceSession, webTabId) + if (local) { + readings.push(local) + } + const partitions = isRecord(state.workspaceSessionsByHostId) + ? state.workspaceSessionsByHostId + : {} + for (const [hostId, session] of Object.entries(partitions)) { + const reading = readSessionPartition(hostId, session, webTabId) + if (reading) { + readings.push(reading) } } return readings @@ -258,7 +250,7 @@ async function parkRemoteTerminalWithToken( fixtureCommand() ) createdTerminals.push(target.terminal) - const decoys = [] + const decoys: PairedHostTerminal[] = [] for (let index = 0; index < 2; index += 1) { const decoy = await createPairedHostTerminal( client.page, @@ -428,7 +420,7 @@ test.describe('host retains nothing', () => { expect({ tokenBeforePark: parked.tokenBeforePark, capturedAtPark: parked.storeAtPark > 0, - // Distinguishes a deleted profile (no partitions) from an empty buffer. + // Distinguishes missing session partitions from an empty buffer. profileSurvived: onDiskAfterQuit.length > 0, runtimePartitionHoldsCapture: runtimePartitionBufferLength(onDiskAfterQuit) > 0, localPartitionDidNotKeepCapture: localPartitionBufferLength(onDiskAfterQuit) <= 0 diff --git a/tests/e2e/plugin-startup-budget.spec.ts b/tests/e2e/plugin-startup-budget.spec.ts index 8f03d03d3e73..369a0c6e24e0 100644 --- a/tests/e2e/plugin-startup-budget.spec.ts +++ b/tests/e2e/plugin-startup-budget.spec.ts @@ -126,27 +126,28 @@ function median(values: readonly number[]): number { // oxlint-disable-next-line no-empty-pattern -- Playwright passes fixtures before testInfo. test('keeps real Electron launch stable with 20 approved inert plugins', async ({}, testInfo) => { test.setTimeout(240_000) - const session = createRestartSession(testInfo, { ORCA_STARTUP_DIAGNOSTICS: '1' }) const baseline: StartupSample[] = [] const populated: StartupSample[] = [] - let markerPaths: string[] = [] - try { - for (let sample = 0; sample < SAMPLE_COUNT; sample += 1) { - seedPlugins(session.userDataDir, 0) - baseline.push(await launchSample(session, 0, testInfo)) - markerPaths = seedPlugins(session.userDataDir, PLUGIN_COUNT) - populated.push(await launchSample(session, PLUGIN_COUNT, testInfo)) + for (let sample = 0; sample < SAMPLE_COUNT; sample += 1) { + for (const count of [0, PLUGIN_COUNT]) { + // Seed each profile before its first migration into authoritative SQLite state. + const session = createRestartSession(testInfo, { ORCA_STARTUP_DIAGNOSTICS: '1' }) + try { + const markerPaths = seedPlugins(session.userDataDir, count) + const samples = count === 0 ? baseline : populated + samples.push(await launchSample(session, count, testInfo)) + expect(markerPaths.every((markerPath) => !existsSync(markerPath))).toBe(true) + } finally { + await session.dispose() + } } - - // The isolated 20-sample unit gate owns the ≤50 ms P95. This app-level - // complement measures the user-visible launch delta because background - // discovery completion overlaps unrelated main-process startup work. - expect(populated.every((sample) => Number.isFinite(sample.pluginDurationMs))).toBe(true) - expect(median(populated.map((sample) => sample.readyToShowMs))).toBeLessThanOrEqual( - median(baseline.map((sample) => sample.readyToShowMs)) + 50 - ) - expect(markerPaths.every((markerPath) => !existsSync(markerPath))).toBe(true) - } finally { - await session.dispose() } + + // The isolated 20-sample unit gate owns the ≤50 ms P95. This app-level + // complement measures the user-visible launch delta because background + // discovery completion overlaps unrelated main-process startup work. + expect(populated.every((sample) => Number.isFinite(sample.pluginDurationMs))).toBe(true) + expect(median(populated.map((sample) => sample.readyToShowMs))).toBeLessThanOrEqual( + median(baseline.map((sample) => sample.readyToShowMs)) + 50 + ) }) From d40b7b87b0919e440419f44f39b740fae200c905 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 21:54:46 -0700 Subject: [PATCH 26/43] Coalesce queued profile durability snapshots --- .../primary-state-write-context.ts | 4 + .../loading-store/primary-state-writes.ts | 41 ++++- .../profile-state-worker-coordination.test.ts | 7 + .../profile-state-write-batching.test.ts | 163 ++++++++++++++++++ 4 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 src/main/persistence/loading-store/profile-state-write-batching.test.ts diff --git a/src/main/persistence/loading-store/primary-state-write-context.ts b/src/main/persistence/loading-store/primary-state-write-context.ts index 6a4ba6a738ba..9d0a27458ff6 100644 --- a/src/main/persistence/loading-store/primary-state-write-context.ts +++ b/src/main/persistence/loading-store/primary-state-write-context.ts @@ -6,4 +6,8 @@ export type PrimaryStateWriteOperationsContext = { runtime: PrimaryStateWriteOperationsRuntime serialization: StateSerializationSecretHandlingOperations backups: BackupRecoveryRotationOperations + queuedSnapshot?: { + completion: Promise + capture: { skipIfClean: boolean; pendingSnapshotFileWork: Promise | null } + } } diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index 672bf8665468..8c3b9705d758 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -164,14 +164,34 @@ export function enqueueWrite( owner: PrimaryStateWriteOperations, options: { fullCheckpoint?: boolean; skipIfClean?: boolean; signal?: AbortSignal } = {} ): Promise { - return enqueuePrimaryStateOperation(owner, async () => { - const { runtime } = owner[primaryStateWriteOperationsContext] + const context = owner[primaryStateWriteOperationsContext] + const { runtime } = context + const batchable = + runtime.profileStateAuthority?.asynchronous && !options.fullCheckpoint && !options.signal + const queued = context.queuedSnapshot + if (batchable && queued) { + queued.capture.skipIfClean &&= options.skipIfClean === true + queued.capture.pendingSnapshotFileWork = runtime.pendingSnapshotFileWork + return queued.completion + } + const capture = { + skipIfClean: options.skipIfClean === true, + pendingSnapshotFileWork: runtime.pendingSnapshotFileWork + } + const completion = enqueuePrimaryStateOperation(owner, async () => { + // Later flushes must capture edits made after this batch starts, even without a new generation. + if (context.queuedSnapshot?.capture === capture) { + context.queuedSnapshot = undefined + } + if (batchable) { + await capture.pendingSnapshotFileWork + } const { signal } = options if (signal?.aborted) { throw new Error('Persistence flush aborted') } if ( - options.skipIfClean && + capture.skipIfClean && runtime.dirtyProfileStateDomains?.size === 0 && runtime.pendingAutomationRunsAfter === undefined && runtime.lastDurableWriteGeneration >= runtime.writeGeneration @@ -199,18 +219,29 @@ export function enqueueWrite( signal?.removeEventListener('abort', abort) } }) + if (batchable) { + context.queuedSnapshot = { completion, capture } + } + return completion } export function enqueuePrimaryStateOperation( owner: PrimaryStateWriteOperations, operation: () => Promise ): Promise { - const { runtime } = owner[primaryStateWriteOperationsContext] + const context = owner[primaryStateWriteOperationsContext] + const { runtime } = context + // A durable mutation, export, or independent checkpoint separates adjacent snapshot batches. + context.queuedSnapshot = undefined const previousWrite = Promise.all([ runtime.pendingWrite ?? runtime.staleTempCleanup, runtime.pendingSnapshotFileWork ?? Promise.resolve() ]).then(() => {}) - const write = previousWrite.then(operation) + const write = previousWrite.then(operation).finally(() => { + if (context.queuedSnapshot?.completion === write) { + context.queuedSnapshot = undefined + } + }) const trackedWrite = write .then(() => {}) .catch((err) => { diff --git a/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts index 1e17948cd6aa..49ecfcfeaa4d 100644 --- a/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts +++ b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' import { fixture } from './profile-state-delayed-authority-fixture' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({ nth_repo_added: 2 }) @@ -178,6 +179,10 @@ describe('worker-owned Store writes', () => { it('retains dirty intent while many durability waiters share an active snapshot', async () => { const { store, authority, readState } = await fixture() + const fullCapture = vi.spyOn( + StateSerializationSecretHandlingOperations.prototype, + 'buildStateToSave' + ) const gate = authority.pause() store.updateSettings({ terminalFontSize: 12 }) const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) @@ -192,6 +197,8 @@ describe('worker-owned Store writes', () => { gate.finish.resolve() await Promise.all(waiters) expect(readState().settings.terminalFontSize).toBe(32) + expect(fullCapture).not.toHaveBeenCalled() + expect(authority.captures).toHaveLength(2) }) it('cancels a queued checkpoint without aborting the preceding writer or losing edits', async () => { diff --git a/src/main/persistence/loading-store/profile-state-write-batching.test.ts b/src/main/persistence/loading-store/profile-state-write-batching.test.ts new file mode 100644 index 000000000000..890706255a62 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-write-batching.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from 'vitest' +import { deferred, fixture } from './profile-state-delayed-authority-fixture' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import * as composition from './store-domain-composition' +import type { StoreRuntimeState } from './store-runtime-state' +import { ProfileStateRevisionConflictError } from '../profile-state/profile-state-document-validation' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +async function snapshotFixture() { + let captured: StoreRuntimeState | undefined + const createDomains = composition.createStoreDomains + vi.spyOn(composition, 'createStoreDomains').mockImplementationOnce((runtime) => { + captured = runtime + return createDomains(runtime) + }) + const state = await fixture() + if (!captured) { + throw new Error('Store runtime was not initialized') + } + return { ...state, runtime: captured } +} + +describe('queued worker snapshot batching', () => { + it('captures getter-only edits in a flush requested after the preceding capture started', async () => { + const { store, authority, readState } = await fixture() + const fullCapture = vi.spyOn( + StateSerializationSecretHandlingOperations.prototype, + 'buildStateToSave' + ) + const firstGate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await firstGate.started.promise + store.getWorkspaceSession().activeTabId = 'getter-only-edit' + const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + firstGate.finish.resolve() + await Promise.all([first, second]) + expect(readState().workspaceSession.activeTabId).toBe('getter-only-edit') + expect(fullCapture).toHaveBeenCalledOnce() + }) + + it('upgrades a queued debounce to capture an explicit getter-only flush', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + vi.useFakeTimers() + try { + const first = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + await vi.advanceTimersByTimeAsync(1_000) + store.getWorkspaceSession().activeTabId = 'explicit-edit' + const explicit = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + gate.finish.resolve() + await Promise.all([first, explicit]) + expect(readState().workspaceSession.activeTabId).toBe('explicit-edit') + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('keeps a later flush ordered after an intervening durable mutation', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const before = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + const after = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const verifyAfter = after.then(() => expect(readState().settings.theme).toBe('light')) + gate.finish.resolve() + await Promise.all([first, before, mutation, verifyAfter]) + }) + + it('shares a queued write failure with its waiters and allows a fresh retry', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ theme: 'light' }) + const failed = [ + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ].map((waiter) => expect(waiter).rejects.toThrow('disk refused')) + vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('disk refused')) + gate.finish.resolve() + await Promise.all([first, ...failed]) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + expect(readState().settings.theme).toBe('light') + }) + + it('checks the disk revision for a clean batch and rejects every waiter on conflict', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const conflict = new ProfileStateRevisionConflictError(1, 2) + const revisionCheck = vi + .spyOn(authority, 'assertCurrentRevision') + .mockRejectedValueOnce(conflict) + const waiters = [ + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ] + await Promise.all(waiters.map((waiter) => expect(waiter).rejects.toBe(conflict))) + expect(revisionCheck).toHaveBeenCalledOnce() + }) + + it('waits for snapshot files admitted by a later member of the queued batch', async () => { + const { store, authority, readState, runtime } = await snapshotFixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const snapshot = deferred() + runtime.pendingSnapshotFileWork = snapshot.promise + store.updateSettings({ theme: 'light' }) + const third = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + try { + gate.finish.resolve() + await first + await new Promise((resolve) => setImmediate(resolve)) + expect(authority.captures).toHaveLength(1) + } finally { + snapshot.resolve() + runtime.pendingSnapshotFileWork = null + await Promise.all([second, third]) + } + expect(readState().settings.theme).toBe('light') + }) + + it('discards a queued batch when its predecessor dependency rejects', async () => { + const { store, readState, runtime } = await snapshotFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + runtime.pendingSnapshotFileWork = Promise.reject(new Error('snapshot preparation failed')) + const waiters = [ + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ] + await Promise.all( + waiters.map((waiter) => expect(waiter).rejects.toThrow('snapshot preparation failed')) + ) + runtime.pendingSnapshotFileWork = null + store.updateSettings({ theme: 'light' }) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + expect(readState().settings.theme).toBe('light') + }) +}) From 4248feede7af66d77d798e89f1e63a07ec2082e5 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 21:57:02 -0700 Subject: [PATCH 27/43] Keep profile writer usable after export preparation failures --- ...le-state-maintenance-compatibility.test.ts | 51 ++++++++++++++++++- .../profile-state-authority-exports.ts | 43 ++++++++++++---- ...-state-json-compatibility-recovery.test.ts | 23 ++++++--- .../profile-state-writer-worker-entry.ts | 10 ++-- 4 files changed, 105 insertions(+), 22 deletions(-) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts index 94e36fcdcfea..8e356ed06e47 100644 --- a/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts +++ b/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts @@ -1,7 +1,8 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' import { createWorkerMaintenanceFixture } from './profile-state-maintenance-fixture' import { profileStateJsonExportPaths } from '../profile-state/profile-state-export-path' +import { openProfileStateDatabase } from '../profile-state/profile-state-database' vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ @@ -58,4 +59,52 @@ describe('maintenance compatibility checkpoint', () => { await (await store.beginProfileMaintenance()).resume() expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') }) + + it('resumes after the worker cannot read JSON before staging compatibility acceptance', async () => { + const { store, dataFile, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + mkdirSync(dataFile) + store.updateSettings({ theme: 'dark' }) + + await expect(store.beginProfileMaintenance()).rejects.toMatchObject({ + outcome: 'known-failure' + }) + expect(readState().settings.theme).toBe('dark') + rmSync(dataFile, { recursive: true }) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + await (await store.beginProfileMaintenance()).resume() + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') + }) + + it('keeps the writer fenced when promotion fails after publishing canonical JSON', async () => { + const { store, authority, dataFile, databaseFile, profileId, readState } = + await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await authority.writeJsonCompatibilityExportAsync(dataFile) + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + opened.db.exec(` + CREATE TRIGGER reject_acceptance BEFORE INSERT ON profile_state_meta + WHEN NEW.key = 'legacy_json_acceptance' + AND json_type(NEW.value, '$.pending') IS NULL + BEGIN SELECT RAISE(ABORT, 'injected promotion failure'); END + `) + } finally { + opened.db.close() + } + store.updateSettings({ theme: 'dark' }) + + await expect(store.beginProfileMaintenance()).rejects.toMatchObject({ + outcome: 'indeterminate' + }) + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('dark') + expect(readState().settings.theme).toBe('dark') + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) + }) }) diff --git a/src/main/persistence/profile-state/profile-state-authority-exports.ts b/src/main/persistence/profile-state/profile-state-authority-exports.ts index 2db7d2a550f0..1d7aafbe3b2d 100644 --- a/src/main/persistence/profile-state/profile-state-authority-exports.ts +++ b/src/main/persistence/profile-state/profile-state-authority-exports.ts @@ -15,6 +15,17 @@ import type Database from '../../sqlite/sync-database' import { writeVersionedProfileStateExport } from './profile-state-versioned-export' import { ProfileStateRevisionConflictError } from './profile-state-document-validation' +/** Preparation may leave a recovery export, but cannot change SQLite acceptance or canonical JSON. */ +export class ProfileStateExportPreparationError extends Error { + constructor(cause: unknown) { + super( + `Profile state compatibility export preparation failed: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause } + ) + this.name = 'ProfileStateExportPreparationError' + } +} + function readExportSnapshot(db: Database.Database, expectedRevision?: number) { const snapshot = readProfileStateSnapshot(db) if (expectedRevision !== undefined && snapshot.revision !== expectedRevision) { @@ -45,10 +56,15 @@ export function writeProfileStateAuthorityCompatibilityExport( if (snapshot.revision === 0) { return undefined } - writeCompatibilityRecoveryExport(targetPath, snapshot) - const retained = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : undefined + let retained: string | undefined + try { + writeCompatibilityRecoveryExport(targetPath, snapshot) + retained = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : undefined + mkdirSync(dirname(targetPath), { recursive: true }) + } catch (error) { + throw new ProfileStateExportPreparationError(error) + } stageProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision, retained) - mkdirSync(dirname(targetPath), { recursive: true }) writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, snapshot.json) acceptProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision) return snapshot.revision @@ -63,15 +79,20 @@ export async function writeProfileStateAuthorityCompatibilityExportAsync( if (snapshot.revision === 0) { return undefined } - writeCompatibilityRecoveryExport(targetPath, snapshot) - const retained = await readFile(targetPath, 'utf8').catch((error: unknown) => { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return undefined - } - throw error - }) + let retained: string | undefined + try { + writeCompatibilityRecoveryExport(targetPath, snapshot) + retained = await readFile(targetPath, 'utf8').catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + }) + await mkdir(dirname(targetPath), { recursive: true }) + } catch (error) { + throw new ProfileStateExportPreparationError(error) + } stageProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision, retained) - await mkdir(dirname(targetPath), { recursive: true }) await writeFileDurable(durableWriteTempPath(targetPath), targetPath, snapshot.json) acceptProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision) return snapshot.revision diff --git a/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts b/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts index bdef12726518..1d1502009a8a 100644 --- a/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts +++ b/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts @@ -187,13 +187,24 @@ describe.each(['sync', 'async'] as const)('%s compatibility export recovery', (m it('refuses an unrelated edit even while a previous export remains staged', async () => { const state = fixture() - vi.spyOn( - durableFiles, - mode === 'sync' ? 'writeFileDurableSync' : 'writeFileDurable' - ).mockImplementationOnce(() => { - throw new Error('injected publication failure') - }) + if (mode === 'sync') { + const write = durableFiles.writeFileDurableSync + vi.spyOn(durableFiles, 'writeFileDurableSync').mockImplementation((...args) => { + if (args[1] === state.paths.dataFile) { + throw new Error('injected publication failure') + } + write(...args) + }) + } else { + vi.spyOn(durableFiles, 'writeFileDurable').mockRejectedValueOnce( + new Error('injected publication failure') + ) + } await expect(state.publish(mode)).rejects.toThrow('injected') + expect(state.acceptance()?.pending).toEqual({ + jsonHash: hashProfileStateJson('{"settings":{"theme":"dark"}}'), + acceptedRevision: 2 + }) const unrelatedJson = '{"settings":{"theme":"system"},"unrelatedEdit":true}' writeFileSync(state.paths.dataFile, unrelatedJson) diff --git a/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts b/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts index 1e76982508da..9837ca66a024 100644 --- a/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts +++ b/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts @@ -1,6 +1,7 @@ import { parentPort, workerData } from 'node:worker_threads' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' import { writeVersionedProfileStateExport } from './profile-state-versioned-export' +import { ProfileStateExportPreparationError } from './profile-state-authority-exports' import { encodeProfileStateWriterError, ProfileStateWriterError @@ -117,10 +118,11 @@ async function accept(value: unknown): Promise { } catch (error) { const failure = encodeProfileStateWriterError( error, - value.command === 'export-json' || - value.command === 'export-latest' || - value.command === 'export-compatibility' || - value.command === 'close' + !(error instanceof ProfileStateExportPreparationError) && + (value.command === 'export-json' || + value.command === 'export-latest' || + value.command === 'export-compatibility' || + value.command === 'close') ) reply({ id: value.id, ok: false, error: failure }) stopping ||= failure.outcome === 'indeterminate' From 01ea72aef6a1da24375921e175f1753a630836d1 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 24 Sep 2026 22:37:15 -0700 Subject: [PATCH 28/43] fix: preserve getter edits across coalesced profile flushes --- .../primary-state-write-context.ts | 6 +- .../loading-store/primary-state-writes.ts | 5 +- .../profile-state-worker-coordination.test.ts | 2 +- .../profile-state-write-batching.test.ts | 90 ++++++++++++++++++- 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/main/persistence/loading-store/primary-state-write-context.ts b/src/main/persistence/loading-store/primary-state-write-context.ts index 9d0a27458ff6..24dd5db6beaa 100644 --- a/src/main/persistence/loading-store/primary-state-write-context.ts +++ b/src/main/persistence/loading-store/primary-state-write-context.ts @@ -8,6 +8,10 @@ export type PrimaryStateWriteOperationsContext = { backups: BackupRecoveryRotationOperations queuedSnapshot?: { completion: Promise - capture: { skipIfClean: boolean; pendingSnapshotFileWork: Promise | null } + capture: { + skipIfClean: boolean + fullCheckpoint: boolean + pendingSnapshotFileWork: Promise | null + } } } diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index 8c3b9705d758..ae3e4d3912ac 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -171,11 +171,14 @@ export function enqueueWrite( const queued = context.queuedSnapshot if (batchable && queued) { queued.capture.skipIfClean &&= options.skipIfClean === true + // Merged explicit flushes must retain the full capture that covered untracked getter edits. + queued.capture.fullCheckpoint ||= !queued.capture.skipIfClean queued.capture.pendingSnapshotFileWork = runtime.pendingSnapshotFileWork return queued.completion } const capture = { skipIfClean: options.skipIfClean === true, + fullCheckpoint: options.fullCheckpoint === true, pendingSnapshotFileWork: runtime.pendingSnapshotFileWork } const completion = enqueuePrimaryStateOperation(owner, async () => { @@ -199,7 +202,7 @@ export function enqueueWrite( return } // A queued predecessor can clear dirty domains before this checkpoint runs. - if (options.fullCheckpoint) { + if (capture.fullCheckpoint) { runtime.dirtyProfileStateDomains = null } const authority = runtime.profileStateAuthority diff --git a/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts index 49ecfcfeaa4d..0f1bdd6ff9d4 100644 --- a/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts +++ b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts @@ -197,7 +197,7 @@ describe('worker-owned Store writes', () => { gate.finish.resolve() await Promise.all(waiters) expect(readState().settings.terminalFontSize).toBe(32) - expect(fullCapture).not.toHaveBeenCalled() + expect(fullCapture).toHaveBeenCalledOnce() expect(authority.captures).toHaveLength(2) }) diff --git a/src/main/persistence/loading-store/profile-state-write-batching.test.ts b/src/main/persistence/loading-store/profile-state-write-batching.test.ts index 890706255a62..fb6f44a5d8af 100644 --- a/src/main/persistence/loading-store/profile-state-write-batching.test.ts +++ b/src/main/persistence/loading-store/profile-state-write-batching.test.ts @@ -29,6 +29,92 @@ async function snapshotFixture() { } describe('queued worker snapshot batching', () => { + it.each(['explicit', 'debounce'] as const)( + 'preserves getter-only edits when an explicit flush joins a dirty %s batch', + async (firstFlush) => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + vi.useFakeTimers() + try { + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + const first = + firstFlush === 'explicit' + ? store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + : await vi.advanceTimersByTimeAsync(1_000) + store.getWorkspaceSession().activeTabId = 'getter-only-edit' + store.updateSettings({ theme: 'light' }) + const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + gate.finish.resolve() + await Promise.all([mutation, first, second]) + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'getter-only-edit' } + }) + expect(authority.captures).toHaveLength(2) + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + } + ) + + it('preserves a queued explicit capture when a later debounce joins it', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + vi.useFakeTimers() + try { + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + store.getWorkspaceSession().activeTabId = 'getter-only-edit' + const explicit = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + store.updateSettings({ theme: 'light' }) + await vi.advanceTimersByTimeAsync(1_000) + gate.finish.resolve() + await Promise.all([mutation, explicit]) + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'getter-only-edit' } + }) + expect(authority.captures).toHaveLength(2) + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('keeps batches of only debounced saves selective', async () => { + const { store, authority, readState } = await fixture() + const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const gate = authority.pause() + vi.useFakeTimers() + try { + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + await vi.advanceTimersByTimeAsync(1_000) + store.updateSettings({ theme: 'light' }) + await vi.advanceTimersByTimeAsync(1_000) + gate.finish.resolve() + await mutation + await store.waitForPendingWrite() + expect(readState().settings.theme).toBe('light') + expect(authority.captures).toHaveLength(2) + expect(full).not.toHaveBeenCalled() + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + it('captures getter-only edits in a flush requested after the preceding capture started', async () => { const { store, authority, readState } = await fixture() const fullCapture = vi.spyOn( @@ -98,7 +184,9 @@ describe('queued worker snapshot batching', () => { store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) ].map((waiter) => expect(waiter).rejects.toThrow('disk refused')) - vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('disk refused')) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce( + new Error('disk refused') + ) gate.finish.resolve() await Promise.all([first, ...failed]) await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) From b3948193c6a1c2530b13b9d1773aa529dcf1aed1 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 00:01:29 -0700 Subject: [PATCH 29/43] fix: preserve profile saving through cancellation and recovery failures --- config/tsconfig.cli.json | 2 + .../profile-state-recovery-admission.test.ts | 12 ++ .../orca-profiles-switch-persistence.test.ts | 115 ++++++++++++ src/main/ipc/orca-profiles.ts | 15 +- .../profile-persistence-deadline.ts | 33 ++-- .../profile-project-domain-changes.ts | 4 +- .../profile-project-domain-move-intent.ts | 20 +-- .../profile-project-domain-transfer.test.ts | 25 +++ .../profile-project-move-intent.ts | 165 +++--------------- .../profile-project-move-record.ts | 141 +++++++++++++++ .../loading-store/primary-state-writes.ts | 21 +-- .../profile-state-caller-cancellation.test.ts | 87 +++++++++ ...le-state-maintenance-final-failure.test.ts | 89 ++++++++++ .../profile-state-maintenance.ts | 3 +- .../loading-store/write-flush-barriers.ts | 26 +-- .../profile-state-access-identity.ts | 25 +++ .../profile-state-access-owner.ts | 36 +++- .../profile-state-access.test.ts | 69 +++++++- .../profile-state-authority-bootstrap.ts | 10 +- ...e-state-bootstrap-publication-race.test.ts | 48 +++++ .../profile-state-database-errors.ts | 1 + .../profile-state-database-publication.ts | 38 ++++ .../profile-state/profile-state-database.ts | 7 +- .../profile-state/profile-state-migration.ts | 4 +- .../profile-state-recovery-command.ts | 7 + .../profile-state-startup-authority.test.ts | 9 +- .../profile-state-startup-failure.test.ts | 10 ++ .../profile-state-startup-failure.ts | 18 ++ .../profile-state-store-factory.test.ts | 2 +- ...rowser-process-user-agent-ordering.test.ts | 35 +++- src/main/startup/main-process-preflight.ts | 23 ++- 31 files changed, 873 insertions(+), 227 deletions(-) create mode 100644 src/main/ipc/orca-profiles-switch-persistence.test.ts create mode 100644 src/main/orca-profiles/profile-project-move-record.ts create mode 100644 src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-publication.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index df86f859f851..a86400ccdfd9 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -64,6 +64,8 @@ "../src/main/persistence/profile-state/profile-state-recovery.ts", "../src/main/persistence/profile-state/profile-state-recovery-copy.ts", "../src/main/persistence/profile-state/profile-state-recovery-command.ts", + "../src/main/orca-profiles/profile-project-move-record.ts", + "../src/main/orca-profiles/profile-project-domain-changes.ts", "../src/main/persistence/profile-state/profile-state-active-location.ts", "../src/main/persistence/profile-state/profile-state-access.ts", "../src/main/persistence/profile-state/profile-state-access-owner.ts", diff --git a/src/cli/handlers/profile-state-recovery-admission.test.ts b/src/cli/handlers/profile-state-recovery-admission.test.ts index c6ff1ec1add7..1da200843c96 100644 --- a/src/cli/handlers/profile-state-recovery-admission.test.ts +++ b/src/cli/handlers/profile-state-recovery-admission.test.ts @@ -123,6 +123,18 @@ function state(databasePath: string) { } describe('offline recovery excludes runtime admission', () => { + it('refuses rollback without changing the database when a move journal is unresolved', async () => { + const profile = await fixture() + const before = readFileSync(profile.databasePath) + const intents = join(profile.root, 'profile-move-intents') + mkdirSync(intents) + const intentPath = join(intents, '00000000-0000-0000-0000-000000000001.json') + writeFileSync(intentPath, '{"partial":true}') + await expect(rollback(profile)).rejects.toThrow('pending project move') + expect(readFileSync(profile.databasePath)).toEqual(before) + expect(readFileSync(intentPath, 'utf8')).toBe('{"partial":true}') + }) + it('refuses recovery before any mutation when a runtime has already entered', async () => { const profile = await fixture() const original = readFileSync(profile.databasePath) diff --git a/src/main/ipc/orca-profiles-switch-persistence.test.ts b/src/main/ipc/orca-profiles-switch-persistence.test.ts new file mode 100644 index 000000000000..e948d4054e70 --- /dev/null +++ b/src/main/ipc/orca-profiles-switch-persistence.test.ts @@ -0,0 +1,115 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from '../persistence/loading-store/profile-state-maintenance-fixture' +import { registerOrcaProfileHandlers } from './orca-profiles' + +const { handlers, quit, select } = vi.hoisted(() => ({ + handlers: new Map Promise>(), + quit: vi.fn(), + select: vi.fn() +})) +vi.mock('electron', () => ({ + app: { quit }, + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => Promise) => { + handlers.set(channel, handler) + } + } +})) +vi.mock('../app-relaunch', () => ({ relaunchApp: vi.fn() })) +vi.mock('../orca-profiles/profile-index-store', () => ({ + getOrcaProfileListState: () => ({ activeProfileId: 'source', profiles: [] }), + setActiveOrcaProfile: select, + createLocalOrcaProfile: vi.fn(), + seedNewOrcaProfileTelemetryConsent: vi.fn() +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('plain profile switch persistence', () => { + it('preserves shutdown changes when quit starts during the switch checkpoint', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + store.upsertSshRemotePtyLease({ targetId: 'remote', ptyId: 'pty', state: 'attached' }) + await store.flushPendingOrThrowAsync() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const hold = async (write: () => Promise) => { + started.resolve() + await release.promise + await write() + } + const selective = authority.writeSerializedDomains.bind(authority) + const complete = authority.writeCompleteSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce((domains) => + hold(() => selective(domains)) + ) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce((domains) => + hold(() => complete(domains)) + ) + store.updateSettings({ theme: 'dark' }) + registerOrcaProfileHandlers(store) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const switching = handlers + .get('orcaProfiles:switch')?.( + { sender: { isDestroyed: () => false, send: vi.fn() } }, + { profileId: 'target' } + ) + .catch((error: unknown) => error) + await started.promise + store.markSshRemotePtyLeasesForShutdown('remote', 'detached') + const final = store.flushFinalOrThrowAsync() + release.resolve() + await final + await expect(switching).resolves.toEqual({ status: 'relaunching' }) + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + sshRemotePtyLeases: [expect.objectContaining({ state: 'detached' })] + }) + }) + + it('admits pre-relaunch writes and includes SSH detach in the final source checkpoint', async () => { + const { store, readState, dataFile } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + store.upsertSshRemotePtyLease({ targetId: 'remote', ptyId: 'pty', state: 'attached' }) + await store.flushPendingOrThrowAsync() + const cleanupSaved = vi.fn() + let final: Promise | undefined + quit.mockImplementation(() => { + store.markSshRemotePtyLeasesForShutdown('remote', 'detached') + final = store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + }) + registerOrcaProfileHandlers(store, { + onBeforeRelaunch: async () => { + store.updateSettings({ theme: 'light' }) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + cleanupSaved() + } + }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const switchProfile = handlers.get('orcaProfiles:switch') + expect(switchProfile).toBeDefined() + await switchProfile?.( + { sender: { isDestroyed: () => false, send: vi.fn() } }, + { profileId: 'target' } + ) + await vi.advanceTimersByTimeAsync(150) + expect(final).toBeDefined() + await final + expect(select).toHaveBeenCalledWith('target') + expect(cleanupSaved).toHaveBeenCalledOnce() + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + sshRemotePtyLeases: [expect.objectContaining({ state: 'detached' })] + }) + expect(JSON.parse(readFileSync(dataFile, 'utf8'))).toEqual(readState()) + }) +}) diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index 21e8c0fb5bb9..694841e544e0 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -35,7 +35,10 @@ import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope' import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer' import { transferActiveProfileProject } from '../orca-profiles/profile-active-transfer' import { findOrcaProfileProjectsByPath } from '../orca-profiles/profile-project-presence' -import { flushActiveProfileBeforeFileMutation } from '../orca-profiles/profile-persistence-deadline' +import { + flushActiveProfileBeforeFileMutation, + flushActiveProfileBeforeRelaunch +} from '../orca-profiles/profile-persistence-deadline' import { normalizeExecutionHostId } from '../../shared/execution-host' import { createCloudLinkedOrcaProfile, @@ -200,13 +203,9 @@ export function registerOrcaProfileHandlers( } // Why: the current profile must be persisted before the global index // points startup at the target profile. - const maintenance = await flushActiveProfileBeforeFileMutation(store) - try { - setActiveOrcaProfile(profileId) - } catch (error) { - await maintenance.resume() - throw error - } + // Switching leaves source files intact; relaunch cleanup still needs its live writer. + await flushActiveProfileBeforeRelaunch(store) + setActiveOrcaProfile(profileId) await runBeforeProfileRelaunch(options.onBeforeRelaunch) scheduleProfileRelaunch('profile-switch', event.sender) diff --git a/src/main/orca-profiles/profile-persistence-deadline.ts b/src/main/orca-profiles/profile-persistence-deadline.ts index ba6277162420..73280914e4aa 100644 --- a/src/main/orca-profiles/profile-persistence-deadline.ts +++ b/src/main/orca-profiles/profile-persistence-deadline.ts @@ -8,6 +8,26 @@ export async function flushActiveProfileBeforeFileMutation( store: Pick, options: Pick = {} ): Promise { + return withinProfilePersistenceDeadline((signal) => + store.beginProfileMaintenance({ ...options, signal }).then(async (handle) => { + if (signal.aborted && options.flush !== false) { + await handle.resume() + throw new Error('orca_profile_persistence_timeout') + } + return handle + }) + ) +} + +export function flushActiveProfileBeforeRelaunch( + store: Pick +): Promise { + return withinProfilePersistenceDeadline((signal) => store.flushPendingOrThrowAsync({ signal })) +} + +async function withinProfilePersistenceDeadline( + operation: (signal: AbortSignal) => Promise +): Promise { const controller = new AbortController() let timeout: ReturnType | null = null const deadline = new Promise((_resolve, reject) => { @@ -17,18 +37,7 @@ export async function flushActiveProfileBeforeFileMutation( }, PROFILE_PERSISTENCE_TIMEOUT_MS) }) try { - return await Promise.race([ - store - .beginProfileMaintenance({ ...options, signal: controller.signal }) - .then(async (handle) => { - if (controller.signal.aborted && options.flush !== false) { - await handle.resume() - throw new Error('orca_profile_persistence_timeout') - } - return handle - }), - deadline - ]) + return await Promise.race([operation(controller.signal), deadline]) } finally { if (timeout) { clearTimeout(timeout) diff --git a/src/main/orca-profiles/profile-project-domain-changes.ts b/src/main/orca-profiles/profile-project-domain-changes.ts index 6022649d1532..30f5aef805e7 100644 --- a/src/main/orca-profiles/profile-project-domain-changes.ts +++ b/src/main/orca-profiles/profile-project-domain-changes.ts @@ -5,7 +5,7 @@ import { } from '../persistence/profile-state/profile-state-document-validation' import { prepareProfileStateDomainMutation } from '../persistence/profile-state/profile-state-domain-write-validation' import type { ProfileStateDomainMutation } from '../persistence/profile-state/profile-state-domain-writes' -import type { TransferProfileState } from './profile-project-state-file' +import type { PersistedState } from '../../shared/persisted-state-types' export type ProfileProjectDomainDigest = { domain: string; hash: string } @@ -33,7 +33,7 @@ export function profileProjectDomainDigests( export function prepareProfileProjectDomainChanges( revision: number, documents: readonly ProfileStateParsedDocument[], - state: TransferProfileState + state: PersistedState ): ProfileProjectDomainChanges { const originals = new Map(documents.map((document) => [document.domain, document])) const replacements: ProfileProjectDomainChanges['replacements'] = [] diff --git a/src/main/orca-profiles/profile-project-domain-move-intent.ts b/src/main/orca-profiles/profile-project-domain-move-intent.ts index cbc07cbd3370..3441b2d8f3a3 100644 --- a/src/main/orca-profiles/profile-project-domain-move-intent.ts +++ b/src/main/orca-profiles/profile-project-domain-move-intent.ts @@ -1,9 +1,9 @@ import { randomUUID } from 'node:crypto' -import type { ProfileProjectMoveIdentity } from './profile-project-move-intent' +import type { ProfileProjectDomainMoveIntent } from './profile-project-move-record' +export type { ProfileProjectDomainMoveIntent } from './profile-project-move-record' import { profileProjectDomainDigests, profileProjectDomainFingerprint, - validateProfileProjectDomainChanges, type ProfileProjectDomainChanges } from './profile-project-domain-changes' import { @@ -11,12 +11,6 @@ import { type ReadProfileProjectTransferResult } from './profile-project-domain-state' -export type ProfileProjectDomainMoveIntent = ProfileProjectMoveIdentity & { - version: 2 - source: ProfileProjectDomainChanges - target: ProfileProjectDomainChanges -} - export function createProfileProjectDomainMoveIntent(args: { sourceProfileId: string targetProfileId: string @@ -62,13 +56,3 @@ function matches( profileProjectDomainFingerprint(profileProjectDomainDigests(snapshot.documents)) === hash ) } - -export function validateProfileProjectDomainMoveIntent( - value: ProfileProjectMoveIdentity & Record -): asserts value is ProfileProjectDomainMoveIntent { - if (value.version !== 2) { - throw new Error('Profile move intent is malformed') - } - validateProfileProjectDomainChanges(value.source) - validateProfileProjectDomainChanges(value.target) -} diff --git a/src/main/orca-profiles/profile-project-domain-transfer.test.ts b/src/main/orca-profiles/profile-project-domain-transfer.test.ts index e26d78623117..00ceb64455ee 100644 --- a/src/main/orca-profiles/profile-project-domain-transfer.test.ts +++ b/src/main/orca-profiles/profile-project-domain-transfer.test.ts @@ -390,6 +390,31 @@ describe('profile domain transfers', () => { } ) + it('leaves conflicted moves between inactive profiles for those profiles to recover', () => { + const { intent } = preparedMove() + persistProfileProjectMoveIntent(root, intent) + domainState.writeProfileProjectDomainChanges('target', root, intent.target) + withDatabase('source', (db) => + writeProfileStateDomain(db, { + expectedRevision: 1, + domain: 'unrelated', + payload: 'true' + }) + ) + expect(recoverPendingProfileProjectMoves(root, 'third-profile')).toBe(0) + expect(() => recoverPendingProfileProjectMoves(root, 'source')).toThrow(/conflicts/) + expect(readdirSync(join(root, 'profile-move-intents'))).toContain(`${intent.id}.json`) + }) + + it('refuses a malformed move record even when its header names inactive profiles', () => { + const { intent } = preparedMove() + persistProfileProjectMoveIntent(root, intent) + const path = join(root, 'profile-move-intents', `${intent.id}.json`) + writeFileSync(path, JSON.stringify({ ...intent, source: null })) + expect(() => recoverPendingProfileProjectMoves(root, 'third-profile')).toThrow('malformed') + expect(JSON.parse(readFileSync(path, 'utf8')).source).toBeNull() + }) + it('refuses independently hashed malformed unrelated data before a copy writes anything', () => { const before = raw('target').json withDatabase('source', (db) => diff --git a/src/main/orca-profiles/profile-project-move-intent.ts b/src/main/orca-profiles/profile-project-move-intent.ts index 67a33bb3962f..51fd796e5ffb 100644 --- a/src/main/orca-profiles/profile-project-move-intent.ts +++ b/src/main/orca-profiles/profile-project-move-intent.ts @@ -1,59 +1,33 @@ import { randomUUID } from 'node:crypto' -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - renameSync, - rmSync, - writeFileSync -} from 'node:fs' -import { basename, join } from 'node:path' +import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../shared/secure-file' import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents' -import { isRecord } from '../persistence/profile-state/profile-state-document-validation' import { readProfileStateWithRevision, writeSerializedProfileState, type ReadProfileStateResult } from './profile-project-state-file' import { getOrcaProfileMoveIntentDirectory } from './profile-storage-paths' -import { - readProfileProjectDomainMoveState, - validateProfileProjectDomainMoveIntent, - type ProfileProjectDomainMoveIntent -} from './profile-project-domain-move-intent' +import { readProfileProjectDomainMoveState } from './profile-project-domain-move-intent' import { writeProfileProjectDomainChanges } from './profile-project-domain-state' - -const PROFILE_MOVE_INTENT_VERSION = 1 -const INTENT_FILE_PATTERN = /^[0-9a-f-]{36}\.json$/ -const PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ - -export type ProfileProjectMoveIdentity = { - id: string - sourceProfileId: string - targetProfileId: string -} - -type ProfileProjectMoveIntentV1 = ProfileProjectMoveIdentity & { - version: typeof PROFILE_MOVE_INTENT_VERSION - expectedSourceRevision: number - expectedTargetRevision: number - sourceBeforeHash: string - targetBeforeHash: string - sourceAfterHash: string - targetAfterHash: string - sourceAfterJson: string - targetAfterJson: string -} - -export type ProfileProjectMoveIntent = ProfileProjectMoveIntentV1 | ProfileProjectDomainMoveIntent +import { + profileProjectMoveIntentPath, + readPendingProfileProjectMoveIntents, + validateProfileProjectMoveIntent, + type ProfileProjectMoveIntent, + type ProfileProjectMoveIntentV1 +} from './profile-project-move-record' +export { profileHasPendingProjectMove } from './profile-project-move-record' +export type { + ProfileProjectMoveIdentity, + ProfileProjectMoveIntent +} from './profile-project-move-record' export function persistProfileProjectMoveIntent( userDataPath: string, intent: ProfileProjectMoveIntent ): void { - validateIntent(intent) + validateProfileProjectMoveIntent(intent) const directory = getOrcaProfileMoveIntentDirectory(userDataPath) mkdirSync(directory, { recursive: true, mode: 0o700 }) const path = profileProjectMoveIntentPath(userDataPath, intent.id) @@ -69,34 +43,22 @@ export function removeProfileProjectMoveIntent(userDataPath: string, intentId: s bestEffortFsyncDirectorySync(getOrcaProfileMoveIntentDirectory(userDataPath)) } -export function recoverPendingProfileProjectMoves(userDataPath: string): number { - const intents = readPendingProfileProjectMoveIntents(userDataPath) +export function recoverPendingProfileProjectMoves( + userDataPath: string, + profileId?: string +): number { + const intents = readPendingProfileProjectMoveIntents(userDataPath).filter( + (intent) => + profileId === undefined || + intent.sourceProfileId === profileId || + intent.targetProfileId === profileId + ) for (const intent of intents) { recoverProfileProjectMoveIntent(userDataPath, intent) } return intents.length } -export function profileHasPendingProjectMove(profileId: string, userDataPath: string): boolean { - try { - return readPendingProfileProjectMoveIntents(userDataPath).some( - (intent) => intent.sourceProfileId === profileId || intent.targetProfileId === profileId - ) - } catch { - // An unreadable intent cannot rule this profile out as a participant. - return true - } -} - -function readPendingProfileProjectMoveIntents(userDataPath: string): ProfileProjectMoveIntent[] { - const directory = getOrcaProfileMoveIntentDirectory(userDataPath) - return existsSync(directory) - ? readdirSync(directory) - .filter((file) => INTENT_FILE_PATTERN.test(file)) - .map((file) => readProfileProjectMoveIntent(join(directory, file))) - : [] -} - function recoverProfileProjectMoveIntent( userDataPath: string, intent: ProfileProjectMoveIntent @@ -160,80 +122,3 @@ function matches(snapshot: ReadProfileStateResult, revision: number, hash: strin hashProfileStateJson(snapshot.serialized) === hash ) } - -function profileProjectMoveIntentPath(userDataPath: string, intentId: string): string { - if (!/^[0-9a-f-]{36}$/.test(intentId)) { - throw new Error('Invalid profile move intent ID') - } - return join(getOrcaProfileMoveIntentDirectory(userDataPath), `${intentId}.json`) -} - -function readProfileProjectMoveIntent(path: string): ProfileProjectMoveIntent { - let parsed: unknown - try { - parsed = JSON.parse(readFileSync(path, 'utf8')) - } catch (error) { - throw new Error( - `Profile move intent is unreadable: ${path}: ${error instanceof Error ? error.message : String(error)}` - ) - } - if (!isRecord(parsed)) { - throw new Error(`Profile move intent is malformed: ${path}`) - } - validateIntent(parsed) - if (basename(path) !== `${parsed.id}.json`) { - throw new Error(`Profile move intent ID does not match its file: ${path}`) - } - return parsed -} - -function validateIntent(value: unknown): asserts value is ProfileProjectMoveIntent { - validateMoveIdentity(value) - if (value.version === 2) { - validateProfileProjectDomainMoveIntent(value) - return - } - const intent = value - const expectedSourceRevision = intent.expectedSourceRevision - const expectedTargetRevision = intent.expectedTargetRevision - if ( - intent.version !== PROFILE_MOVE_INTENT_VERSION || - !Number.isSafeInteger(expectedSourceRevision) || - !Number.isSafeInteger(expectedTargetRevision) || - typeof expectedSourceRevision !== 'number' || - typeof expectedTargetRevision !== 'number' || - expectedSourceRevision < 0 || - expectedTargetRevision < 0 || - !isHash(intent.sourceBeforeHash) || - !isHash(intent.targetBeforeHash) || - !isHash(intent.sourceAfterHash) || - !isHash(intent.targetAfterHash) || - typeof intent.sourceAfterJson !== 'string' || - typeof intent.targetAfterJson !== 'string' || - hashProfileStateJson(intent.sourceAfterJson) !== intent.sourceAfterHash || - hashProfileStateJson(intent.targetAfterJson) !== intent.targetAfterHash - ) { - throw new Error('Profile move intent is malformed') - } -} - -function validateMoveIdentity( - value: unknown -): asserts value is ProfileProjectMoveIdentity & Record { - if ( - !isRecord(value) || - typeof value.id !== 'string' || - !/^[0-9a-f-]{36}$/.test(value.id) || - typeof value.sourceProfileId !== 'string' || - typeof value.targetProfileId !== 'string' || - !PROFILE_ID_PATTERN.test(value.sourceProfileId) || - !PROFILE_ID_PATTERN.test(value.targetProfileId) || - value.sourceProfileId === value.targetProfileId - ) { - throw new Error('Profile move intent is malformed') - } -} - -function isHash(value: unknown): value is string { - return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) -} diff --git a/src/main/orca-profiles/profile-project-move-record.ts b/src/main/orca-profiles/profile-project-move-record.ts new file mode 100644 index 000000000000..68391cd117df --- /dev/null +++ b/src/main/orca-profiles/profile-project-move-record.ts @@ -0,0 +1,141 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { basename, join } from 'node:path' +import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { isRecord } from '../persistence/profile-state/profile-state-document-validation' +import { getOrcaProfileMoveIntentDirectory } from './profile-storage-paths' +import { + validateProfileProjectDomainChanges, + type ProfileProjectDomainChanges +} from './profile-project-domain-changes' + +const PROFILE_MOVE_INTENT_VERSION = 1 +const INTENT_FILE_PATTERN = /^[0-9a-f-]{36}\.json$/ +const PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ + +export type ProfileProjectMoveIdentity = { + id: string + sourceProfileId: string + targetProfileId: string +} + +export type ProfileProjectMoveIntentV1 = ProfileProjectMoveIdentity & { + version: typeof PROFILE_MOVE_INTENT_VERSION + expectedSourceRevision: number + expectedTargetRevision: number + sourceBeforeHash: string + targetBeforeHash: string + sourceAfterHash: string + targetAfterHash: string + sourceAfterJson: string + targetAfterJson: string +} + +export type ProfileProjectDomainMoveIntent = ProfileProjectMoveIdentity & { + version: 2 + source: ProfileProjectDomainChanges + target: ProfileProjectDomainChanges +} + +export type ProfileProjectMoveIntent = ProfileProjectMoveIntentV1 | ProfileProjectDomainMoveIntent + +export function profileHasPendingProjectMove(profileId: string, userDataPath: string): boolean { + try { + return readPendingProfileProjectMoveIntents(userDataPath).some( + (intent) => intent.sourceProfileId === profileId || intent.targetProfileId === profileId + ) + } catch { + // An unreadable intent cannot rule this profile out as a participant. + return true + } +} + +export function readPendingProfileProjectMoveIntents( + userDataPath: string +): ProfileProjectMoveIntent[] { + const directory = getOrcaProfileMoveIntentDirectory(userDataPath) + return existsSync(directory) + ? readdirSync(directory) + .filter((file) => INTENT_FILE_PATTERN.test(file)) + .map((file) => readProfileProjectMoveIntent(join(directory, file))) + : [] +} + +export function profileProjectMoveIntentPath(userDataPath: string, intentId: string): string { + if (!/^[0-9a-f-]{36}$/.test(intentId)) { + throw new Error('Invalid profile move intent ID') + } + return join(getOrcaProfileMoveIntentDirectory(userDataPath), `${intentId}.json`) +} + +function readProfileProjectMoveIntent(path: string): ProfileProjectMoveIntent { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error( + `Profile move intent is unreadable: ${path}: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (!isRecord(parsed)) { + throw new Error(`Profile move intent is malformed: ${path}`) + } + validateProfileProjectMoveIntent(parsed) + if (basename(path) !== `${parsed.id}.json`) { + throw new Error(`Profile move intent ID does not match its file: ${path}`) + } + return parsed +} + +export function validateProfileProjectMoveIntent( + value: unknown +): asserts value is ProfileProjectMoveIntent { + validateMoveIdentity(value) + if (value.version === 2) { + validateProfileProjectDomainChanges(value.source) + validateProfileProjectDomainChanges(value.target) + return + } + const intent = value + const expectedSourceRevision = intent.expectedSourceRevision + const expectedTargetRevision = intent.expectedTargetRevision + if ( + intent.version !== PROFILE_MOVE_INTENT_VERSION || + !Number.isSafeInteger(expectedSourceRevision) || + !Number.isSafeInteger(expectedTargetRevision) || + typeof expectedSourceRevision !== 'number' || + typeof expectedTargetRevision !== 'number' || + expectedSourceRevision < 0 || + expectedTargetRevision < 0 || + !isHash(intent.sourceBeforeHash) || + !isHash(intent.targetBeforeHash) || + !isHash(intent.sourceAfterHash) || + !isHash(intent.targetAfterHash) || + typeof intent.sourceAfterJson !== 'string' || + typeof intent.targetAfterJson !== 'string' || + hashProfileStateJson(intent.sourceAfterJson) !== intent.sourceAfterHash || + hashProfileStateJson(intent.targetAfterJson) !== intent.targetAfterHash + ) { + throw new Error('Profile move intent is malformed') + } +} + +function validateMoveIdentity( + value: unknown +): asserts value is ProfileProjectMoveIdentity & Record { + if ( + !isRecord(value) || + typeof value.id !== 'string' || + !/^[0-9a-f-]{36}$/.test(value.id) || + typeof value.sourceProfileId !== 'string' || + typeof value.targetProfileId !== 'string' || + !PROFILE_ID_PATTERN.test(value.sourceProfileId) || + !PROFILE_ID_PATTERN.test(value.targetProfileId) || + value.sourceProfileId === value.targetProfileId + ) { + throw new Error('Profile move intent is malformed') + } +} + +function isHash(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) +} diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index ae3e4d3912ac..db1eed63836d 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -1,4 +1,5 @@ import { unlinkSync } from 'node:fs' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { parseCodexResetCreditAttemptLedger, type CodexResetCreditAttemptLedger @@ -205,27 +206,13 @@ export function enqueueWrite( if (capture.fullCheckpoint) { runtime.dirtyProfileStateDomains = null } - const authority = runtime.profileStateAuthority - const abort = () => { - if (authority?.asynchronous) { - void authority - .abort() - .catch((error) => - console.error('[persistence] Failed to stop aborted profile writer:', error) - ) - } - } - signal?.addEventListener('abort', abort, { once: true }) - try { - await writeToDiskAsync(owner) - } finally { - signal?.removeEventListener('abort', abort) - } + await writeToDiskAsync(owner) }) if (batchable) { context.queuedSnapshot = { completion, capture } } - return completion + // A caller may stop waiting; the admitted write must retain its acknowledgement and ordering. + return waitForPromiseWithSignal(completion, options.signal) } export function enqueuePrimaryStateOperation( diff --git a/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts b/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts new file mode 100644 index 000000000000..728c63f6b8d0 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('profile flush caller cancellation', () => { + it('releases a canceled waiter while its admitted write completes and later saving continues', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + await write(domains) + started.resolve() + await release.promise + }) + const abort = vi.spyOn(authority, 'abort') + const controller = new AbortController() + store.updateSettings({ theme: 'dark' }) + const pending = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const rejected = expect(pending).rejects.toThrow('aborted') + let settled = false + void pending.catch(() => { + settled = true + }) + await started.promise + controller.abort() + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(abort).not.toHaveBeenCalled() + expect(settled).toBe(true) + expect(() => authority.assertWritable()).not.toThrow() + } finally { + release.resolve() + await rejected + } + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + }) + + it('keeps an abandoned write ordered before the final checkpoint', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + const controller = new AbortController() + store.updateSettings({ theme: 'dark' }) + const abandoned = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const rejected = expect(abandoned).rejects.toThrow('aborted') + await started.promise + controller.abort() + store.getWorkspaceSession().activeTabId = 'shutdown-edit' + const capture = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const final = store.flushFinalOrThrowAsync() + const result = final.catch((error: unknown) => error) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(capture).not.toHaveBeenCalled() + } finally { + release.resolve() + await rejected + } + await expect(result).resolves.toBeUndefined() + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'shutdown-edit' } + }) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts new file mode 100644 index 000000000000..7b9a5e16fa98 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('quit during failed profile maintenance', () => { + it('retries a known failed checkpoint and persists shutdown edits before closing', async () => { + const { store, authority, readState, dataFile } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.upsertSshRemotePtyLease({ targetId: 'remote', ptyId: 'pty', state: 'attached' }) + await store.flushPendingOrThrowAsync() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const checkpoint = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(async () => { + started.resolve() + await release.promise + throw new Error('SQLITE_BUSY') + }) + store.updateSettings({ theme: 'dark' }) + const failed = expect(store.beginProfileMaintenance()).rejects.toThrow('SQLITE_BUSY') + await started.promise + store.markSshRemotePtyLeasesForShutdown('remote', 'detached') + const final = store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + const result = final.catch((error: unknown) => error) + release.resolve() + await failed + await expect(result).resolves.toBeUndefined() + expect(checkpoint).toHaveBeenCalledTimes(2) + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + sshRemotePtyLeases: [expect.objectContaining({ state: 'detached' })] + }) + expect(JSON.parse(readFileSync(dataFile, 'utf8'))).toEqual(readState()) + }) + + it.each(['indeterminate', 'changed-source'] as const)( + 'keeps %s maintenance fenced during quit', + async (kind) => { + const { store, authority, readState, peer } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const durable = readState() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const failure = + kind === 'indeterminate' + ? new ProfileStateWriterError('test-unknown-commit', 'commit outcome unknown', kind) + : new Error('SQLITE_BUSY') + const checkpoint = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(async () => { + started.resolve() + await release.promise + if (kind === 'changed-source') { + const other = peer() + try { + other.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) + } finally { + other.close() + } + } + throw failure + }) + store.updateSettings({ theme: 'dark' }) + const failed = expect(store.beginProfileMaintenance()).rejects.toBe(failure) + await started.promise + const final = expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) + release.resolve() + await Promise.all([failed, final]) + expect(checkpoint).toHaveBeenCalledOnce() + expect(readState()).toEqual( + kind === 'changed-source' ? { ...durable, peer: { preserved: true } } : durable + ) + } + ) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance.ts b/src/main/persistence/loading-store/profile-state-maintenance.ts index 2d65d50a85a0..111c188821aa 100644 --- a/src/main/persistence/loading-store/profile-state-maintenance.ts +++ b/src/main/persistence/loading-store/profile-state-maintenance.ts @@ -110,7 +110,6 @@ async function canResumeFailedMaintenance( if ( options.flush === false || runtime.writesFrozen || - runtime.quitFlushStarted || profileStateWriterFailureOutcome(error) === 'indeterminate' ) { return false @@ -122,7 +121,7 @@ async function canResumeFailedMaintenance( await (authority?.pauseForMaintenance ? (await authority.pauseForMaintenance()).resume() : authority?.assertCurrentRevision?.()) - return !runtime.quitFlushStarted + return true } catch { return false } diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts index f066f47188a0..10007ded90ae 100644 --- a/src/main/persistence/loading-store/write-flush-barriers.ts +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -85,17 +85,23 @@ export class WriteFlushBarrierOperations { return runtime.quitFlushPromise } runtime.quitFlushStarted = true - const maintenance = runtime.profileMaintenancePending - runtime.quitFlushPromise = ( - maintenance - ? Promise.resolve(runtime.pendingProfileMaintenance) - : drainProfileStateOperations([ - ...runtime.pendingProfileFlushes, - runtime.profileStateAuthority?.drainBackups?.(true) - ]).then(() => flushCurrentStateAsync(this, true)) - ) + runtime.quitFlushPromise = Promise.resolve(runtime.pendingProfileMaintenance) + .catch((error: unknown) => { + // Failed maintenance may re-admit unchanged storage before this final checkpoint. + if (runtime.profileMaintenancePending || runtime.writesFrozen) { + throw error + } + }) .then(async () => { - if (options.exportJsonCompatibility && !maintenance) { + if (runtime.profileMaintenancePending) { + return + } + await drainProfileStateOperations([ + ...runtime.pendingProfileFlushes, + runtime.profileStateAuthority?.drainBackups?.(true) + ]) + await flushCurrentStateAsync(this, true) + if (options.exportJsonCompatibility) { await runtime.profileStateAuthority?.writeJsonCompatibilityExportAsync?.(runtime.dataFile) } }) diff --git a/src/main/persistence/profile-state/profile-state-access-identity.ts b/src/main/persistence/profile-state/profile-state-access-identity.ts index f7d8d898d804..c5c4e51c02a0 100644 --- a/src/main/persistence/profile-state/profile-state-access-identity.ts +++ b/src/main/persistence/profile-state/profile-state-access-identity.ts @@ -6,8 +6,33 @@ import { } from '../../daemon/daemon-process-start-time' let bootIdentity: string | null | undefined +let machineIdentity: string | null | undefined let ownProcessIdentity: string | null | undefined +/** A boot change proves exit only when the record belongs to this machine. */ +export function profileStateAccessMachineIdentity(): string | null { + if (machineIdentity !== undefined) { + return machineIdentity + } + machineIdentity = null + try { + if (process.platform === 'linux') { + machineIdentity = readFileSync('/etc/machine-id', 'utf8').trim() || null + } else if (process.platform === 'darwin') { + const result = runProcessSync({ + program: '/usr/sbin/sysctl', + args: ['-n', 'kern.hostuuid'], + timeoutMs: 1_000, + maxOutputBytes: 1024 + }) + machineIdentity = result.code === 0 ? result.stdout.trim() || null : null + } + } catch { + // Missing machine identity cannot establish ownership across a reboot. + } + return machineIdentity +} + /** A kernel boot UUID survives hostname changes without conflating machines sharing a profile. */ export function profileStateAccessBootIdentity(): string | null { if (bootIdentity !== undefined) { diff --git a/src/main/persistence/profile-state/profile-state-access-owner.ts b/src/main/persistence/profile-state/profile-state-access-owner.ts index 94f30ea43523..ef32cb991d9a 100644 --- a/src/main/persistence/profile-state/profile-state-access-owner.ts +++ b/src/main/persistence/profile-state/profile-state-access-owner.ts @@ -12,9 +12,11 @@ import { writeFileSync } from 'node:fs' import { hostname } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' import { profileStateAccessBootIdentity, + profileStateAccessMachineIdentity, profileStateAccessProcessIdentity } from './profile-state-access-identity' @@ -53,6 +55,7 @@ type AccessOwner = { platform: string pidNamespace: string | null bootIdentity?: string | null + machineIdentity?: string | null processStartIdentity?: string | null } @@ -101,6 +104,10 @@ function readOwner(path: string): AccessOwner | undefined { 'bootIdentity' in owner && typeof owner.bootIdentity === 'string' ? owner.bootIdentity : null, + machineIdentity: + 'machineIdentity' in owner && typeof owner.machineIdentity === 'string' + ? owner.machineIdentity + : null, processStartIdentity: 'processStartIdentity' in owner && typeof owner.processStartIdentity === 'string' && @@ -120,12 +127,20 @@ function readOwner(path: string): AccessOwner | undefined { } function ownerExited(owner: AccessOwner): boolean { - const sameBoot = Boolean( - owner.bootIdentity && owner.bootIdentity === profileStateAccessBootIdentity() - ) - if ((!sameBoot && owner.host !== hostname()) || owner.platform !== process.platform) { + const currentBoot = profileStateAccessBootIdentity() + const currentMachine = profileStateAccessMachineIdentity() + const sameBoot = Boolean(owner.bootIdentity && owner.bootIdentity === currentBoot) + const sameMachine = Boolean(owner.machineIdentity && owner.machineIdentity === currentMachine) + if ( + (!sameBoot && !sameMachine && owner.host !== hostname()) || + owner.platform !== process.platform || + (!sameBoot && owner.machineIdentity && currentMachine && !sameMachine) + ) { return false } + if (sameMachine && owner.bootIdentity && currentBoot && owner.bootIdentity !== currentBoot) { + return true + } // Windows/WSL and Linux PID namespaces cannot establish each other's process absence. if ( process.platform === 'linux' && @@ -224,6 +239,7 @@ export function publishAccessOwner(paths: ProfileStateAccessPaths, exclusive: bo platform: process.platform, pidNamespace: profileStateAccessPidNamespace(), bootIdentity: profileStateAccessBootIdentity(), + machineIdentity: profileStateAccessMachineIdentity(), processStartIdentity: profileStateAccessProcessIdentity(process.pid) }), { @@ -231,6 +247,8 @@ export function publishAccessOwner(paths: ProfileStateAccessPaths, exclusive: bo mode: 0o600 } ) + fsyncFileSync(join(candidate, entry)) + bestEffortFsyncDirectorySync(candidate) for (let attempt = 0; ; attempt += 1) { try { renameSync(candidate, target) @@ -243,6 +261,14 @@ export function publishAccessOwner(paths: ProfileStateAccessPaths, exclusive: bo reclaimExitedOwner(target) } } + bestEffortFsyncDirectorySync(dirname(target)) + bestEffortFsyncDirectorySync(paths.candidates) + } catch (error) { + if (published) { + removeOwnerEntry(join(target, entry)) + removeEmptyOwnerDirectory(target) + } + throw error } finally { if (!published) { removeOwnerEntry(join(candidate, entry)) diff --git a/src/main/persistence/profile-state/profile-state-access.test.ts b/src/main/persistence/profile-state/profile-state-access.test.ts index ff4f76bcc817..e3e9d4c6dc1e 100644 --- a/src/main/persistence/profile-state/profile-state-access.test.ts +++ b/src/main/persistence/profile-state/profile-state-access.test.ts @@ -36,6 +36,40 @@ afterEach(() => { }) describe('profile state admission and maintenance', () => { + it.skipIf(process.platform === 'win32')( + 'releases its published owner when directory sync fails', + () => { + const path = root() + const syncFile = fs.fsyncSync + let syncCount = 0 + const sync = vi.spyOn(fs, 'fsyncSync').mockImplementation((fd) => { + if (++syncCount === 3) { + throw new Error('directory sync failed') + } + syncFile(fd) + }) + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('directory sync failed') + const paths = profileStateAccessPaths(path) + expect(fs.readdirSync(paths.participants)).toEqual([]) + expect(fs.readdirSync(paths.candidates)).toEqual([]) + sync.mockRestore() + acquireProfileStateMaintenance(path).release() + } + ) + + it('does not publish an owner whose contents could not be synced', () => { + const path = root() + const sync = vi.spyOn(fs, 'fsyncSync').mockImplementationOnce(() => { + throw new Error('owner sync failed') + }) + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('owner sync failed') + const paths = profileStateAccessPaths(path) + expect(fs.readdirSync(paths.participants)).toEqual([]) + expect(fs.readdirSync(paths.candidates)).toEqual([]) + sync.mockRestore() + acquireProfileStateMaintenance(path).release() + }) + it('allows concurrent normal writers and refuses maintenance until every admission releases', () => { const path = root() const first = acquireProfileStateRuntimeAdmission(path) @@ -142,7 +176,12 @@ function staleGate( path: string, pid = 12345, host = hostname(), - extra: { bootIdentity?: string; startedAtMs?: number; processStartIdentity?: string } = {} + extra: { + bootIdentity?: string + machineIdentity?: string + startedAtMs?: number + processStartIdentity?: string + } = {} ): string { const gate = profileStateAccessPaths(path).maintenance fs.mkdirSync(gate) @@ -163,6 +202,34 @@ function staleGate( } describe('profile state owner reclamation', () => { + it('reclaims a local owner from a previous boot even when its PID is now live', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('current-boot') + vi.spyOn(identity, 'profileStateAccessMachineIdentity').mockReturnValue('same-machine') + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'previous-boot', + machineIdentity: 'same-machine' + }) + const kill = vi.spyOn(process, 'kill') + acquireProfileStateMaintenance(path).release() + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(false) + }) + + it('keeps a differently booted machine with the same hostname unverifiable', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('current-boot') + vi.spyOn(identity, 'profileStateAccessMachineIdentity').mockReturnValue('this-machine') + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'another-boot', + machineIdentity: 'another-machine' + }) + const kill = vi.spyOn(process, 'kill') + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(true) + }) + it('reclaims a reused PID only when its recorded process start differs on the same boot', () => { const path = root() vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts index 23fddbba2b08..35f722b307fa 100644 --- a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts @@ -1,13 +1,14 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { randomUUID } from 'node:crypto' import { dirname } from 'node:path' -import { publishFileDurableSync } from '../../durable-file-write' +import { publishProfileStateDatabase } from './profile-state-database-publication' import type { ProfileStateAuthorityInitialState, ProfileStateStartupPaneAlias } from '../loading-store/profile-state-authority' import { Store } from '../loading-store/store' import { isProfileStateSqliteAvailable, openProfileStateDatabase } from './profile-state-database' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' import { migrateProfileStateToSqlite } from './profile-state-migration' import { @@ -96,7 +97,10 @@ export function bootstrapProfileStateAuthority( return { classification, authority, initialState, migrated: false } } catch (error) { authority.close() - if (error instanceof ProfileStateAuthorityBootstrapError) { + if ( + error instanceof ProfileStateAuthorityBootstrapError || + (error instanceof ProfileStateDatabaseOpenError && error.code === 'newer-schema') + ) { throw error } throw new ProfileStateRecoveryRequiredError(options, error) @@ -119,7 +123,7 @@ function createEmptyProfileStateDatabase({ ) } assertProfileStateCanInitialize({ dataFile, databaseFile, profileId }) - if (!publishFileDurableSync(temporaryDatabaseFile, databaseFile)) { + if (!publishProfileStateDatabase(temporaryDatabaseFile, databaseFile)) { throw new ProfileStateAuthorityBootstrapError( 'Profile state storage changed while creating an empty database' ) diff --git a/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts b/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts index 87b202c9612b..967822332fde 100644 --- a/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts +++ b/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts @@ -7,6 +7,7 @@ import { migrateProfileStateToSqlite } from './profile-state-migration' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' import { profileStateJsonExportPath } from './profile-state-export-path' import type { ProfileStateAuthority } from '../loading-store/profile-state-authority' +import { formatProfileStateStartupFailure } from './profile-state-startup-failure' vi.mock('node:fs', async (original) => ({ ...(await original()) })) vi.mock('../../telemetry/client', () => ({ track: () => {} })) @@ -31,6 +32,53 @@ afterEach(() => { }) describe('first database publication with competing startup', () => { + it.each(['empty', 'legacy'])( + 'explains unavailable hard links without publishing a partial %s database', + (kind) => { + const root = fs.mkdtempSync(join(tmpdir(), 'orca-bootstrap-no-hardlinks-')) + roots.push(root) + const options = { + dataFile: join(root, 'orca-data.json'), + databaseFile: join(root, 'profile-state.db'), + profileId: 'unsupported-publication', + allowEmptyProfileState: true + } + const json = '{"settings":{"theme":"dark"}}' + if (kind === 'legacy') { + fs.writeFileSync(options.dataFile, json) + } + const link = vi.spyOn(fs, 'linkSync').mockImplementation(() => { + throw Object.assign(new Error('hard links unsupported'), { + code: 'ENOTSUP', + syscall: 'link' + }) + }) + let failure: unknown + try { + bootstrapProfileStateAuthority(options) + } catch (error) { + failure = error + } + expect(failure).toMatchObject({ code: 'profile-state-publication-unavailable' }) + const message = formatProfileStateStartupFailure(failure) + expect(message).toContain('hard links') + expect(message).toContain(root) + expect(message).toContain('complete Orca data directory') + expect(message).not.toContain('rollback') + expect(fs.existsSync(options.databaseFile)).toBe(false) + expect(fs.readdirSync(root)).toEqual(kind === 'legacy' ? ['orca-data.json'] : []) + if (kind === 'legacy') { + expect(fs.readFileSync(options.dataFile, 'utf8')).toBe(json) + } + link.mockRestore() + const retry = bootstrapProfileStateAuthority(options) + expect(retry.authority).toBeDefined() + if (retry.authority) { + authorities.push(retry.authority) + } + } + ) + it('names its migration export after the snapshot actually captured', () => { const root = fs.mkdtempSync(join(tmpdir(), 'orca-migration-export-race-')) roots.push(root) diff --git a/src/main/persistence/profile-state/profile-state-database-errors.ts b/src/main/persistence/profile-state/profile-state-database-errors.ts index 2123147c5a8b..65b58470a5e7 100644 --- a/src/main/persistence/profile-state/profile-state-database-errors.ts +++ b/src/main/persistence/profile-state/profile-state-database-errors.ts @@ -2,6 +2,7 @@ export type ProfileStateDatabaseOpenErrorCode = | 'unreadable' | 'identity-mismatch' | 'invalid-profile-id' + | 'newer-schema' export class ProfileStateDatabaseOpenError extends Error { readonly code: ProfileStateDatabaseOpenErrorCode diff --git a/src/main/persistence/profile-state/profile-state-database-publication.ts b/src/main/persistence/profile-state/profile-state-database-publication.ts new file mode 100644 index 000000000000..eb265b433983 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-publication.ts @@ -0,0 +1,38 @@ +import { dirname } from 'node:path' +import { publishFileDurableSync } from '../../durable-file-write' + +class ProfileStateDatabasePublicationError extends Error { + readonly code = 'profile-state-publication-unavailable' as const + + constructor(databaseFile: string, cause: unknown) { + super( + [ + `Orca could not safely publish profile state in ${dirname(databaseFile)}.`, + 'This location must support hard links, and Orca needs permission to create them.', + 'Close Orca and orcad before checking folder permissions or moving the complete Orca data directory to a writable local filesystem that supports hard links, such as APFS, NTFS, or ext4.', + 'Keep the original directory and all recovery files.' + ].join('\n'), + { cause } + ) + this.name = 'ProfileStateDatabasePublicationError' + } +} + +/** Preserve atomic no-overwrite publication while explaining filesystem refusals. */ +export function publishProfileStateDatabase(stagingFile: string, databaseFile: string): boolean { + try { + return publishFileDurableSync(stagingFile, databaseFile) + } catch (error) { + if ( + error instanceof Error && + 'syscall' in error && + error.syscall === 'link' && + 'code' in error && + typeof error.code === 'string' && + ['ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EPERM', 'EACCES', 'EXDEV'].includes(error.code) + ) { + throw new ProfileStateDatabasePublicationError(databaseFile, error) + } + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-database.ts b/src/main/persistence/profile-state/profile-state-database.ts index 2bb165f64abf..d264d9f27d87 100644 --- a/src/main/persistence/profile-state/profile-state-database.ts +++ b/src/main/persistence/profile-state/profile-state-database.ts @@ -68,7 +68,10 @@ export function openWritableProfileStateDatabase( const opened = openProfileStateDatabase(databasePath, profileId) if (opened.readOnly) { opened.db.close() - throw new Error('Cannot write a future profile state schema') + throw new ProfileStateDatabaseOpenError( + 'newer-schema', + 'This profile requires a newer version of Orca' + ) } return opened } @@ -197,7 +200,7 @@ export function openProfileStateDatabaseReadOnly( verifyProfileStateIntegrity(db) if (storedVersion > PROFILE_STATE_DATABASE_SCHEMA_VERSION) { throw new ProfileStateDatabaseOpenError( - 'unreadable', + 'newer-schema', `Profile state database schema is newer than this runtime: ${storedVersion}` ) } diff --git a/src/main/persistence/profile-state/profile-state-migration.ts b/src/main/persistence/profile-state/profile-state-migration.ts index 05d990d073ba..35b4a8923642 100644 --- a/src/main/persistence/profile-state/profile-state-migration.ts +++ b/src/main/persistence/profile-state/profile-state-migration.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname } from 'node:path' -import { publishFileDurableSync } from '../../durable-file-write' +import { publishProfileStateDatabase } from './profile-state-database-publication' import { openProfileStateDatabase } from './profile-state-database' import { hashProfileStateJson, importProfileStateJson } from './profile-state-documents' import { writeVersionedProfileStateExport } from './profile-state-versioned-export' @@ -47,7 +47,7 @@ export function migrateProfileStateToSqlite(options: ProfileStateMigrationOption // Closing checkpoints the temporary database before its canonical path becomes visible. assertMigrationSourceUnchanged(options) - if (!publishFileDurableSync(temporaryDatabaseFile, options.databaseFile)) { + if (!publishProfileStateDatabase(temporaryDatabaseFile, options.databaseFile)) { throw new Error('Profile state storage changed while importing legacy JSON') } published = true diff --git a/src/main/persistence/profile-state/profile-state-recovery-command.ts b/src/main/persistence/profile-state/profile-state-recovery-command.ts index 381c72673e47..dca8253528ad 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-command.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-command.ts @@ -16,6 +16,7 @@ import { restoreProfileStateDatabaseBackup } from './profile-state-database-reco import type { ProfileStateMaintenance } from './profile-state-access' import { readProfileStateDomain } from './profile-state-domain-reader' import { isRecord } from './profile-state-document-validation' +import { profileHasPendingProjectMove } from '../../orca-profiles/profile-project-move-record' import { invalidateHttp1CompatibilityMarker, writeHttp1CompatibilityMarker @@ -44,6 +45,12 @@ export function rollbackProfileState( maintenance: ProfileStateMaintenance ): ProfileStateRollbackResult { const result = getProfileStateExports(userDataPath) + if (profileHasPendingProjectMove(result.profileId, userDataPath)) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + 'This profile has a pending project move. Resolve the move with both profiles preserved before restoring a single profile.' + ) + } if (selector.kind === 'sqlite') { return restoreDatabaseBackup(userDataPath, result, selector.backupId, maintenance) } diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.test.ts b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts index bce6bf6aa989..2eb64a50d4dd 100644 --- a/src/main/persistence/profile-state/profile-state-startup-authority.test.ts +++ b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts @@ -250,7 +250,14 @@ describe('profile-state startup authority boundary', () => { } } const before = readFileSync(options.databaseFile) - await expect(createProfileStateStoreForStartup(options)).rejects.toThrow() + await expect(createProfileStateStoreForStartup(options)).rejects.toMatchObject({ + code: + kind === 'future-schema' + ? 'newer-schema' + : kind === 'ambiguous' + ? 'ambiguous-profile-state' + : 'profile-state-recovery-required' + }) expect(readFileSync(options.databaseFile)).toEqual(before) if (kind === 'ambiguous') { expect(readFileSync(options.dataFile, 'utf8')).toBe('{"settings":{"theme":"dark"}}') diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.test.ts b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts index 24361cd13b5f..699f084883be 100644 --- a/src/main/persistence/profile-state/profile-state-startup-failure.test.ts +++ b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts @@ -9,8 +9,18 @@ import { } from './profile-state-startup-failure' import { ProfileStateWriterError } from './profile-state-writer-errors' import { ProfileStateRevisionConflictError } from './profile-state-document-validation' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' describe('profile-state startup failure formatting', () => { + it('asks for a newer build instead of rollback when the schema is newer', () => { + const error = new ProfileStateDatabaseOpenError('newer-schema', 'Newer schema: 999') + expect(profileStateStartupFailureClass(error)).toBe('newer-schema') + const message = formatProfileStateStartupFailure(error) + expect(message).toContain('newer version of Orca') + expect(message).not.toContain('rollback') + expect(message).not.toContain('unreadable') + }) + it('prints recovery paths and the offline rollback command', () => { const error = new ProfileStateRecoveryRequiredError( { diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.ts b/src/main/persistence/profile-state/profile-state-startup-failure.ts index 7189e25c4ee6..a4c278bb7e72 100644 --- a/src/main/persistence/profile-state/profile-state-startup-failure.ts +++ b/src/main/persistence/profile-state/profile-state-startup-failure.ts @@ -16,6 +16,8 @@ export type ProfileStateStartupFailureClass = | 'ambiguous-authority' | 'revision-conflict' | 'writer-unavailable' + | 'newer-schema' + | 'publication-unavailable' /** Return the bounded failure class used by startup breadcrumbs and support diagnostics. */ export function profileStateStartupFailureClass( @@ -28,6 +30,12 @@ export function profileStateStartupFailureClass( return 'ambiguous-authority' } if (isRecord(error) && typeof error.code === 'string') { + if (error.code === 'profile-state-publication-unavailable') { + return 'publication-unavailable' + } + if (error.code === 'newer-schema') { + return 'newer-schema' + } if (error.code === 'profile-state-revision-conflict') { return 'revision-conflict' } @@ -73,6 +81,16 @@ export function formatProfileStateStartupFailure(error: unknown): string | undef } const failureClass = profileStateStartupFailureClass(error) + if ( + failureClass === 'publication-unavailable' && + isRecord(error) && + typeof error.message === 'string' + ) { + return error.message + } + if (failureClass === 'newer-schema') { + return 'This profile was saved by a newer version of Orca. Open it with that version or a newer release. Your profile has not been changed.' + } if (failureClass === 'revision-conflict') { return 'The active profile changed while Orca was starting. Close other Orca processes using this profile, then restart Orca.' } diff --git a/src/main/persistence/profile-state/profile-state-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-store-factory.test.ts index 535a700fff37..20af26f1c1c4 100644 --- a/src/main/persistence/profile-state/profile-state-store-factory.test.ts +++ b/src/main/persistence/profile-state/profile-state-store-factory.test.ts @@ -108,7 +108,7 @@ describe('profile state Store authority factory', () => { expect(() => createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) - ).toThrow(ProfileStateRecoveryRequiredError) + ).toThrow(expect.objectContaining({ code: 'newer-schema' })) expect(readFileSync(options.databaseFile)).toEqual(databaseBefore) expect(existsSync(options.dataFile)).toBe(keepJson) if (keepJson) { diff --git a/src/main/startup/browser-process-user-agent-ordering.test.ts b/src/main/startup/browser-process-user-agent-ordering.test.ts index 148781d0ff8f..3665902b45d7 100644 --- a/src/main/startup/browser-process-user-agent-ordering.test.ts +++ b/src/main/startup/browser-process-user-agent-ordering.test.ts @@ -34,6 +34,8 @@ const mocks = vi.hoisted(() => { app, events, userAgent: () => userAgent, + showErrorBox: vi.fn(), + backgroundLaunch: vi.fn(() => true), admission: vi.fn(), lock: vi.fn(() => true), afterIdentity: vi.fn((): void => { @@ -45,10 +47,14 @@ const mocks = vi.hoisted(() => { vi.mock('electron', () => ({ app: mocks.app, + dialog: { showErrorBox: mocks.showErrorBox }, ipcMain: {}, powerMonitor: {}, session: { defaultSession: {} } })) +vi.mock('../window/foreground-activation-policy', () => ({ + isBackgroundLaunch: mocks.backgroundLaunch +})) vi.mock('@electron-toolkit/utils', () => ({ is: { dev: true } })) vi.mock('./cli-launch-redirect', () => ({ maybeRedirectCliLaunch: () => ({ redirected: false, status: 0 }) @@ -157,6 +163,9 @@ vi.mock('../orca-profiles/profile-storage-paths', () => ({ vi.mock('../orca-profiles/profile-project-move-intent', () => ({ recoverPendingProfileProjectMoves: mocks.recoverMoves })) +vi.mock('../persistence/profile-state/profile-state-active-location', () => ({ + getActiveProfileStateLocation: () => ({ profileId: 'active-profile' }) +})) vi.mock('../stats/collector') vi.mock('../claude-usage/store') vi.mock('../codex-usage/store') @@ -190,6 +199,29 @@ vi.mock('../browser/browser-identity-mode-store', () => ({ })) describe('browser process user-agent startup ordering', () => { + it('explains admission refusal before a desktop launch exits', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + mocks.backgroundLaunch.mockReturnValueOnce(false) + mocks.admission.mockImplementationOnce(() => { + throw new Error('Stop Orca and orcad before retrying profile recovery') + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(mocks.showErrorBox).toHaveBeenCalledWith( + 'Orca could not start', + expect.stringContaining('Stop Orca and orcad before retrying profile recovery') + ) + expect(mocks.app.isReady).not.toHaveBeenCalled() + } finally { + error.mockRestore() + mocks.showErrorBox.mockClear() + mocks.events.length = 0 + } + }) + it('does not acquire profile admission for a duplicate launch', async () => { const { runMainProcessPreflight } = await import('./main-process-preflight') mocks.events.length = 0 @@ -256,6 +288,7 @@ describe('browser process user-agent startup ordering', () => { ]) expect(focusExistingWindow).not.toHaveBeenCalled() expect(requestDesktopActivation).not.toHaveBeenCalled() + expect(mocks.showErrorBox).not.toHaveBeenCalled() } finally { error.mockRestore() } @@ -276,7 +309,7 @@ it('exits and releases admission after pending profile move recovery fails', asy expect( runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) ).toBe(false) - expect(mocks.recoverMoves).toHaveBeenCalledWith('/canonical-user-data') + expect(mocks.recoverMoves).toHaveBeenCalledWith('/canonical-user-data', 'active-profile') expect(release).toHaveBeenCalledOnce() expect(mocks.app.exit).toHaveBeenCalledWith(1) }) diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 2b9133af55e0..783dcb402ddf 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -1,4 +1,4 @@ -import { app, ipcMain, powerMonitor, session } from 'electron' +import { app, dialog, ipcMain, powerMonitor, session } from 'electron' import { is } from '@electron-toolkit/utils' import os from 'node:os' import { join } from 'node:path' @@ -93,6 +93,9 @@ import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' import { initializeBrowserProcessUserAgent } from '../browser/browser-process-user-agent' import { initializeBrowserIdentityModeStore } from '../browser/browser-identity-mode-store' import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' +import { isBackgroundLaunch } from '../window/foreground-activation-policy' +import { formatProfileStateStartupFailure } from '../persistence/profile-state/profile-state-startup-failure' +import { getActiveProfileStateLocation } from '../persistence/profile-state/profile-state-active-location' export type MainProcessPreflightOptions = { focusExistingWindow: () => void @@ -105,6 +108,18 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b return initializeMainProcessPreflight(options) } catch (error) { console.error('[startup] Preflight failed:', error) + if (!state.isServeMode && !isBackgroundLaunch()) { + try { + // showErrorBox also works before Electron is ready. + dialog.showErrorBox( + 'Orca could not start', + formatProfileStateStartupFailure(error) ?? + (error instanceof Error ? error.message : String(error)) + ) + } catch (dialogError) { + console.warn('[startup] Could not show startup failure:', dialogError) + } + } try { state.profileStateAdmission?.release() state.profileStateAdmission = undefined @@ -307,7 +322,11 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b initOrcaProfilePaths() // A crash can leave a cross-profile SQLite move between its two commits. Resolve // that journal before any Store opens a profile, so no reader observes a half-move. - recoverPendingProfileProjectMoves(getProfileUserDataPath()) + const profileUserDataPath = getProfileUserDataPath() + recoverPendingProfileProjectMoves( + profileUserDataPath, + getActiveProfileStateLocation(profileUserDataPath)?.profileId + ) // Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28. initStatsPath() initClaudeUsagePath() From b94142b5eb38a282bf89a2bfd9df6d56bc411880 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 00:27:51 -0700 Subject: [PATCH 30/43] fix: keep cloned host identities from reclaiming live profile owners --- .../profile-state-access-identity.test.ts | 30 +++++++++++++++++++ .../profile-state-access-identity.ts | 3 +- .../profile-state-access-owner.ts | 11 +++++-- .../profile-state-access.test.ts | 26 ++++++++++++++-- 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 src/main/persistence/profile-state/profile-state-access-identity.test.ts diff --git a/src/main/persistence/profile-state/profile-state-access-identity.test.ts b/src/main/persistence/profile-state/profile-state-access-identity.test.ts new file mode 100644 index 000000000000..f0e29776c4d0 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-identity.test.ts @@ -0,0 +1,30 @@ +import * as fs from 'node:fs' +import { afterEach, expect, it, vi } from 'vitest' + +vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()) })) + +const platform = Object.getOwnPropertyDescriptor(process, 'platform') + +afterEach(() => { + vi.restoreAllMocks() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } +}) + +it.each([ + ['8de277067b3544d4b65c267d0edab928\n', '8de277067b3544d4b65c267d0edab928'], + ['00000000000000000000000000000000', null], + ['uninitialized\n', null], + ['invalid-machine-id', null], + ['', null] +] as const)('validates the Linux machine identity %j', async (contents, expected) => { + vi.resetModules() + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const read = fs.readFileSync + vi.spyOn(fs, 'readFileSync').mockImplementation((path, options) => + path === '/etc/machine-id' ? contents : read(path, options) + ) + const { profileStateAccessMachineIdentity } = await import('./profile-state-access-identity') + expect(profileStateAccessMachineIdentity()).toBe(expected) +}) diff --git a/src/main/persistence/profile-state/profile-state-access-identity.ts b/src/main/persistence/profile-state/profile-state-access-identity.ts index c5c4e51c02a0..498856c4f5cb 100644 --- a/src/main/persistence/profile-state/profile-state-access-identity.ts +++ b/src/main/persistence/profile-state/profile-state-access-identity.ts @@ -17,7 +17,8 @@ export function profileStateAccessMachineIdentity(): string | null { machineIdentity = null try { if (process.platform === 'linux') { - machineIdentity = readFileSync('/etc/machine-id', 'utf8').trim() || null + const value = readFileSync('/etc/machine-id', 'utf8').trim() + machineIdentity = /^[a-f0-9]{32}$/.test(value) && !/^0+$/.test(value) ? value : null } else if (process.platform === 'darwin') { const result = runProcessSync({ program: '/usr/sbin/sysctl', diff --git a/src/main/persistence/profile-state/profile-state-access-owner.ts b/src/main/persistence/profile-state/profile-state-access-owner.ts index ef32cb991d9a..e8ed7733adbe 100644 --- a/src/main/persistence/profile-state/profile-state-access-owner.ts +++ b/src/main/persistence/profile-state/profile-state-access-owner.ts @@ -131,14 +131,21 @@ function ownerExited(owner: AccessOwner): boolean { const currentMachine = profileStateAccessMachineIdentity() const sameBoot = Boolean(owner.bootIdentity && owner.bootIdentity === currentBoot) const sameMachine = Boolean(owner.machineIdentity && owner.machineIdentity === currentMachine) + const sameHost = owner.host === hostname() if ( - (!sameBoot && !sameMachine && owner.host !== hostname()) || + (!sameBoot && !sameHost) || owner.platform !== process.platform || (!sameBoot && owner.machineIdentity && currentMachine && !sameMachine) ) { return false } - if (sameMachine && owner.bootIdentity && currentBoot && owner.bootIdentity !== currentBoot) { + if ( + sameHost && + sameMachine && + owner.bootIdentity && + currentBoot && + owner.bootIdentity !== currentBoot + ) { return true } // Windows/WSL and Linux PID namespaces cannot establish each other's process absence. diff --git a/src/main/persistence/profile-state/profile-state-access.test.ts b/src/main/persistence/profile-state/profile-state-access.test.ts index e3e9d4c6dc1e..fa114535127f 100644 --- a/src/main/persistence/profile-state/profile-state-access.test.ts +++ b/src/main/persistence/profile-state/profile-state-access.test.ts @@ -9,7 +9,7 @@ import { assertProfileStateMaintenance, type ProfileStateMaintenance } from './profile-state-access' -import { profileStateAccessPaths } from './profile-state-access-owner' +import { profileStateAccessPaths, reclaimExitedOwner } from './profile-state-access-owner' import * as identity from './profile-state-access-identity' import * as processStart from '../../daemon/daemon-process-start-time' @@ -230,6 +230,25 @@ describe('profile state owner reclamation', () => { expect(fs.existsSync(owner)).toBe(true) }) + it.each(['current-boot', null])( + 'does not reclaim another host with a cloned machine identity (current boot: %s)', + (currentBoot) => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue(currentBoot) + vi.spyOn(identity, 'profileStateAccessMachineIdentity').mockReturnValue('cloned-machine') + const owner = staleGate(path, 12345, 'another-host', { + bootIdentity: 'another-boot', + machineIdentity: 'cloned-machine' + }) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('absent on this host'), { code: 'ESRCH' }) + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(true) + } + ) + it('reclaims a reused PID only when its recorded process start differs on the same boot', () => { const path = root() vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') @@ -279,7 +298,10 @@ describe('profile state owner reclamation', () => { processStartIdentity }) clock += 60_000 - expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + // Linux identity emulation must not change the host filesystem's publication/fsync flags. + expect(() => reclaimExitedOwner(profileStateAccessPaths(path).maintenance)).toThrow( + 'unverifiable' + ) expect(fs.existsSync(owner)).toBe(true) expect(wallStart).not.toHaveBeenCalled() } finally { From 59bae52a2be8b9ef84bd872003f0c81497374732 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 00:37:51 -0700 Subject: [PATCH 31/43] test: await durable state in pane restart integration --- src/main/ipc/pty-pane-restart-replace.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index e8e91ea7d453..a1ced7904f8e 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -137,6 +137,7 @@ function installRestartHarness( session = next }), flushOrThrow: vi.fn(), + runDurableMutation: vi.fn(async (mutate: () => { value: T }) => mutate().value), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), From 18f1c412db983b1efdfe77b6896fee681e49172f Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 01:55:54 -0700 Subject: [PATCH 32/43] fix: show Linux startup failures after Electron is ready --- ...rowser-process-user-agent-ordering.test.ts | 147 ++++++++++++++++++ .../startup/main-process-preflight-failure.ts | 41 +++++ src/main/startup/main-process-preflight.ts | 24 +-- 3 files changed, 191 insertions(+), 21 deletions(-) create mode 100644 src/main/startup/main-process-preflight-failure.ts diff --git a/src/main/startup/browser-process-user-agent-ordering.test.ts b/src/main/startup/browser-process-user-agent-ordering.test.ts index 3665902b45d7..ee14f1a8d3e5 100644 --- a/src/main/startup/browser-process-user-agent-ordering.test.ts +++ b/src/main/startup/browser-process-user-agent-ordering.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => { events.push('is-ready') return false }), + whenReady: vi.fn(() => Promise.resolve()), setName: vi.fn((name: string) => { events.push(`set-name:${name}`) }) @@ -201,6 +202,7 @@ vi.mock('../browser/browser-identity-mode-store', () => ({ describe('browser process user-agent startup ordering', () => { it('explains admission refusal before a desktop launch exits', async () => { const { runMainProcessPreflight } = await import('./main-process-preflight') + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') mocks.backgroundLaunch.mockReturnValueOnce(false) mocks.admission.mockImplementationOnce(() => { throw new Error('Stop Orca and orcad before retrying profile recovery') @@ -217,6 +219,7 @@ describe('browser process user-agent startup ordering', () => { expect(mocks.app.isReady).not.toHaveBeenCalled() } finally { error.mockRestore() + platform.mockRestore() mocks.showErrorBox.mockClear() mocks.events.length = 0 } @@ -313,3 +316,147 @@ it('exits and releases admission after pending profile move recovery fails', asy expect(release).toHaveBeenCalledOnce() expect(mocks.app.exit).toHaveBeenCalledWith(1) }) + +it('defers a Linux desktop startup failure until Electron is ready', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + let resolveReady: (() => void) | undefined + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => { + throw new Error('Linux pre-ready failure') + }) + mocks.app.whenReady.mockReturnValueOnce(ready) + mocks.app.exit.mockClear() + mocks.showErrorBox.mockClear() + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + mocks.backgroundLaunch.mockReturnValueOnce(false) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(release).toHaveBeenCalledOnce() + expect(mocks.app.whenReady).toHaveBeenCalledOnce() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + expect(mocks.app.exit).not.toHaveBeenCalled() + + resolveReady?.() + await ready + await Promise.resolve() + expect(mocks.showErrorBox).toHaveBeenCalledWith( + 'Orca could not start', + expect.stringContaining('Linux pre-ready failure') + ) + expect(mocks.app.exit).toHaveBeenCalledWith(1) + } finally { + error.mockRestore() + platform.mockRestore() + mocks.app.whenReady.mockClear() + } +}) + +it('exits after a Linux desktop readiness rejection without showing a dialog', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + let rejectReady!: (error: Error) => void + const ready = new Promise((_resolve, reject) => { + rejectReady = reject + }) + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => { + throw new Error('Linux pre-ready failure') + }) + mocks.app.whenReady.mockReturnValueOnce(ready) + mocks.app.exit.mockClear() + mocks.showErrorBox.mockClear() + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + mocks.backgroundLaunch.mockReturnValueOnce(false) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(release).toHaveBeenCalledOnce() + rejectReady(new Error('Electron readiness failed')) + await ready.catch(() => undefined) + await Promise.resolve() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + expect(mocks.app.exit).toHaveBeenCalledWith(1) + } finally { + error.mockRestore() + platform.mockRestore() + mocks.app.whenReady.mockClear() + } +}) + +it('keeps Linux background startup failures console-only and immediate', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => { + throw new Error('Linux background failure') + }) + mocks.app.whenReady.mockClear() + mocks.app.exit.mockClear() + mocks.showErrorBox.mockClear() + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + mocks.backgroundLaunch.mockReturnValueOnce(true) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(release).toHaveBeenCalledOnce() + expect(mocks.app.whenReady).not.toHaveBeenCalled() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + expect(mocks.app.exit).toHaveBeenCalledWith(1) + } finally { + error.mockRestore() + platform.mockRestore() + } +}) + +it('keeps Linux serve startup failures console-only and immediate', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + const originalArgv = process.argv + process.argv = originalArgv.includes('--serve') ? [...originalArgv] : [...originalArgv, '--serve'] + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => { + throw new Error('Linux serve failure') + }) + mocks.app.whenReady.mockClear() + mocks.app.exit.mockClear() + mocks.showErrorBox.mockClear() + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + mocks.backgroundLaunch.mockReturnValueOnce(false) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(release).toHaveBeenCalledOnce() + expect(mocks.app.whenReady).not.toHaveBeenCalled() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + expect(mocks.app.exit).toHaveBeenCalledWith(1) + } finally { + error.mockRestore() + platform.mockRestore() + process.argv = originalArgv + } +}) diff --git a/src/main/startup/main-process-preflight-failure.ts b/src/main/startup/main-process-preflight-failure.ts new file mode 100644 index 000000000000..0fcd47935cbd --- /dev/null +++ b/src/main/startup/main-process-preflight-failure.ts @@ -0,0 +1,41 @@ +import { app, dialog } from 'electron' +import { formatProfileStateStartupFailure } from '../persistence/profile-state/profile-state-startup-failure' +import { isBackgroundLaunch } from '../window/foreground-activation-policy' +import { mainProcessState as state } from './main-process-state' + +/** Ends a failed preflight without showing a Linux dialog before Electron is ready. */ +export function handleMainProcessPreflightFailure(error: unknown): void { + const message = + formatProfileStateStartupFailure(error) ?? + (error instanceof Error ? error.message : String(error)) + const shouldShowDialog = !state.isServeMode && !isBackgroundLaunch() + state.desktopActivationGate = null + const admission = state.profileStateAdmission + state.profileStateAdmission = undefined + try { + admission?.release() + } catch (releaseError) { + console.warn('[startup] Could not release profile state admission:', releaseError) + } + + const showDialogAndExit = (): void => { + try { + dialog.showErrorBox('Orca could not start', message) + } catch (dialogError) { + console.warn('[startup] Could not show startup failure:', dialogError) + } finally { + app.exit(1) + } + } + if (process.platform === 'linux' && shouldShowDialog) { + try { + void app.whenReady().then(showDialogAndExit, () => app.exit(1)) + } catch { + app.exit(1) + } + } else if (shouldShowDialog) { + showDialogAndExit() + } else { + app.exit(1) + } +} diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 783dcb402ddf..2aa02ece1923 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -1,4 +1,4 @@ -import { app, dialog, ipcMain, powerMonitor, session } from 'electron' +import { app, ipcMain, powerMonitor, session } from 'electron' import { is } from '@electron-toolkit/utils' import os from 'node:os' import { join } from 'node:path' @@ -93,9 +93,8 @@ import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' import { initializeBrowserProcessUserAgent } from '../browser/browser-process-user-agent' import { initializeBrowserIdentityModeStore } from '../browser/browser-identity-mode-store' import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' -import { isBackgroundLaunch } from '../window/foreground-activation-policy' -import { formatProfileStateStartupFailure } from '../persistence/profile-state/profile-state-startup-failure' import { getActiveProfileStateLocation } from '../persistence/profile-state/profile-state-active-location' +import { handleMainProcessPreflightFailure } from './main-process-preflight-failure' export type MainProcessPreflightOptions = { focusExistingWindow: () => void @@ -108,24 +107,7 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b return initializeMainProcessPreflight(options) } catch (error) { console.error('[startup] Preflight failed:', error) - if (!state.isServeMode && !isBackgroundLaunch()) { - try { - // showErrorBox also works before Electron is ready. - dialog.showErrorBox( - 'Orca could not start', - formatProfileStateStartupFailure(error) ?? - (error instanceof Error ? error.message : String(error)) - ) - } catch (dialogError) { - console.warn('[startup] Could not show startup failure:', dialogError) - } - } - try { - state.profileStateAdmission?.release() - state.profileStateAdmission = undefined - } finally { - app.exit(1) - } + handleMainProcessPreflightFailure(error) return false } } From 9325aa1706060244cf5c51676697538de1b957df Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 02:45:11 -0700 Subject: [PATCH 33/43] fix: require live orphan evidence before adopting a terminal --- .../worktree-agent-activation-gate.test.ts | 68 ++++++++++++++++--- .../worktree-agent-live-surface-adoption.ts | 4 +- ...ktree-live-terminal-surface-owners.test.ts | 62 ++++++++++++++--- .../worktree-live-terminal-surface-owners.ts | 36 +++++----- 4 files changed, 132 insertions(+), 38 deletions(-) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index 49d13f87f016..84b498f18f25 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -7,7 +7,10 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/termin import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import type { TerminalSlice } from '@/store/slices/terminals' import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate' -import type { LiveTerminalSurfaceOwnerIndex } from './worktree-live-terminal-surface-owners' +import { + indexLiveTerminalSurfaceOwners, + type LiveTerminalSurfaceOwnerIndex +} from './worktree-live-terminal-surface-owners' const WORKTREE_ID = 'repo::/worktree' const STALE_STRUCTURED_SESSION_ID = 'structured-session-stale' @@ -212,7 +215,10 @@ function seedExistingSurface( describe('worktree agent activation gate', () => { it('uses immediately ready development restore inventory', async () => { const ptyId = `${WORKTREE_ID}@@live-pty` - const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + const { deps, createTab, resume } = testDeps({ + sessions: [listed(ptyId)], + surfaceOwners: new Map([[ptyId, 'unowned']]) + }) const awaitReady = vi.fn(async () => true) await expect( @@ -226,7 +232,10 @@ describe('worktree agent activation gate', () => { it('waits for packaged restore hydration before reading daemon inventory', async () => { const ptyId = `${WORKTREE_ID}@@live-pty` - const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + const { deps, createTab, resume } = testDeps({ + sessions: [listed(ptyId)], + surfaceOwners: new Map([[ptyId, 'unowned']]) + }) let releaseReady!: (ready: boolean) => void const awaitReady = vi.fn(() => new Promise((resolve) => (releaseReady = resolve))) @@ -270,7 +279,10 @@ describe('worktree agent activation gate', () => { it('adopts a live daemon PTY before activation can resume another agent', async () => { const ptyId = `${WORKTREE_ID}@@live-pty` - const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + const { deps, createTab, resume } = testDeps({ + sessions: [listed(ptyId)], + surfaceOwners: new Map([[ptyId, 'unowned']]) + }) await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') @@ -285,7 +297,10 @@ describe('worktree agent activation gate', () => { it('adopts a daemon PTY minted for a folder workspace', async () => { const folderWorkspaceId = 'folder:plain-workspace' const ptyId = `${folderWorkspaceId}@@live-pty` - const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + const { deps, createTab, resume } = testDeps({ + sessions: [listed(ptyId)], + surfaceOwners: new Map([[ptyId, 'unowned']]) + }) await expect(runWorktreeAgentActivationGate(folderWorkspaceId, deps)).resolves.toBe('adopted') @@ -593,7 +608,10 @@ describe('worktree agent activation gate', () => { const unverifiablePtyId = `${WORKTREE_ID}@@ambiguous-agent` const { deps } = testDeps({ sessions: [listed(adoptedPtyId), listed(unverifiablePtyId)], - surfaceOwners: new Map([[unverifiablePtyId, null]]), + surfaceOwners: new Map([ + [adoptedPtyId, 'unowned'], + [unverifiablePtyId, null] + ]), resumeCount: 0 }) @@ -653,14 +671,14 @@ describe('worktree agent activation gate', () => { const livePtyId = `${WORKTREE_ID}@@live-agent` const { deps, createTab } = testDeps({ sessions: [listed(livePtyId)], - surfaceOwners: new Map() + surfaceOwners: new Map([[livePtyId, 'unowned']]) }) const store = deps.getState() seedExistingSurface(store, { tabId: 'tab-live', leafId: LIVE_LEAF_ID }) // The pane mounts while the census is in flight, binding the PTY behind the sweep. deps.listSurfaceOwners.mockImplementation(async () => { store.ptyIdsByTabId['tab-live'] = [livePtyId] - return new Map() + return new Map([[livePtyId, 'unowned']]) }) await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') @@ -668,6 +686,38 @@ describe('worktree agent activation gate', () => { expect(createTab).not.toHaveBeenCalled() }) + it('does not adopt a predecessor after the relay restarts between activation inventories', async () => { + const predecessor = 'ssh:target@@pty2:old-relay:1' + const replacement = 'ssh:target@@pty2:new-relay:1' + const { deps, createTab } = testDeps({ resumeCount: 0 }) + const store = deps.getState() + seedExistingSurface(store, { + tabId: 'tab-live', + leafId: LIVE_LEAF_ID, + boundPtyId: predecessor + }) + let replyWithOldInventory!: (sessions: PtyListedSession[]) => void + deps.listSessions.mockImplementationOnce( + () => + new Promise((resolve) => { + replyWithOldInventory = resolve + }) + ) + const activation = runWorktreeAgentActivationGate(WORKTREE_ID, deps) + await vi.waitFor(() => expect(deps.listSessions).toHaveBeenCalledOnce()) + + // Recovery rebinds the same pane before the old relay's inventory response arrives. + store.terminalLayoutsByTabId['tab-live']!.ptyIdsByLeafId[LIVE_LEAF_ID] = replacement + store.ptyIdsByTabId['tab-live'] = [replacement] + deps.listSurfaceOwners.mockResolvedValueOnce(indexLiveTerminalSurfaceOwners([], WORKTREE_ID)) + replyWithOldInventory([{ ...listed(predecessor), worktreeId: WORKTREE_ID }]) + await activation + + expect(createTab).not.toHaveBeenCalled() + expect(store.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual(['tab-live']) + expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId[LIVE_LEAF_ID]).toBe(replacement) + }) + it('gives every host pane of an unmounted tab its own leaf', async () => { const firstPtyId = `${WORKTREE_ID}@@live-agent` const secondPtyId = `${WORKTREE_ID}@@live-sibling` @@ -706,7 +756,7 @@ describe('worktree agent activation gate', () => { const livePtyId = `${WORKTREE_ID}@@orphan-agent` const { deps, createTab } = testDeps({ sessions: [listed(livePtyId)], - surfaceOwners: new Map() + surfaceOwners: new Map([[livePtyId, 'unowned']]) }) await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts index 3675b7575dac..81ac509ffb7e 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -155,7 +155,7 @@ export async function adoptLiveWorkspacePtySurfaces( continue } const owner = surfaceOwners?.get(ptyId) - if (owner) { + if (owner && owner !== 'unowned') { if (adoptHostOwnedSurface(getState, worktreeId, owner, materializedTabIds)) { surfaced = true } else { @@ -165,7 +165,7 @@ export async function adoptLiveWorkspacePtySurfaces( } // Why: only the execution host can prove a live PTY is unowned, and minting // on anything weaker forks a running agent onto a second empty surface. - if (!surfaceOwners || surfaceOwners.has(ptyId)) { + if (owner !== 'unowned') { declinedPtyIds.push(ptyId) continue } diff --git a/src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts b/src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts index 033ff1da0345..66db1463496b 100644 --- a/src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts +++ b/src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts @@ -27,10 +27,12 @@ function summary(overrides: Partial): RuntimeTerminalSum } } -function stubTerminalList(result: unknown): void { +function stubTerminalList(result: unknown) { + const call = vi.fn(async () => ({ ok: true, result })) vi.stubGlobal('window', { - api: { runtime: { call: vi.fn(async () => ({ ok: true, result })) } } + api: { runtime: { call } } }) + return call } afterEach(() => { @@ -48,7 +50,7 @@ describe('live terminal surface owners', () => { }) }) - it('leaves an orphaned PTY absent so it stays eligible for a recovery tab', () => { + it('records explicit live orphan evidence for a recovery tab', () => { const ptyId = `${WORKTREE_ID}@@orphan` const owners = indexLiveTerminalSurfaceOwners( [ @@ -62,9 +64,43 @@ describe('live terminal surface owners', () => { WORKTREE_ID ) - expect(owners.has(ptyId)).toBe(false) + expect(owners.get(ptyId)).toBe('unowned') }) + it('does not authorize adoption of a disconnected orphan', () => { + const ptyId = `${WORKTREE_ID}@@orphan` + const owners = indexLiveTerminalSurfaceOwners( + [summary({ ptyId, orphaned: true, connected: false })], + WORKTREE_ID + ) + + expect(owners.get(ptyId)).toBeNull() + }) + + it('does not infer orphan ownership from a legacy synthetic surface', () => { + const ptyId = `${WORKTREE_ID}@@orphan` + const owners = indexLiveTerminalSurfaceOwners( + [summary({ ptyId, tabId: `pty:${ptyId}`, leafId: `pty:${ptyId}` })], + WORKTREE_ID + ) + + expect(owners.get(ptyId)).toBeNull() + }) + + it.each([false, true])( + 'rejects conflicting owned and orphan rows (orphan first: %s)', + (orphanFirst) => { + const orphan = summary({ orphaned: true }) + const owned = summary({}) + const owners = indexLiveTerminalSurfaceOwners( + orphanFirst ? [orphan, owned, orphan] : [owned, orphan, owned], + WORKTREE_ID + ) + + expect(owners.get(owned.ptyId!)).toBeNull() + } + ) + it('ignores rows belonging to another workspace', () => { const owners = indexLiveTerminalSurfaceOwners( [summary({ worktreeId: 'repo::/other' })], @@ -74,14 +110,13 @@ describe('live terminal surface owners', () => { expect(owners.size).toBe(0) }) - // Dropping the host's row over path spelling would read as `unowned` and mint a duplicate. it('indexes a row the host spelled with an equivalent workspace path', () => { const owners = indexLiveTerminalSurfaceOwners( [summary({ worktreeId: `${WORKTREE_ID}/` })], WORKTREE_ID ) - expect(owners.get(`${WORKTREE_ID}@@live-agent`)?.tabId).toBe('tab-live') + expect(owners.get(`${WORKTREE_ID}@@live-agent`)).toMatchObject({ tabId: 'tab-live' }) }) it('reports a PTY claimed by two panes as unverifiable rather than unowned', () => { @@ -122,7 +157,7 @@ describe('live terminal surface owners', () => { const owners = await readWorktreeLiveTerminalSurfaceOwners(WORKTREE_ID) - expect(owners?.get(`${WORKTREE_ID}@@live-agent`)?.tabId).toBe('tab-live') + expect(owners?.get(`${WORKTREE_ID}@@live-agent`)).toMatchObject({ tabId: 'tab-live' }) }) it('refuses a census from a host that cannot name the scope it answered for', async () => { @@ -142,7 +177,7 @@ describe('live terminal surface owners', () => { }) it('indexes a complete census', async () => { - stubTerminalList({ + const call = stubTerminalList({ terminals: [summary({})], truncated: false, hostScope: { hostIds: ['local'], omittedHostIds: [] } @@ -150,7 +185,16 @@ describe('live terminal surface owners', () => { const owners = await readWorktreeLiveTerminalSurfaceOwners(WORKTREE_ID) - expect(owners?.get(`${WORKTREE_ID}@@live-agent`)?.tabId).toBe('tab-live') + expect(owners?.get(`${WORKTREE_ID}@@live-agent`)).toMatchObject({ tabId: 'tab-live' }) + expect(call).toHaveBeenCalledWith({ + method: 'terminal.list', + params: { + worktree: `id:${WORKTREE_ID}`, + limit: 200, + requireFreshPtyLiveness: true, + includeVisualLayouts: false + } + }) }) it('refuses a census the host could not answer', async () => { diff --git a/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts b/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts index d42c8e5ad4fe..34aa91438f8a 100644 --- a/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts +++ b/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts @@ -20,12 +20,11 @@ export type LiveTerminalSurfaceOwner = { * client-created tab can leave empty, so they cannot answer "is this PTY * unowned?" — only the host can. * - * Three verdicts, never two: an entry is the owner, a `null` entry is - * `unverifiable` (the host named a surface this renderer cannot address, or - * named two), and a whole-index `null` is `unverifiable` for every PTY. Absence - * from a readable index is the only proof of `unowned`. + * Only `unowned` proves the host observed a live PTY with no surface. Null and + * missing entries are unverifiable; an earlier inventory may name a retired PTY. */ -export type LiveTerminalSurfaceOwnerIndex = ReadonlyMap +type LiveTerminalSurfaceOwnership = LiveTerminalSurfaceOwner | 'unowned' | null +export type LiveTerminalSurfaceOwnerIndex = ReadonlyMap const OWNER_LISTING_LIMIT = 200 @@ -65,25 +64,25 @@ function toSurfaceOwner(terminal: RuntimeTerminalSummary): LiveTerminalSurfaceOw export function indexLiveTerminalSurfaceOwners( terminals: readonly RuntimeTerminalSummary[], worktreeId: string -): Map { - const owners = new Map() +): Map { + const owners = new Map() for (const terminal of terminals) { - // `orphaned` is the host's own word for "live PTY, no surface owns it". - // Path spelling can differ between the host's row and the renderer's id; dropping a row - // over that would read as `unowned` and mint the duplicate this index exists to prevent. - if ( - !worktreeIdsEqual(terminal.worktreeId, worktreeId) || - !terminal.ptyId || - terminal.orphaned === true - ) { + if (!worktreeIdsEqual(terminal.worktreeId, worktreeId) || !terminal.ptyId) { continue } - const owner = toSurfaceOwner(terminal) + const owner = + terminal.orphaned === true + ? terminal.connected === true + ? 'unowned' + : null + : toSurfaceOwner(terminal) const recorded = owners.get(terminal.ptyId) - // Two surfaces claiming one PTY is the duplicate this index must not endorse. + const recordedPane = recorded && recorded !== 'unowned' ? recorded.paneKey : recorded + const ownerPane = owner && owner !== 'unowned' ? owner.paneKey : owner + // Conflicting ownership claims cannot authorize adoption. owners.set( terminal.ptyId, - owners.has(terminal.ptyId) && recorded?.paneKey !== owner?.paneKey ? null : owner + owners.has(terminal.ptyId) && recordedPane !== ownerPane ? null : owner ) } return owners @@ -104,6 +103,7 @@ export async function readWorktreeLiveTerminalSurfaceOwners( params: { worktree: toRuntimeWorktreeSelector(worktreeId), limit: OWNER_LISTING_LIMIT, + requireFreshPtyLiveness: true, includeVisualLayouts: false } }) From 6924559b3dc2f7488f6bbbba224ec55882b515f7 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 02:57:55 -0700 Subject: [PATCH 34/43] test: close heartbeat clients before removing browser globals --- src/renderer/src/web/web-runtime-client-heartbeat.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/src/web/web-runtime-client-heartbeat.test.ts b/src/renderer/src/web/web-runtime-client-heartbeat.test.ts index ab85698660b8..cde7022eefd7 100644 --- a/src/renderer/src/web/web-runtime-client-heartbeat.test.ts +++ b/src/renderer/src/web/web-runtime-client-heartbeat.test.ts @@ -6,6 +6,7 @@ import { WebRuntimeClient } from './web-runtime-client' // keeping its timer armed while the window is hidden. const fakeSockets: FakeWebSocket[] = [] +const clients: WebRuntimeClient[] = [] let visibilityState: DocumentVisibilityState = 'visible' let nextIntervalId = 1 const documentListeners = new Map void>() @@ -73,6 +74,7 @@ function makeConnectedClient(): { deviceToken: 'token', publicKeyB64: Buffer.alloc(32).toString('base64') }) + clients.push(client) const internals = client as unknown as HeartbeatInternals // Override the protected time/visibility seams deterministically. internals.now = () => nowMs @@ -130,6 +132,7 @@ describe('WebRuntimeClient liveness heartbeat', () => { }) afterEach(() => { + clients.splice(0).forEach((client) => client.close()) vi.unstubAllGlobals() }) From 5ceeae4ba8fa40f06f4bb034e723c9776712fc0f Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 03:45:19 -0700 Subject: [PATCH 35/43] test: distinguish transient relay descriptors from accumulation --- .../ssh-docker-resource-accumulation.spec.ts | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/tests/e2e/ssh-docker-resource-accumulation.spec.ts b/tests/e2e/ssh-docker-resource-accumulation.spec.ts index f028ef0d2ab0..bb4a35201836 100644 --- a/tests/e2e/ssh-docker-resource-accumulation.spec.ts +++ b/tests/e2e/ssh-docker-resource-accumulation.spec.ts @@ -71,17 +71,22 @@ const DESCRIBE_MASTER_FD_HOLDERS = [ 'done' ].join('\n') +function readRelayFdCount(target: DockerSshRelayTarget, relayPid: number): number { + const raw = execDockerSshRelayTargetCommand( + target, + `set -o pipefail\nls /proc/${relayPid}/fd | wc -l` + ) + return Number(raw.trim()) +} + function sampleRemoteResources(target: DockerSshRelayTarget): RemoteResourceSample { const groups = readDockerSshRelayProcessSnapshots(target) // Why: fd growth is only meaningful against the relay that owns the PTYs, so read // the table of every relay group and sum, rather than assuming a single relay. - const relayFdCount = groups.reduce((total, group) => { - const raw = execDockerSshRelayTargetCommand( - target, - `ls /proc/${group.relayPid}/fd 2>/dev/null | wc -l` - ) - return total + Number(raw.trim() || '0') - }, 0) + const relayFdCount = groups.reduce( + (total, group) => total + readRelayFdCount(target, group.relayPid), + 0 + ) const ptsCount = Number( execDockerSshRelayTargetCommand(target, 'ls /dev/pts | grep -c "^[0-9]" || true').trim() || '0' ) @@ -192,7 +197,36 @@ test.describe('Docker SSH relay resource accumulation', () => { // Why: the interesting failure is monotonic growth across cycles, not the // absolute count, so compare the last cycle against the first. expect(last.ptsCount).toBeLessThanOrEqual(first.ptsCount) - expect(last.relayFdCount).toBeLessThanOrEqual(first.relayFdCount + 4) + let settledFdCount = last.relayFdCount + if (settledFdCount > first.relayFdCount + 4) { + const groups = readDockerSshRelayProcessSnapshots(target) + expect(groups).toHaveLength(1) + const relayPid = groups[0]!.relayPid + const diagnostics = execDockerSshRelayTargetCommand( + target, + [`ls -l /proc/${relayPid}/fd || true`, 'ps -eo pid,ppid,stat,tty,args'].join('\n') + ) + console.log(`[resource-accumulation] over-budget relay fds\n${diagnostics}`) + await testInfo.attach('relay-fd-over-budget', { + body: diagnostics, + contentType: 'text/plain' + }) + // Background inventory reads can overlap this sample; allow 10s for their FDs to close. + await expect + .poll( + () => { + expect( + readDockerSshRelayProcessSnapshots(captured).map((group) => group.relayPid) + ).toEqual([relayPid]) + settledFdCount = readRelayFdCount(captured, relayPid) + console.log(`[resource-accumulation] settling relay fds ${settledFdCount}`) + return settledFdCount + }, + { timeout: 10_000, intervals: [100, 250, 500] } + ) + .toBeLessThanOrEqual(first.relayFdCount + 4) + } + expect(settledFdCount).toBeLessThanOrEqual(first.relayFdCount + 4) expect(last.nodeProcessCount).toBeLessThanOrEqual(first.nodeProcessCount) expect(last.leakedMasterFdCount).toBeLessThanOrEqual(first.leakedMasterFdCount) } finally { From 92b1deda4dab556d9242163783da94c2efaa12e4 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 04:37:59 -0700 Subject: [PATCH 36/43] fix: withhold orphan claims until terminal spawn admission settles --- ...orca-runtime-build-pty-terminal-summary.ts | 10 +- .../coordinator-task-dispatch.ts | 5 +- ...inator-terminal-census-unavailable.test.ts | 84 ++++++++++ src/main/runtime/orchestration/coordinator.ts | 3 + ...inal-list-pending-pty-registration.test.ts | 144 ++++++++++++++++++ .../worktree-terminal-mutation-lock.test.ts | 4 + .../worktree-terminal-mutation-lock.ts | 4 + 7 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts create mode 100644 src/main/runtime/terminal-list-pending-pty-registration.test.ts diff --git a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts index 159a709fd3c7..d7a169d00e17 100644 --- a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts +++ b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts @@ -1,7 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithGetPtyRecordForPaneKey } from './orca-runtime-get-pty-record-for-pane-key' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import { runtimeWorktreeIdentityKey, type ResolvedWorktree } from './runtime-worktree-path-identity' import type { RuntimeTerminalRead, RuntimeTerminalSummary } from '../../shared/runtime-types' import { getLatestPtyTitle } from './runtime-worktree-status-projection' import { parsePaneKey } from '../../shared/stable-pane-id' @@ -28,6 +28,14 @@ export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPt const title = getLatestPtyTitle(pty) const pane = parsePaneKey(pty.paneKey ?? '') const orphaned = !ptyHoldsRecordedSurface(pty, this.ptySurfaceTopology()) + // A live process awaiting its pane binding is not evidence of an orphan. + if ( + orphaned && + (this.pendingPtyRegistrationIncarnations.has(pty.ptyId) || + this.terminalMutationLock.hasActiveSpawns(runtimeWorktreeIdentityKey(pty.worktreeId))) + ) { + throw new Error('terminal_surface_ownership_unavailable') + } return { handle: this.issuePtyHandle(pty), ptyId: pty.ptyId, diff --git a/src/main/runtime/orchestration/coordinator-task-dispatch.ts b/src/main/runtime/orchestration/coordinator-task-dispatch.ts index bb64f6dd98b9..a305afedf40d 100644 --- a/src/main/runtime/orchestration/coordinator-task-dispatch.ts +++ b/src/main/runtime/orchestration/coordinator-task-dispatch.ts @@ -32,7 +32,7 @@ export async function listAvailableWorkerTerminals( runtime: CoordinatorRuntime, coordinatorHandle: string, worktree: string | undefined -): Promise { +): Promise { try { const result = await runtime.listTerminals(worktree, undefined, { includeVisualLayouts: false @@ -55,7 +55,8 @@ export async function listAvailableWorkerTerminals( ) .map((t) => t.handle) } catch { - return [] + // A failed census cannot authorize creating another worker. + return null } } diff --git a/src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts b/src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts new file mode 100644 index 000000000000..49b348141449 --- /dev/null +++ b/src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Coordinator } from './coordinator' +import type { CoordinatorRuntime } from './coordinator-runtime-contract' +import { OrchestrationDb } from './db' + +afterEach(() => vi.useRealTimers()) + +describe('coordinator terminal census availability', () => { + it.each(['terminal_surface_ownership_unavailable', 'terminal_liveness_unavailable'])( + 'defers dispatch on %s and reuses the existing worker after recovery', + async (error) => { + vi.useFakeTimers() + const db = new OrchestrationDb(':memory:') + const task = db.createTask({ runId: 'run_legacy_local', spec: 'implement the feature' }) + const listTerminals = vi + .fn() + .mockRejectedValueOnce(new Error(error)) + .mockResolvedValue({ + terminals: [ + { handle: 'term_existing', worktreeId: 'wt1', connected: true, writable: true } + ] + }) + const createTerminal = vi.fn(async () => ({ + handle: 'term_unnecessary', + worktreeId: 'wt1' + })) + const sendTerminalAgentPrompt = vi.fn( + async () => ({ accepted: true }) + ) + const runtime: CoordinatorRuntime = { + listTerminals, + createTerminal, + sendTerminalAgentPrompt, + waitForTerminal: async (handle) => ({ handle, condition: 'exit' }), + probeWorktreeDrift: async () => null + } + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 1000, + worktree: 'wt1' + }) + const run = coordinator.run() + try { + await vi.advanceTimersByTimeAsync(0) + expect(listTerminals).toHaveBeenCalledTimes(1) + expect(createTerminal).not.toHaveBeenCalled() + expect(sendTerminalAgentPrompt).not.toHaveBeenCalled() + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.listTasks({ status: 'dispatched' })).toEqual([]) + + await vi.advanceTimersByTimeAsync(1000) + expect(listTerminals).toHaveBeenCalledTimes(2) + expect(createTerminal).not.toHaveBeenCalled() + expect(sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) + const dispatch = db.getDispatchContext(task.id) + expect(dispatch?.assignee_handle).toBe('term_existing') + expect(db.getTask(task.id)?.status).toBe('dispatched') + db.insertMessage({ + runId: 'run_legacy_local', + from: 'term_existing', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch?.id, + outcome: 'succeeded' + }) + }) + await vi.advanceTimersByTimeAsync(1000) + await expect(run).resolves.toMatchObject({ status: 'completed', completedTasks: [task.id] }) + } finally { + coordinator.stop() + try { + await vi.runOnlyPendingTimersAsync() + await run + } finally { + db.close() + } + } + } + ) +}) diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 252c9f89ea90..915dc9e16a4f 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -245,6 +245,9 @@ export class Coordinator { this.opts.coordinatorHandle, this.opts.worktree ) + if (terminals === null) { + return + } if (terminals.length === 0 && slotsAvailable > 0) { // Why: create at most one terminal per tick to avoid spawning many at once. try { diff --git a/src/main/runtime/terminal-list-pending-pty-registration.test.ts b/src/main/runtime/terminal-list-pending-pty-registration.test.ts new file mode 100644 index 000000000000..62a5daf78e3b --- /dev/null +++ b/src/main/runtime/terminal-list-pending-pty-registration.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createInventoryRuntime, + deferred, + processRow, + PREDECESSOR, + PTY, + WORKTREE +} from './pty-inventory-lifecycle-fixture' + +const TAB = '40000000-0000-4000-8000-000000000001' +const LEAF = '40000000-0000-4000-8000-000000000002' + +afterEach(() => vi.restoreAllMocks()) + +describe('terminal listing while a spawn binding is being persisted', () => { + it.each([ + { host: 'local', ptyId: PTY, connectionId: null, phase: 'binding', incarnationId: PREDECESSOR }, + { + host: 'SSH', + ptyId: 'ssh:host-a@@pty2:pending-spawn:1', + connectionId: 'host-a', + phase: 'binding' + }, + { host: 'local', ptyId: PTY, connectionId: null, phase: 'provider' }, + { + host: 'SSH', + ptyId: 'ssh:host-a@@pty2:pending-spawn:1', + connectionId: 'host-a', + phase: 'provider' + } + ])( + 'does not authorize orphan adoption during $host $phase admission', + async ({ ptyId, connectionId, phase, incarnationId }) => { + const bindingPersisted = deferred() + const { runtime } = createInventoryRuntime(async () => [processRow(ptyId)]) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [{ tabId: TAB, worktreeId: WORKTREE, title: '', activeLeafId: LEAF, layout: null }], + leaves: [ + { + tabId: TAB, + worktreeId: WORKTREE, + leafId: LEAF, + paneRuntimeId: 1, + ptyId: null, + paneTitle: null, + title: '' + } + ] + }) + + const releaseSpawn = + phase === 'provider' ? await runtime.acquireWorktreeTerminalSpawn(WORKTREE) : undefined + if (phase === 'binding') { + runtime.beginPtyRegistration(ptyId, incarnationId) + } + // Spawn commit awaits durable binding persistence before publishing the runtime surface. + const commit = bindingPersisted.promise.then(() => { + runtime.registerPty(ptyId, WORKTREE, connectionId, { + tabId: TAB, + leafId: LEAF, + incarnationId: PREDECESSOR + }) + }) + try { + await expect( + runtime.listTerminals(`id:${WORKTREE}`, undefined, { + requireFreshPtyLiveness: true, + includeVisualLayouts: false + }) + ).rejects.toThrow('terminal_surface_ownership_unavailable') + expect(runtime.capture(ptyId).verdict).toMatchObject({ status: 'live' }) + } finally { + bindingPersisted.resolve() + try { + await commit + } finally { + releaseSpawn?.() + } + } + + const committed = await runtime.listTerminals(`id:${WORKTREE}`, undefined, { + requireFreshPtyLiveness: true, + includeVisualLayouts: false + }) + expect(committed.terminals).toEqual([ + expect.objectContaining({ + ptyId, + tabId: TAB, + leafId: LEAF, + connected: true, + incarnationId: PREDECESSOR, + orphaned: false + }) + ]) + } + ) + + it('continues reporting a committed surface while another spawn is pending', async () => { + const { runtime } = createInventoryRuntime(async () => [processRow()]) + runtime.register() + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(WORKTREE) + try { + const listed = await runtime.listTerminals(`id:${WORKTREE}`) + expect(listed.terminals).toEqual([ + expect.objectContaining({ ptyId: PTY, connected: true, orphaned: false }) + ]) + } finally { + releaseSpawn() + } + }) + + it('does not block orphan recovery in an unrelated workspace', async () => { + const { runtime } = createInventoryRuntime(async () => [processRow()]) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn('repo::/tmp/another-workspace') + try { + const listed = await runtime.listTerminals(`id:${WORKTREE}`) + expect(listed.terminals).toEqual([ + expect.objectContaining({ ptyId: PTY, connected: true, orphaned: true }) + ]) + } finally { + releaseSpawn() + } + }) + + it('permits orphan recovery once the competing spawn admission has ended', async () => { + const { runtime } = createInventoryRuntime(async () => [processRow()]) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(WORKTREE) + runtime.beginPtyRegistration(PTY, PREDECESSOR) + try { + await expect(runtime.listTerminals(`id:${WORKTREE}`)).rejects.toThrow( + 'terminal_surface_ownership_unavailable' + ) + } finally { + runtime.cancelPendingPtyRegistration(PTY, PREDECESSOR) + releaseSpawn() + } + const listed = await runtime.listTerminals(`id:${WORKTREE}`) + expect(listed.terminals).toEqual([ + expect.objectContaining({ ptyId: PTY, connected: true, orphaned: true }) + ]) + }) +}) diff --git a/src/main/runtime/worktree-terminal-mutation-lock.test.ts b/src/main/runtime/worktree-terminal-mutation-lock.test.ts index 822238034a86..b972bd02dec9 100644 --- a/src/main/runtime/worktree-terminal-mutation-lock.test.ts +++ b/src/main/runtime/worktree-terminal-mutation-lock.test.ts @@ -16,9 +16,13 @@ describe('WorktreeTerminalMutationLock', () => { lock.acquire(KEY, 'shared') ]) expect(releases).toHaveLength(4) + expect(lock.hasActiveSpawns(KEY)).toBe(true) + expect(lock.hasActiveSpawns('other')).toBe(false) for (const release of releases) { + expect(lock.hasActiveSpawns(KEY)).toBe(true) release() } + expect(lock.hasActiveSpawns(KEY)).toBe(false) expect(lock.trackedKeyCount).toBe(0) }) diff --git a/src/main/runtime/worktree-terminal-mutation-lock.ts b/src/main/runtime/worktree-terminal-mutation-lock.ts index b0af98b2d3bc..f76d84498268 100644 --- a/src/main/runtime/worktree-terminal-mutation-lock.ts +++ b/src/main/runtime/worktree-terminal-mutation-lock.ts @@ -35,6 +35,10 @@ export const WORKTREE_TERMINAL_SLEEP_TIMEOUT_ERROR = 'terminal_worktree_sleep_ti export class WorktreeTerminalMutationLock { private readonly entries = new Map() + hasActiveSpawns(key: string): boolean { + return (this.entries.get(key)?.activeSpawns ?? 0) > 0 + } + /** Why exposed: entry deletion is the only thing keeping this map from * becoming a per-worktree leak, so the tests assert on it directly. */ get trackedKeyCount(): number { From 0013333085caca4c63164ad13cc784d595db0cbf Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 05:12:32 -0700 Subject: [PATCH 37/43] test: wait for terminal mount before synthetic background events --- tests/e2e/settled-worker-tab-survives-restart.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/settled-worker-tab-survives-restart.spec.ts b/tests/e2e/settled-worker-tab-survives-restart.spec.ts index eb86dfdf96ae..695abfa0325a 100644 --- a/tests/e2e/settled-worker-tab-survives-restart.spec.ts +++ b/tests/e2e/settled-worker-tab-survives-restart.spec.ts @@ -72,6 +72,8 @@ async function findSecondaryWorktree( } async function backgroundMountTab(page: Page, worktreeId: string, tabId: string): Promise { + // The synthetic event bypasses the production queue, so its Terminal listener must be mounted. + await waitForActiveTerminalManager(page) await page.evaluate( ({ tabId, worktreeId }) => { window.dispatchEvent( From 8cfb5928329ead76383e080435a31c74892989e5 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 05:27:19 -0700 Subject: [PATCH 38/43] fix: route SSH reconnects after early connect replies --- .../direct-ssh-connect-reply-routing.test.ts | 291 ++++++++++++++++++ .../ipc-events/direct-ssh-state-ipc-bridge.ts | 17 +- .../ssh-reconnect-failure-observation.ts | 120 ++++++++ .../ssh-docker-reconnect-pane-restore.spec.ts | 30 +- 4 files changed, 441 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts create mode 100644 tests/e2e/helpers/ssh-reconnect-failure-observation.ts diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts new file mode 100644 index 000000000000..85023d75f4f5 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { + DirectSshAuthority, + SshConnectionState, + SshProviderEpoch +} from '../../../../shared/ssh-types' +import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' +import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' + +function connectedState( + targetId = 'target-a', + generation = 1 +): SshConnectionState & DirectSshAuthority { + return { + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Tests issue a fixed opaque provider token for each target. + providerEpoch: `epoch-${targetId}` as SshProviderEpoch, + connectionGeneration: generation + } +} + +function authority(state: DirectSshAuthority): DirectSshAuthority { + return { + targetId: state.targetId, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } +} + +async function settle(): Promise { + for (let index = 0; index < 60; index += 1) { + await Promise.resolve() + } +} + +const originalStore = useAppStore.getState() +const cleanups: (() => void)[] = [] + +async function createHarness( + initialStates: readonly SshConnectionState[] = [], + reconciledStates: readonly SshConnectionState[] = [] +) { + const states = new Map(initialStates.map((state) => [state.targetId, state])) + const reconciled = new Map(reconciledStates.map((state) => [state.targetId, state])) + let stateListener: ((event: { targetId: string; state: unknown }) => void) | undefined + const getState = vi.fn(async ({ targetId }: { targetId: string }) => { + const state = states.get(targetId) ?? null + const next = reconciled.get(targetId) + if (next) { + states.set(targetId, next) + } + return state + }) + const targets = ['target-a', 'target-b'].map((id) => ({ id, label: id })) + vi.stubGlobal('window', { + addEventListener: () => {}, + removeEventListener: () => {}, + api: { + ui: {}, + repos: {}, + worktrees: {}, + ssh: { + listTargets: async () => targets, + listRemovedTargetLabels: async () => ({}), + getState, + listPortForwards: async () => [], + listDetectedPorts: async () => [], + onCredentialRequest: () => () => {}, + onCredentialResolved: () => () => {}, + onPortForwardsChanged: () => () => {}, + onDetectedPortsChanged: () => () => {}, + onStateChanged: (listener: typeof stateListener) => { + stateListener = listener + return () => {} + } + } + } + }) + const store = useAppStore.getState() + const invalidate = vi.spyOn(store, 'invalidateStaleDirectSshTargetPtyBindings').mockReturnValue(1) + const retry = vi.spyOn(store, 'retryDirectSshTargetPanes').mockReturnValue(1) + const clearBindings = vi.spyOn(store, 'clearDirectSshTargetPtyBindings').mockReturnValue(1) + const runtime = createDirectSshBridgeRuntime() + const requestReconnect = vi.spyOn(runtime.reconnectCoordinator, 'requestReconnect') + const prepareAndSync = vi.spyOn(runtime, 'prepareAndSync') + const unsubs: (() => void)[] = [] + cleanups.push(() => { + for (const unsubscribe of unsubs) { + unsubscribe() + } + runtime.stop() + }) + registerDirectSshStateIpcBridge(unsubs, runtime) + await settle() + return { + runtime, + getState, + invalidate, + retry, + clearBindings, + requestReconnect, + prepareAndSync, + emit: (state: SshConnectionState) => { + if (!stateListener) { + throw new Error('SSH state listener was not registered') + } + stateListener({ targetId: state.targetId, state }) + }, + applyConnectReply: (state: SshConnectionState) => { + useAppStore.getState().setSshConnectionState(state.targetId, state) + } + } +} + +beforeEach(() => { + useAppStore.setState(originalStore, true) +}) + +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + cleanup() + } + await settle() + vi.restoreAllMocks() + useAppStore.setState(originalStore, true) + vi.unstubAllGlobals() +}) + +describe('direct SSH connect reply routing', () => { + it.each(['reply-first', 'push-first'] as const)( + 'retries through the real coordinator once when connection ordering is %s', + async (ordering) => { + const harness = await createHarness() + const state = connectedState() + if (ordering === 'reply-first') { + harness.applyConnectReply(state) + } + harness.emit(state) + if (ordering === 'push-first') { + harness.applyConnectReply(state) + } + + expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.runtime.reconnectAuthorityByTarget.get(state.targetId)).toEqual( + authority(state) + ) + await settle() + + harness.retry.mockClear() + harness.emit({ ...state }) + + expect(harness.requestReconnect).toHaveBeenCalledOnce() + expect(harness.invalidate).toHaveBeenCalledOnce() + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.prepareAndSync).toHaveBeenLastCalledWith(authority(state), 'wake-refresh') + } + ) + + it('keeps hydration preparation-only and corrects its duplicate without requesting reconnect', async () => { + const state = connectedState() + const harness = await createHarness([state]) + + expect(harness.prepareAndSync).toHaveBeenCalledExactlyOnceWith( + authority(state), + 'initial-hydration' + ) + expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.invalidate).not.toHaveBeenCalled() + expect(harness.retry).not.toHaveBeenCalled() + + harness.emit({ ...state }) + + expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.runtime.reconnectAuthorityByTarget.size).toBe(0) + + const next = connectedState(state.targetId, 2) + harness.applyConnectReply(next) + harness.retry.mockClear() + harness.emit(next) + + expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(next)) + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(next)) + }) + + it.each(['disconnected', 'error'] as const)( + 'forgets a routed authority after %s even when the next connect reply reaches the store first', + async (status) => { + const state = connectedState() + const harness = await createHarness([state]) + harness.emit({ ...state, status }) + + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) + harness.applyConnectReply(state) + harness.emit(state) + + expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.runtime.reconnectAuthorityByTarget.get(state.targetId)).toEqual( + authority(state) + ) + } + ) + + it.each(['providerEpoch', 'connectionGeneration'] as const)( + 'preserves initial hydration routing when %s must be reconciled', + async (missingField) => { + const state = connectedState() + const partial: SshConnectionState = { ...state } + delete partial[missingField] + const harness = await createHarness([partial], [state]) + + expect(useAppStore.getState().sshConnectionStates.get(state.targetId)).toEqual(state) + expect(harness.prepareAndSync).toHaveBeenCalledExactlyOnceWith( + authority(state), + 'initial-hydration' + ) + expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.retry).not.toHaveBeenCalled() + + harness.emit(state) + + expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.runtime.reconnectAuthorityByTarget.size).toBe(0) + } + ) + + it.each(['providerEpoch', 'connectionGeneration'] as const)( + 'waits for partial authority reconciliation when %s is missing', + async (missingField) => { + const harness = await createHarness() + const state = connectedState() + let resolveState!: (state: SshConnectionState) => void + harness.getState.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveState = resolve + }) + ) + harness.applyConnectReply(state) + const partial: SshConnectionState = { ...state } + delete partial[missingField] + harness.emit(partial) + + expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.retry).not.toHaveBeenCalled() + resolveState(state) + await settle() + + expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.retry).toHaveBeenCalledWith(authority(state)) + harness.emit(state) + expect(harness.requestReconnect).toHaveBeenCalledOnce() + } + ) + + it('keeps routed authorities and disconnect cleanup separate for each target', async () => { + const harness = await createHarness() + const first = connectedState('target-a') + const second = connectedState('target-b') + for (const state of [first, second]) { + harness.applyConnectReply(state) + harness.emit(state) + } + await settle() + expect(harness.requestReconnect.mock.calls).toEqual([[authority(first)], [authority(second)]]) + + harness.emit({ ...first, status: 'disconnected' }) + harness.retry.mockClear() + harness.emit(second) + + expect(harness.requestReconnect).toHaveBeenCalledTimes(2) + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(second)) + expect(harness.runtime.reconnectAuthorityByTarget.get(second.targetId)).toEqual( + authority(second) + ) + expect(harness.runtime.reconnectAuthorityByTarget.has(first.targetId)).toBe(false) + + harness.applyConnectReply(first) + harness.emit(first) + expect(harness.requestReconnect).toHaveBeenNthCalledWith(3, authority(first)) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts index 11ceff832fc5..2efacc7765d4 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts @@ -22,6 +22,8 @@ export function registerDirectSshStateIpcBridge( prepareAndSync } = runtime const sshStateWatermarkByTargetId = new Map() + // Connect replies can update the store before this bridge routes the matching push. + const routedAuthorityByTarget = new Map() const pendingPortHydrationByTargetId = new Map< string, { receivedForwardPush: boolean; receivedDetectedPush: boolean } @@ -152,10 +154,10 @@ export function registerDirectSshStateIpcBridge( origin: DirectSshConnectedStateOrigin ): void => { const store = useAppStore.getState() - const previous = store.sshConnectionStates?.get(targetId) store.setSshConnectionState(targetId, state) if (canConnectSshStatus(state.status)) { + routedAuthorityByTarget.delete(targetId) reconnectAuthorityByTarget.delete(targetId) reconnectCoordinator.invalidate(targetId) store.clearRemoteDetectedAgents(targetId) @@ -175,16 +177,8 @@ export function registerDirectSshStateIpcBridge( reconcileSshAuthority(targetId, state, origin, sshStateWatermarkByTargetId.get(targetId) ?? 0) return } - const previousAuthority = - previous?.status === 'connected' && - previous.providerEpoch && - previous.connectionGeneration !== undefined - ? { - targetId, - providerEpoch: previous.providerEpoch, - connectionGeneration: previous.connectionGeneration - } - : null + const previousAuthority = routedAuthorityByTarget.get(targetId) ?? null + routedAuthorityByTarget.set(targetId, authority) routeDirectSshConnectedState( { coordinator: reconnectCoordinator, @@ -235,6 +229,7 @@ export function registerDirectSshStateIpcBridge( } const latestStore = useAppStore.getState() if (!targets.some((target) => target.id === data.targetId)) { + routedAuthorityByTarget.delete(data.targetId) latestStore.clearRemovedSshTargetState(data.targetId) return } diff --git a/tests/e2e/helpers/ssh-reconnect-failure-observation.ts b/tests/e2e/helpers/ssh-reconnect-failure-observation.ts new file mode 100644 index 000000000000..4a1dd4f9d58c --- /dev/null +++ b/tests/e2e/helpers/ssh-reconnect-failure-observation.ts @@ -0,0 +1,120 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { + execDockerSshRelayTargetControlCommand, + type DockerSshRelayTarget +} from './docker-ssh-relay-target' +import { getTerminalContent, readPaneIdentitySnapshot } from './terminal-pane-identity' + +export async function attachSshReconnectFailureObservation( + page: Page, + testInfo: TestInfo, + target: DockerSshRelayTarget | null, + targetId: string | null, + originalPtyId: string | null +): Promise { + const observations: Record = { originalPtyId, targetId } + const observe = async (name: string, read: () => unknown): Promise => { + let timer: ReturnType | undefined + try { + observations[name] = await Promise.race([ + Promise.resolve().then(read), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Observation timed out')), 5_000) + }) + ]) + } catch (error) { + observations[name] = { error: String(error) } + } finally { + clearTimeout(timer) + } + } + await Promise.all([ + observe('paneIdentity', () => readPaneIdentitySnapshot(page)), + observe('renderedContent', () => getTerminalContent(page, 8000)), + observe('renderer', () => + page.evaluate((targetId) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + return { + authority: targetId ? state?.sshConnectionStates.get(targetId) : null, + worktreeId, + activeTabId: state?.activeTabId, + tabs: worktreeId + ? state?.tabsByWorktree[worktreeId]?.map(({ id, title }) => ({ id, title })) + : null, + panes: [...(window.__paneManagers?.entries() ?? [])].map(([tabId, manager]) => ({ + tabId, + diagnostics: manager.getRenderingDiagnostics() + })) + } + }, targetId) + ), + observe('pty', () => + page.evaluate(async (originalPtyId) => { + const ids = new Set(originalPtyId ? [originalPtyId] : []) + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes()) { + const id = pane.container.dataset.ptyId + if (id) { + ids.add(id) + } + } + } + const read = async (request: Promise): Promise => { + let timer: ReturnType | undefined + try { + return await Promise.race([ + request, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('PTY observation timed out')), 3_000) + }) + ]) + } catch (error) { + return { error: String(error) } + } finally { + clearTimeout(timer) + } + } + const [delivery, processes] = await Promise.all([ + read(window.api.pty.getRendererDeliveryDebugSnapshot()), + Promise.all( + [...ids].map(async (id) => { + const [process, buffer] = await Promise.all([ + read(window.api.pty.inspectProcess(id, { scanChildProcesses: true })), + read( + window.api.pty.getMainBufferSnapshot(id, { scrollbackRows: 40 }).then((buffer) => + buffer + ? { + source: buffer.source, + seq: buffer.seq, + alternateScreen: buffer.alternateScreen, + cols: buffer.cols, + rows: buffer.rows, + data: buffer.data.slice(-8000), + scrollbackAnsi: buffer.scrollbackAnsi?.slice(-8000) + } + : null + ) + ) + ]) + return { id, process, buffer } + }) + ) + ]) + return { delivery, processes } + }, originalPtyId) + ) + ]) + if (target) { + await observe('remoteProcesses', () => + execDockerSshRelayTargetControlCommand( + target, + 'ps -eo pid,ppid,pgid,sid,tpgid,stat,comm,args' + ) + ) + } + await testInfo.attach('ssh-reconnect-failure.json', { + body: JSON.stringify(observations, null, 2), + contentType: 'application/json' + }) +} diff --git a/tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts b/tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts index a7a2261de685..27be0b9ad1b0 100644 --- a/tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts +++ b/tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts @@ -18,6 +18,7 @@ import { reconnectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' import { openTerminalTabInActiveGroup } from './helpers/terminal-tab-open' +import { attachSshReconnectFailureObservation } from './helpers/ssh-reconnect-failure-observation' const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' @@ -63,6 +64,8 @@ test.describe('SSH reconnect pane restore', () => { }, testInfo) => { test.slow() let target: DockerSshRelayTarget | null = null + let targetId: string | null = null + let originalPtyId: string | null = null try { target = startDockerSshRelayTarget(testInfo) // The fixture image's shell emits no OSC 0, so without this every tab keeps its placeholder @@ -71,9 +74,11 @@ test.describe('SSH reconnect pane restore', () => { await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) const remote = await connectDockerSshRelayTarget(orcaPage, target) + targetId = remote.targetId await ensureTerminalVisible(orcaPage, 45_000) await waitForActiveTerminalManager(orcaPage, 60_000) const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + originalPtyId = ptyId // A marker rather than a prompt: a prompt reappears on its own after a reconnect, so it cannot // distinguish restored scrollback from a fresh shell. This string only exists if the pane kept @@ -108,13 +113,15 @@ test.describe('SSH reconnect pane restore', () => { await execInTerminal(orcaPage, ptyId, 'top -b -n 1 > /dev/null; top') await waitForTerminalOutput(orcaPage, 'load average', 30_000, 8000) - await reconnectDockerSshRelayTarget(orcaPage, remote.targetId) - await waitForActiveTerminalManager(orcaPage, 60_000) - await waitForActivePanePtyId(orcaPage, 60_000) + for (let reconnect = 0; reconnect < 3; reconnect += 1) { + await reconnectDockerSshRelayTarget(orcaPage, remote.targetId) + await waitForActiveTerminalManager(orcaPage, 60_000) + await waitForActivePanePtyId(orcaPage, 60_000) - await waitForTerminalOutput(orcaPage, 'load average', 60_000, 8000) - const tuiContent = await getTerminalContent(orcaPage, 8000) - expect(tuiContent).toContain('PID') + await waitForTerminalOutput(orcaPage, 'load average', 60_000, 8000) + const tuiContent = await getTerminalContent(orcaPage, 8000) + expect(tuiContent).toContain('PID') + } // REGRESSION 2: opening a tab AFTER a reconnect. The prepaint could still fire on this mount // and write over the new shell, leaving a pane with no prompt and a generic tab title. @@ -152,6 +159,17 @@ test.describe('SSH reconnect pane restore', () => { { timeout: 60_000, message: 'New tab kept its placeholder title' } ) .not.toMatch(/^Terminal \d+$/) + } catch (error) { + await attachSshReconnectFailureObservation( + orcaPage, + testInfo, + target, + targetId, + originalPtyId + ).catch((diagnosticError) => + console.warn('SSH reconnect diagnostics failed', diagnosticError) + ) + throw error } finally { if (target) { cleanupDockerSshRelayTarget(target) From 5f0ee154e3ec0722845b4c7fe2f29e0c51fe7b8f Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 14:29:59 -0700 Subject: [PATCH 39/43] fix: preserve profile saving and terminal ownership through recovery --- .../agent-hooks-load-boundary.test.ts | 42 +++++ src/cli/handlers/agent-hooks.ts | 66 +++---- src/cli/handlers/profile-state.test.ts | 69 +++++-- src/cli/handlers/profile-state.ts | 20 +- .../profile-state-recovery-launch.test.ts | 15 ++ src/cli/specs/profile-state.test.ts | 23 +++ src/cli/specs/profile-state.ts | 13 +- .../agent-auth-restart-preservation.test.ts | 6 +- src/main/agent-auth-restart-preservation.ts | 2 +- .../codex-accounts/async-file-rename.test.ts | 87 +++++++++ .../codex-accounts/codex-account-selection.ts | 9 +- src/main/codex-accounts/fs-utils.ts | 39 +++- ...vice-account-selection-and-removal.test.ts | 102 ++++++---- .../daemon/daemon-process-identity-query.ts | 14 +- .../daemon-process-identity-time-zone.test.ts | 32 ++++ src/main/durable-file-write.ts | 15 +- src/main/ipc/pty/ipc/spawn-commit-persist.ts | 124 ++++++------ ...spawn-commit-ssh-lease-cardinality.test.ts | 14 +- src/main/ipc/pty/ipc/spawn-commit.ts | 86 +++------ .../ipc/pty/pane/terminal-spawn-restore.ts | 46 +++++ src/main/ipc/pty/runtime/spawn-commit.ts | 96 +++++----- ...le-state-maintenance-compatibility.test.ts | 57 +++--- .../profile-state-maintenance-fixture.ts | 10 +- ...profile-state-maintenance-recovery.test.ts | 15 +- .../profile-state-maintenance.test.ts | 32 ++-- .../profile-state-store-backups.test.ts | 30 +++ ...retirement-publication-during-read.test.ts | 146 +++++++++++++++ .../pty-spawn-exit-durability.test.ts | 77 +++++++- .../pty-spawn-handle-publication.test.ts | 81 ++++++++ .../pty-spawn-replacement-durability.test.ts | 74 ++++++++ .../pty-spawn-restore-durability.test.ts | 117 ++++++++++++ .../loading-store/write-flush-barriers.ts | 6 +- .../profile-state-access-identity.ts | 14 +- .../profile-state-access-owner.ts | 20 +- .../profile-state-access.test.ts | 46 +++++ .../profile-state-authority-exports.ts | 43 ++--- ...profile-state-current-json-command.test.ts | 171 +++++++++++++++++ ...ile-state-database-rollback-export.test.ts | 4 +- .../profile-state-live-store-factory.ts | 6 +- .../profile-state-read-snapshot.ts | 18 +- .../profile-state-recovery-command.ts | 8 +- .../profile-state-startup-authority.ts | 8 +- .../profile-state-startup-failure.test.ts | 2 + .../profile-state-startup-failure.ts | 1 + .../profile-state-worker-authority.test.ts | 17 ++ .../profile-state-worker-authority.ts | 8 +- ...ofile-state-worker-export-failures.test.ts | 177 ++++++++++++++++++ .../profile-state-writer-connection.ts | 20 +- .../profile-state-writer-errors.ts | 9 +- ...ofile-state-writer-protocol-faults.test.ts | 61 +++++- .../profile-state-writer-worker-entry.ts | 11 +- .../orca-runtime-fit-override-listeners.ts | 1 + ...-runtime-invalidate-all-handles-for-pty.ts | 1 + src/main/runtime/orca-runtime-on-pty-exit.ts | 28 +-- ...me-persist-terminal-surface-retirements.ts | 6 +- src/main/runtime/orca-runtime-register-pty.ts | 4 + .../startup/main-process-ready-foundation.ts | 4 +- .../profile-state-recovery-preflight.test.ts | 59 +++--- .../profile-state-write-failure.test.ts | 53 ++++++ .../startup/profile-state-write-failure.ts | 18 ++ ...transport-recycled-pty-incarnation.test.ts | 44 ++++- src/shared/cli-argument-boundary.ts | 1 + .../profile-state-recovery-command.test.ts | 28 +++ src/shared/profile-state-recovery-command.ts | 4 +- 64 files changed, 1976 insertions(+), 484 deletions(-) create mode 100644 src/cli/handlers/agent-hooks-load-boundary.test.ts create mode 100644 src/cli/specs/profile-state.test.ts create mode 100644 src/main/codex-accounts/async-file-rename.test.ts create mode 100644 src/main/daemon/daemon-process-identity-time-zone.test.ts create mode 100644 src/main/ipc/pty/pane/terminal-spawn-restore.ts create mode 100644 src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts create mode 100644 src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts create mode 100644 src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts create mode 100644 src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-current-json-command.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts create mode 100644 src/main/startup/profile-state-write-failure.test.ts create mode 100644 src/main/startup/profile-state-write-failure.ts create mode 100644 src/shared/profile-state-recovery-command.test.ts diff --git a/src/cli/handlers/agent-hooks-load-boundary.test.ts b/src/cli/handlers/agent-hooks-load-boundary.test.ts new file mode 100644 index 000000000000..ff11e2a2d501 --- /dev/null +++ b/src/cli/handlers/agent-hooks-load-boundary.test.ts @@ -0,0 +1,42 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { main } from '../index' + +const { prepare } = vi.hoisted(() => ({ prepare: vi.fn() })) +vi.mock('../runtime-client', () => ({ + RuntimeClient: class { + async call() { + return { result: { settings: { agentStatusHooksEnabled: true } } } + } + }, + RuntimeClientError: Error, + getDefaultUserDataPath: () => '/unused/user-data' +})) +vi.mock('../../main/codex/managed-home-shell-preflight', () => ({ + prepareManagedCodexHomeBeforeShellLaunch: prepare +})) +vi.mock('../../main/persistence/profile-state/profile-state-offline-settings', () => { + throw new Error('Offline profile settings loaded during online preparation') +}) +vi.mock('../../main/persistence/profile-state/profile-state-access', () => { + throw new Error('Profile admission loaded during online preparation') +}) +vi.mock('../profile-state-location', () => { + throw new Error('Profile location loaded during online preparation') +}) + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + process.exitCode = undefined +}) + +it('prepares Codex through the runtime without loading offline profile storage', async () => { + vi.stubEnv('WSL_DISTRO_NAME', '') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + await main(['agent', 'hooks', 'prepare-codex']) + expect(error).not.toHaveBeenCalled() + expect(prepare).toHaveBeenCalledWith({ + userDataPath: '/unused/user-data', + hooksEnabled: true + }) +}) diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index ed17728c64a7..11902e985c20 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -17,16 +17,7 @@ import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' import type { PersistedState } from '../../shared/persisted-state-types' import { prepareManagedCodexHomeBeforeShellLaunch } from '../../main/codex/managed-home-shell-preflight' -import { - readAgentHookSettingsFromProfileState, - updateAgentHookSettingsFromProfileState, - type ProfileStateOfflineLocation -} from '../../main/persistence/profile-state/profile-state-offline-settings' -import { getActiveProfileStateLocation } from '../profile-state-location' -import { - acquireProfileStateMaintenance, - acquireProfileStateRuntimeAdmission -} from '../../main/persistence/profile-state/profile-state-access' +import type { ProfileStateOfflineLocation } from '../../main/persistence/profile-state/profile-state-offline-settings' type AgentHookCommandResult = { enabled: boolean @@ -38,11 +29,14 @@ type AgentHookCommandResult = { // Covers managed-home verification, WSL identity, trust grant, and bounded app-server reap. const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000 -function getDataPath(): string { - return getProfileStateLocation()?.dataFile ?? join(getDefaultUserDataPath(), 'orca-data.json') +async function getDataPath(): Promise { + return ( + (await getProfileStateLocation())?.dataFile ?? join(getDefaultUserDataPath(), 'orca-data.json') + ) } -function getProfileStateLocation(): ProfileStateOfflineLocation | undefined { +async function getProfileStateLocation(): Promise { + const { getActiveProfileStateLocation } = await import('../profile-state-location.js') return getActiveProfileStateLocation() } @@ -87,27 +81,29 @@ function writePersistedState(dataPath: string, state: PersistedState): void { } } -function readHookSettingsFromDisk(): Pick< - GlobalSettings, - 'agentStatusHooksEnabled' | 'disabledTuiAgents' +async function readHookSettingsFromDisk(): Promise< + Pick > { + const { acquireProfileStateRuntimeAdmission } = + await import('../../main/persistence/profile-state/profile-state-access.js') const admission = acquireProfileStateRuntimeAdmission(getDefaultUserDataPath()) try { - return readAdmittedHookSettingsFromDisk() + return await readAdmittedHookSettingsFromDisk() } finally { admission.release() } } -function readAdmittedHookSettingsFromDisk(): Pick< - GlobalSettings, - 'agentStatusHooksEnabled' | 'disabledTuiAgents' +async function readAdmittedHookSettingsFromDisk(): Promise< + Pick > { - const profileStateLocation = getProfileStateLocation() + const profileStateLocation = await getProfileStateLocation() if (profileStateLocation) { + const { readAgentHookSettingsFromProfileState } = + await import('../../main/persistence/profile-state/profile-state-offline-settings.js') return readAgentHookSettingsFromProfileState(profileStateLocation) } - const state = readPersistedState(getDataPath()) + const state = readPersistedState(await getDataPath()) return { agentStatusHooksEnabled: state.settings?.agentStatusHooksEnabled !== false, disabledTuiAgents: normalizeDisabledTuiAgents(state.settings?.disabledTuiAgents) @@ -134,28 +130,32 @@ async function readHookSettings( return readHookSettingsFromDisk() } -function updateEnabledOnDisk(enabled: boolean): { +async function updateEnabledOnDisk(enabled: boolean): Promise<{ settingsPath: string settings: Pick -} { +}> { + const { acquireProfileStateMaintenance } = + await import('../../main/persistence/profile-state/profile-state-access.js') // A stopped-status response cannot exclude first migration racing this JSON write. const maintenance = acquireProfileStateMaintenance(getDefaultUserDataPath()) try { - return updateAdmittedEnabledOnDisk(enabled) + return await updateAdmittedEnabledOnDisk(enabled) } finally { maintenance.release() } } -function updateAdmittedEnabledOnDisk(enabled: boolean): { +async function updateAdmittedEnabledOnDisk(enabled: boolean): Promise<{ settingsPath: string settings: Pick -} { - const profileStateLocation = getProfileStateLocation() +}> { + const profileStateLocation = await getProfileStateLocation() if (profileStateLocation) { + const { updateAgentHookSettingsFromProfileState } = + await import('../../main/persistence/profile-state/profile-state-offline-settings.js') return updateAgentHookSettingsFromProfileState(profileStateLocation, enabled) } - const dataPath = getDataPath() + const dataPath = await getDataPath() const state = readPersistedState(dataPath) state.settings = { ...getDefaultPersistedState(homedir()).settings, @@ -219,8 +219,8 @@ async function setAgentHooksEnabled( const { applyAgentStatusHooksEnabled, getManagedAgentHookStatuses } = await import('../../main/agent-hooks/managed-agent-hook-controls.js') const updatedRuntime = await updateRunningRuntime(client, enabled) - const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled) - const settingsPath = offlineUpdate?.settingsPath ?? getDataPath() + const offlineUpdate = updatedRuntime ? null : await updateEnabledOnDisk(enabled) + const settingsPath = offlineUpdate?.settingsPath ?? (await getDataPath()) const statuses = updatedRuntime ? getManagedAgentHookStatuses() : await applyAgentStatusHooksEnabled(enabled, offlineUpdate?.settings) @@ -263,8 +263,8 @@ export const AGENT_HOOK_HANDLERS: Record = { const { getManagedAgentHookStatuses } = await import('../../main/agent-hooks/managed-agent-hook-controls.js') const result: AgentHookCommandResult = { - enabled: readHookSettingsFromDisk().agentStatusHooksEnabled, - settingsPath: getDataPath(), + enabled: (await readHookSettingsFromDisk()).agentStatusHooksEnabled, + settingsPath: await getDataPath(), appliedBy: 'offline', statuses: getManagedAgentHookStatuses() } diff --git a/src/cli/handlers/profile-state.test.ts b/src/cli/handlers/profile-state.test.ts index 772c33501e42..c6eb514d539d 100644 --- a/src/cli/handlers/profile-state.test.ts +++ b/src/cli/handlers/profile-state.test.ts @@ -166,6 +166,32 @@ describe('profile-state CLI recovery', () => { expect(getCliStatusMock).toHaveBeenCalledOnce() }) + it('adopts current JSON through CLI with an honest source description', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + const original = readFileSync(profile.dataFile) + await main(['profile', 'state', 'rollback', '--current-json'], profile.userDataPath) + expect(readFileSync(profile.dataFile)).toEqual(original) + expect(existsSync(profile.databaseFile)).toBe(false) + const output = String(vi.mocked(console.log).mock.calls.at(-1)?.[0]) + expect(output).toContain('source: current JSON') + expect(output).not.toContain('revision:') + }) + + it.each([ + ['--current-json', '--revision', '1'], + ['--current-json', '--backup', '1'], + ['--current-json=false'] + ])('rejects ambiguous current JSON arguments: %s', async (...flags) => { + getCliStatusMock.mockClear() + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + await main(['profile', 'state', 'rollback', ...flags, '--json'], profile.userDataPath) + expect(process.exitCode).toBe(1) + expect(existsSync(profile.databaseFile)).toBe(true) + expect(getCliStatusMock).not.toHaveBeenCalled() + }) + it('keeps profile-state recovery local when remote selection is configured', async () => { const profile = createProfile() getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) @@ -312,28 +338,31 @@ describe('profile-state CLI recovery', () => { ) }) - it('refuses rollback while the runtime is reachable', async () => { - const profile = createProfile() - getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) - getCliStatusMock.mockResolvedValueOnce({ - id: 'status', - ok: true, - result: { - app: { running: true, pid: 123 }, - runtime: { state: 'ready', reachable: true, runtimeId: 'desktop' }, - graph: { state: 'ready' } - }, - _meta: { runtimeId: 'test' } - }) + it.each([['--revision', '1'], ['--current-json']])( + 'refuses rollback while runtime is reachable: %s', + async (...flags) => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + getCliStatusMock.mockResolvedValueOnce({ + id: 'status', + ok: true, + result: { + app: { running: true, pid: 123 }, + runtime: { state: 'ready', reachable: true, runtimeId: 'desktop' }, + graph: { state: 'ready' } + }, + _meta: { runtimeId: 'test' } + }) - await main(['profile', 'state', 'rollback', '--revision', '1'], profile.userDataPath) + await main(['profile', 'state', 'rollback', ...flags], profile.userDataPath) - expect(existsSync(profile.databaseFile)).toBe(true) - expect(readFileSync(profile.dataFile, 'utf8')).toBe( - JSON.stringify({ settings: { theme: 'old' } }) - ) - expect(vi.mocked(console.error).mock.calls.at(-1)?.[0]).toContain('Stop Orca') - }) + expect(existsSync(profile.databaseFile)).toBe(true) + expect(readFileSync(profile.dataFile, 'utf8')).toBe( + JSON.stringify({ settings: { theme: 'old' } }) + ) + expect(vi.mocked(console.error).mock.calls.at(-1)?.[0]).toContain('Stop Orca') + } + ) it('lists SQLite backups alongside JSON exports without opening the damaged primary', async () => { const profile = createProfile() diff --git a/src/cli/handlers/profile-state.ts b/src/cli/handlers/profile-state.ts index 3d8f5729416c..b84cd694df6e 100644 --- a/src/cli/handlers/profile-state.ts +++ b/src/cli/handlers/profile-state.ts @@ -49,7 +49,7 @@ function formatExports(result: ProfileStateExportsResult): string { function formatRollback(result: ProfileStateRollbackResult): string { return [ `profileId: ${result.profileId}`, - `revision: ${result.revision}`, + result.revision === null ? 'source: current JSON' : `revision: ${result.revision}`, `storage: ${result.storage}`, `restored: ${result.restoredPath}`, `quarantine: ${result.quarantineDirectory}`, @@ -97,12 +97,6 @@ export const PROFILE_STATE_HANDLERS: Record = { }, 'profile state rollback': async ({ client, flags, json }) => { rejectProfileStateRemoteSelection(flags) - if (flags.has('revision') && flags.has('backup')) { - throw new RuntimeClientError( - 'invalid_argument', - 'Select exactly one of --revision or --backup.' - ) - } const selector = parseSelector(flags) const userDataPath = getDefaultUserDataPath() let result: ProfileStateRollbackResult @@ -125,6 +119,18 @@ export const PROFILE_STATE_HANDLERS: Record = { } function parseSelector(flags: Map): ProfileStateRecoverySelector { + if (['revision', 'backup', 'current-json'].filter((flag) => flags.has(flag)).length !== 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Select exactly one of --revision, --backup, or --current-json.' + ) + } + if (flags.has('current-json')) { + if (flags.get('current-json') !== true) { + throw new RuntimeClientError('invalid_argument', '--current-json does not take a value.') + } + return { kind: 'current-json' } + } if (!flags.has('backup')) { return { kind: 'json', revision: parseRevision(flags) } } diff --git a/src/cli/runtime/profile-state-recovery-launch.test.ts b/src/cli/runtime/profile-state-recovery-launch.test.ts index 5f8d83e8a2ea..36ae0e7868ff 100644 --- a/src/cli/runtime/profile-state-recovery-launch.test.ts +++ b/src/cli/runtime/profile-state-recovery-launch.test.ts @@ -81,6 +81,21 @@ describe('profile-state recovery launch', () => { expect(mocks.run.mock.calls[0][0].env).not.toHaveProperty('ELECTRON_RUN_AS_NODE') }) + it('round-trips current JSON selection without requiring an invented revision', async () => { + const current = { ...result, revision: null } + mocks.run.mockResolvedValue({ + code: 0, + signal: null, + timedOut: false, + stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: true, result: current })}\n`, + stderr: '' + }) + expect( + await launchProfileStateRecovery({ userDataPath: '.', selector: { kind: 'current-json' } }) + ).toEqual(current) + expect(mocks.run.mock.calls[0][0].args.at(-1)).toContain('"kind":"current-json"') + }) + it('preserves a structured refusal from the lock owner', async () => { mocks.run.mockResolvedValue({ code: 1, diff --git a/src/cli/specs/profile-state.test.ts b/src/cli/specs/profile-state.test.ts new file mode 100644 index 000000000000..ef331390ecf1 --- /dev/null +++ b/src/cli/specs/profile-state.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { parseArgs, validateCommandAndFlags } from '../args' +import { PROFILE_STATE_COMMAND_SPECS } from './profile-state' + +describe('profile state rollback discovery', () => { + it.each([ + { argv: ['profile', 'state', 'rollback', '--current-json'] }, + { argv: ['--current-json', 'profile', 'state', 'rollback'] }, + { argv: ['profile', '--current-json', 'state', 'rollback'] } + ])('parses the current JSON selector as a boolean: $argv', ({ argv }) => { + const parsed = parseArgs(argv) + expect(parsed.commandPath).toEqual(['profile', 'state', 'rollback']) + expect(parsed.flags.get('current-json')).toBe(true) + expect(() => validateCommandAndFlags(PROFILE_STATE_COMMAND_SPECS, parsed)).not.toThrow() + }) + + it('explains that adoption selects one full state and preserves both copies', () => { + const spec = PROFILE_STATE_COMMAND_SPECS.find((item) => item.path.at(-1) === 'rollback') + expect(spec?.usage).toContain('--current-json') + expect(spec?.notes?.join('\n')).toContain('without merging; both copies are archived') + expect(spec?.examples).toContain('orca profile state rollback --current-json') + }) +}) diff --git a/src/cli/specs/profile-state.ts b/src/cli/specs/profile-state.ts index ae5aad105389..030a5b57b229 100644 --- a/src/cli/specs/profile-state.ts +++ b/src/cli/specs/profile-state.ts @@ -11,17 +11,20 @@ export const PROFILE_STATE_COMMAND_SPECS: CommandSpec[] = [ { path: ['profile', 'state', 'rollback'], destructive: true, - summary: 'Restore a retained SQLite backup or JSON export after profile-state corruption', - usage: 'orca profile state rollback (--backup | --revision ) [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'revision', 'backup'], + summary: 'Restore a SQLite backup, retained JSON export, or current JSON profile', + usage: + 'orca profile state rollback (--backup | --revision | --current-json) [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'revision', 'backup', 'current-json'], notes: [ 'Orca must be stopped. Recovery validates the selected artifact and archives the current database family, JSON, and retained recovery artifacts before replacing state.', - '--backup restores SQLite authority; --revision restores a JSON export for an older compatible runtime.' + '--backup restores SQLite authority; --revision restores a JSON export for an older compatible runtime.', + '--current-json keeps the current orca-data.json, including edits from an older build. It replaces SQLite state without merging; both copies are archived. The next SQLite-capable start imports the selected JSON.' ], examples: [ 'orca profile state exports', 'orca profile state rollback --backup ', - 'orca profile state rollback --revision 1' + 'orca profile state rollback --revision 1', + 'orca profile state rollback --current-json' ] } ] diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index 700631280687..291d294d9ba5 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -151,12 +151,14 @@ describe('preserveAgentAuthBeforeRestart', () => { ) }) - it('flushes the store when auth services are missing', async () => { + it('checkpoints admitted state without waiting for ongoing edits when auth services are missing', async () => { const flushPendingOrThrowAsync = vi.fn() await preserveAgentAuthBeforeRestart({ store: { flushPendingOrThrowAsync } }) - expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + expect(flushPendingOrThrowAsync).toHaveBeenCalledExactlyOnceWith({ + drainToStableGeneration: false + }) }) it('logs secret-free warnings and does not throw when sync fails', async () => { diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index a3e24ef8abed..d00641088cc2 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -44,7 +44,7 @@ export async function preserveAgentAuthBeforeRestart({ const storePreservation = store ? runWithinLifecycleTimeout( 'Store persistence', - () => store.flushPendingOrThrowAsync(), + () => store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), remainingLifecycleTime(startedAt) ) : Promise.resolve() diff --git a/src/main/codex-accounts/async-file-rename.test.ts b/src/main/codex-accounts/async-file-rename.test.ts new file mode 100644 index 000000000000..a453378a78d8 --- /dev/null +++ b/src/main/codex-accounts/async-file-rename.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { writeFileDurable, writeFileDurableIfCurrent } from '../durable-file-write' + +const rename = vi.hoisted(() => vi.fn()) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + rename.mockImplementation(actual.rename) + return { ...actual, rename } +}) + +const platform = process.platform +const roots: string[] = [] +afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + rename.mockClear() + vi.restoreAllMocks() +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-async-rename-')) + roots.push(root) + const target = join(root, 'state.json') + const temporary = join(root, 'temporary.json') + writeFileSync(target, 'old') + return { root, target, temporary } +} + +it.each(['EPERM', 'EACCES', 'EBUSY'])( + 'retries a transient Windows %s without blocking the event loop', + async (code) => { + const { target, temporary } = fixture() + Object.defineProperty(process, 'platform', { value: 'win32' }) + rename.mockRejectedValueOnce(Object.assign(new Error('file busy'), { code })) + let ticked = false + const timer = setTimeout(() => { + ticked = true + }, 0) + try { + await writeFileDurable(temporary, target, 'new') + expect(readFileSync(target, 'utf8')).toBe('new') + expect(rename).toHaveBeenCalledTimes(2) + expect(ticked).toBe(true) + } finally { + clearTimeout(timer) + } + } +) + +it.each([ + ['win32', 'EPERM', 6], + ['win32', 'ENOSPC', 1], + ['linux', 'EBUSY', 1] +] as const)('bounds %s %s failures and preserves the old file', async (host, code, attempts) => { + const { root, target, temporary } = fixture() + Object.defineProperty(process, 'platform', { value: host }) + for (let attempt = 0; attempt < attempts; attempt++) { + rename.mockRejectedValueOnce(Object.assign(new Error('injected file failure'), { code })) + } + await expect(writeFileDurable(temporary, target, 'new')).rejects.toThrow('injected file failure') + expect(rename).toHaveBeenCalledTimes(attempts) + expect(readFileSync(target, 'utf8')).toBe('old') + expect(readdirSync(root)).toEqual(['state.json']) +}) + +it('does not publish a superseded snapshot after a Windows retry delay', async () => { + const { root, target, temporary } = fixture() + Object.defineProperty(process, 'platform', { value: 'win32' }) + let current = true + rename.mockImplementationOnce(async () => { + current = false + writeFileSync(target, 'newer snapshot') + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + }) + await expect( + writeFileDurableIfCurrent(temporary, target, 'stale snapshot', () => current) + ).resolves.toBe(false) + expect(rename).toHaveBeenCalledOnce() + expect(readFileSync(target, 'utf8')).toBe('newer snapshot') + expect(readdirSync(root)).toEqual(['state.json']) +}) diff --git a/src/main/codex-accounts/codex-account-selection.ts b/src/main/codex-accounts/codex-account-selection.ts index a95c1da9f34e..24785e79dc12 100644 --- a/src/main/codex-accounts/codex-account-selection.ts +++ b/src/main/codex-accounts/codex-account-selection.ts @@ -83,10 +83,13 @@ export class CodexAccountSelection { } this.dependencies.removeManagedHome(account.managedHomePath, account.id) - // Why: a removed account can no longer appear in the switcher dropdown, - // so purge its cached usage to avoid stale entries. this.dependencies.rateLimits.evictInactiveCodexCache(accountId) - await this.dependencies.discardResetAttempts(accountId) + try { + await this.dependencies.discardResetAttempts(accountId) + } catch (error) { + // Removal already succeeded; retain the ledger's safety guards if cleanup fails. + console.warn('[codex-accounts] Removed account, but credit ledger cleanup failed:', error) + } const accountTarget = getCodexSelectionTargetForAccount(account) this.startQuotaRefresh( getSelectedCodexAccountIdForTarget(settings, accountTarget) === accountId diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 989f743fdcfe..295dfac9b66b 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto' import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { rename } from 'node:fs/promises' import { dirname } from 'node:path' +import { setTimeout } from 'node:timers/promises' import { grantDirAcl, isPermissionError } from '../win32-utils' import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality' @@ -201,19 +203,38 @@ export function renameFileWithWindowsRetry(source: string, target: string): void runFileOperationWithWindowsRetry(() => renameSync(source, target)) } +export async function renameFileWithWindowsRetryAsync( + source: string, + target: string, + isCurrent: () => boolean = () => true +): Promise { + for (let attempt = 1; ; attempt++) { + if (!isCurrent()) { + return false + } + try { + await rename(source, target) + return true + } catch (error) { + if (!shouldRetryFileOperation(error, attempt)) { + throw error + } + await setTimeout(attempt * 50) + } + } +} + export function copyFileWithWindowsRetry(source: string, target: string): void { runFileOperationWithWindowsRetry(() => copyFileSync(source, target)) } function runFileOperationWithWindowsRetry(operation: () => void): void { - const maxAttempts = process.platform === 'win32' ? 6 : 1 - for (let attempt = 1; attempt <= maxAttempts; attempt++) { + for (let attempt = 1; ; attempt++) { try { operation() return } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (attempt < maxAttempts && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY')) { + if (shouldRetryFileOperation(error, attempt)) { sleepSync(attempt * 50) continue } @@ -222,6 +243,16 @@ function runFileOperationWithWindowsRetry(operation: () => void): void { } } +function shouldRetryFileOperation(error: unknown, attempt: number): boolean { + return ( + process.platform === 'win32' && + attempt < 6 && + error instanceof Error && + 'code' in error && + (error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY') + ) +} + // Why: writeFileAtomically is a sync API called from sync paths, so the retry // backoff must park the thread instead of burning CPU in a Date.now() loop. const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) diff --git a/src/main/codex-accounts/service-account-selection-and-removal.test.ts b/src/main/codex-accounts/service-account-selection-and-removal.test.ts index a2d7d9a4bb73..861859223ea8 100644 --- a/src/main/codex-accounts/service-account-selection-and-removal.test.ts +++ b/src/main/codex-accounts/service-account-selection-and-removal.test.ts @@ -215,47 +215,67 @@ describe('CodexAccountService config sync', () => { }) }) - it('removes an account and cleans up managed home', async () => { - const managedHomePath = createManagedHome( - testState.userDataDir, - 'account-1', - '', - '{"account":"managed"}\n' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const rateLimits = createRateLimits() - const runtimeHome = createRuntimeHome() - - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - store as never, - rateLimits as never, - runtimeHome as never - ) - - const result = await service.removeAccount('account-1') - - expect(result.accounts).toHaveLength(0) - expect(result.activeAccountId).toBe(null) - expect(existsSync(managedHomePath)).toBe(false) - expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() - }) + it.each(['healthy', 'unreadable'])( + 'removes an account with a %s credit ledger', + async (ledger) => { + const managedHomePath = createManagedHome( + testState.userDataDir, + 'account-1', + '', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + const ledgerError = new Error('credit ledger unreadable') + if (ledger === 'unreadable') { + store.getCodexResetCreditAttemptLedger.mockImplementation(() => { + throw ledgerError + }) + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.removeAccount('account-1') + + expect(result.accounts).toHaveLength(0) + expect(result.activeAccountId).toBe(null) + expect(existsSync(managedHomePath)).toBe(false) + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() + expect(rateLimits.evictInactiveCodexCache).toHaveBeenCalledWith('account-1') + if (ledger === 'unreadable') { + expect(warn).toHaveBeenCalledWith( + '[codex-accounts] Removed account, but credit ledger cleanup failed:', + expect.any(Error) + ) + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled() + } + warn.mockRestore() + } + ) it('refuses to remove a managed home owned by a different account', async () => { const otherAccountHome = createManagedHome( diff --git a/src/main/daemon/daemon-process-identity-query.ts b/src/main/daemon/daemon-process-identity-query.ts index 479aa821ecfd..d4eee6b1b84d 100644 --- a/src/main/daemon/daemon-process-identity-query.ts +++ b/src/main/daemon/daemon-process-identity-query.ts @@ -16,22 +16,26 @@ export type PsProcessIdentity = { startedAtMs: number | null } -function parsePsProcessIdentity(output: string): PsProcessIdentity { +function parsePsProcessIdentity(output: string, utc = false): PsProcessIdentity { // BSD ps formats lstart as a fixed-width 24-character timestamp. - const startedAtMs = Date.parse(output.slice(0, 24)) + const startedAtMs = Date.parse(output.slice(0, 24) + (utc ? ' UTC' : '')) return { commandLine: output.slice(24).trim(), startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : null } } -export function getPsProcessIdentity(pid: number): PsProcessIdentity | null { +export function getPsProcessIdentity( + pid: number, + options?: { utc?: boolean } +): PsProcessIdentity | null { try { const output = execFileSync('ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'command='], { encoding: 'utf8', - timeout: 2_000 + timeout: 2_000, + ...(options?.utc ? { env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' } } : {}) }) - return parsePsProcessIdentity(output) + return parsePsProcessIdentity(output, options?.utc) } catch { return null } diff --git a/src/main/daemon/daemon-process-identity-time-zone.test.ts b/src/main/daemon/daemon-process-identity-time-zone.test.ts new file mode 100644 index 000000000000..aee3e4f44204 --- /dev/null +++ b/src/main/daemon/daemon-process-identity-time-zone.test.ts @@ -0,0 +1,32 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { getPsProcessIdentity } from './daemon-process-identity-query' + +const { execFileSync } = vi.hoisted(() => ({ execFileSync: vi.fn() })) +vi.mock('node:child_process', () => ({ execFileSync, execFile: vi.fn() })) + +afterEach(() => { + vi.unstubAllEnvs() + vi.clearAllMocks() +}) + +it.each(['America/Los_Angeles', 'America/New_York', 'UTC'])( + 'keeps the autumn clock transition unambiguous under %s', + (timezone) => { + vi.stubEnv('TZ', timezone) + execFileSync.mockReturnValue('Sun Nov 1 09:30:00 2026 /path/to/orca\n') + expect(getPsProcessIdentity(42, { utc: true })).toEqual({ + startedAtMs: Date.parse('2026-11-01T09:30:00Z'), + commandLine: '/path/to/orca' + }) + expect(execFileSync).toHaveBeenCalledWith( + 'ps', + ['-p', '42', '-o', 'lstart=', '-o', 'command='], + expect.objectContaining({ env: expect.objectContaining({ TZ: 'UTC', LC_ALL: 'C' }) }) + ) + } +) + +it('treats an unreadable UTC process start as unknown', () => { + execFileSync.mockReturnValue(' /path/to/orca\n') + expect(getPsProcessIdentity(42, { utc: true })?.startedAtMs).toBeNull() +}) diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index d186be1ae575..05e849327b7f 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -4,9 +4,13 @@ // hour's loss; fsync stops it from happening. import { closeSync, fsyncSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { copyFile, open, readdir, rename, rm, stat } from 'node:fs/promises' +import { copyFile, open, readdir, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' -import { publishFileWithoutOverwrite, renameFileWithWindowsRetry } from './codex-accounts/fs-utils' +import { + publishFileWithoutOverwrite, + renameFileWithWindowsRetry, + renameFileWithWindowsRetryAsync +} from './codex-accounts/fs-utils' /** * fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a @@ -64,7 +68,7 @@ export function publishFileDurableSync(tmpPath: string, finalPath: string): bool * themselves and need the rename made durable. */ export async function renameDurable(tmpPath: string, finalPath: string): Promise { - await rename(tmpPath, finalPath) + await renameFileWithWindowsRetryAsync(tmpPath, finalPath) await syncDirectory(dirname(finalPath)) } @@ -112,7 +116,7 @@ export async function copyFileDurable(sourcePath: string, finalPath: string): Pr } finally { await handle.close() } - await rename(tmpPath, finalPath) + await renameFileWithWindowsRetryAsync(tmpPath, finalPath) renamed = true await syncDirectory(dirname(finalPath)) return true @@ -148,10 +152,9 @@ export async function writeFileDurableIfCurrent( try { // Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file. await writeTempFileDurable(tmpPath, payload) - if (!isCurrent()) { + if (!(await renameFileWithWindowsRetryAsync(tmpPath, finalPath, isCurrent))) { return false } - await rename(tmpPath, finalPath) renamed = true await syncDirectory(dirname(finalPath)) return true diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 0164b3d4701d..692bb30c6721 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -1,9 +1,9 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host' -import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/registry' import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' import { pendingByPaneKey, pendingPtyIdBySerializerGeneration, @@ -16,11 +16,7 @@ import { clearProviderPtyState } from '../provider/state-cleanup' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { PtyIpcSpawnState } from './spawn-state' -export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ - rendererPreSignaled: boolean - rendererAlreadyRegistered: boolean - committedSize: PtyGrid -}> { +export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise { const args = ctx.args try { ctx.stablePaneBindingPersisted = await persistAdmittedStablePaneBinding({ @@ -40,67 +36,12 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ agentSessionOperationOutcome: 'unknown' as const }) } - ctx.spawnTiming.log(ctx.result.id, { - daemon: ctx.isDaemonHostSpawn, - reattach: ctx.result.isReattach ?? false - }) - recordCodexPaneAccountForSpawn({ - ptyId: ctx.result.id, - isDaemonHostSpawn: ctx.isDaemonHostSpawn, - isReattach: ctx.result.isReattach === true, - pinnedByResume: ctx.codexResumeHomeSelected, - launchCodexHomePath: ctx.selectedCodexHomePath, - launchEnv: ctx.baseEnv, - target: ctx.codexSelectionTarget, - settings: ctx.deps.getSettings?.() - }) - ptyOwnership.set(ctx.result.id, args.connectionId ?? null) - if (ctx.result.incarnationId) { - ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) - } - if (ctx.initiallyHidden) { - // Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn). - ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.result.id, true) - if (ctx.preSpawnHiddenMarkId !== null && ctx.preSpawnHiddenMarkId !== ctx.result.id) { - // Defense: never strand a mark on an id the provider renamed. - ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) - } - // Why after ptyOwnership.set: provider lookup routes by ownership, and a hidden-spawned agent should be paceable from its first flood. - ctx.deps.syncPtyBackgroundedDelivery(ctx.result.id, 'spawn') - closeStartupQueryAuthorityForPty(ctx.result.id) - } - // Why: record the native-Windows-ConPTY determination before the headless seed so the emulator's DA1 override exists from byte zero. - if (ctx.nativeWindowsConptySpawn) { - markNativeWindowsConptyPty(ctx.result.id) - } - const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id) - if (ctx.deps.store && args.connectionId) { - // Why: remote PTYs live in the SSH relay grace window after Orca detaches; persist IDs immediately so reconnect reattaches instead of spawning a fresh shell. - ctx.deps.store.upsertSshRemotePtyLease({ - targetId: args.connectionId, - ptyId: relayResultId, - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), - ...(ctx.validatedLeafId ? { leafId: ctx.validatedLeafId } : {}), - state: 'attached', - lastAttachedAt: Date.now() - }) - } - if (ctx.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { - if (ctx.deps.runtime?.registerPreAllocatedHandleForPty) { - ctx.deps.runtime.registerPreAllocatedHandleForPty(ctx.result.id, ctx.preAllocatedHandle) - ctx.agentTeamsLeaderHandle = null - } - } const committedSize = resolveCommittedPtySize({ result: ctx.result, requested: { cols: args.cols, rows: args.rows }, cachedBeforeAttach: ctx.sessionSizeBeforeAttach }) - ptySizes.set(ctx.result.id, committedSize) - if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { - ptySizes.delete(ctx.effectiveSessionAppId) - } + const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id) // Persist the binding before acknowledging spawn so the renderer debounce cannot orphan history. if ( ctx.deps.store && @@ -144,14 +85,58 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ }) } } - // Why here and not at the upsert: this path leases before it binds, so supersession fenced on the - // pane's binding still named the predecessor and bailed on every reconnect — one more reattachable - // lease, and one more `pty.attach`, per reconnect forever. Runs after whichever binding write this - // commit made, so the lease/binding order no longer decides. - if (ctx.deps.store && args.connectionId && ctx.validatedLeafId !== null) { - ctx.deps.store.supersedeSshRemotePtyLeasesForBoundPane(args.connectionId, ctx.validatedLeafId) + return committedSize +} + +export function publishPtyIpcSpawnCommit(ctx: PtyIpcSpawnState, committedSize: PtyGrid): void { + const args = ctx.args + ctx.spawnTiming.log(ctx.result.id, { + daemon: ctx.isDaemonHostSpawn, + reattach: ctx.result.isReattach ?? false + }) + recordCodexPaneAccountForSpawn({ + ptyId: ctx.result.id, + isDaemonHostSpawn: ctx.isDaemonHostSpawn, + isReattach: ctx.result.isReattach === true, + pinnedByResume: ctx.codexResumeHomeSelected, + launchCodexHomePath: ctx.selectedCodexHomePath, + launchEnv: ctx.baseEnv, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) + ptyOwnership.set(ctx.result.id, args.connectionId ?? null) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + if (ctx.initiallyHidden) { + // Refresh the pre-spawn hidden mark only after this incarnation survives its save. + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.result.id, true) + if (ctx.preSpawnHiddenMarkId !== null && ctx.preSpawnHiddenMarkId !== ctx.result.id) { + // Defense: never strand a mark on an id the provider renamed. + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) + } + // Why after ptyOwnership.set: provider lookup routes by ownership, and a hidden-spawned agent should be paceable from its first flood. + ctx.deps.syncPtyBackgroundedDelivery(ctx.result.id, 'spawn') + closeStartupQueryAuthorityForPty(ctx.result.id) } - // Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md. + if (ctx.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { + if (ctx.deps.runtime?.registerPreAllocatedHandleForPty) { + ctx.deps.runtime.registerPreAllocatedHandleForPty(ctx.result.id, ctx.preAllocatedHandle) + ctx.agentTeamsLeaderHandle = null + } + } + ptySizes.set(ctx.result.id, committedSize) + if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { + ptySizes.delete(ctx.effectiveSessionAppId) + } + claimSshPaneLease({ + store: ctx.deps.store, + connectionId: args.connectionId, + ptyId: ctx.result.id, + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: ctx.validatedLeafId ?? undefined + }) const rendererPreSignaled = ctx.validatedPaneKey ? pendingByPaneKey.has(ctx.validatedPaneKey) : false @@ -167,5 +152,4 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ pendingPtyIdBySerializerGeneration.set(pending.gen, ctx.result.id) } } - return { rendererPreSignaled, rendererAlreadyRegistered, committedSize } } diff --git a/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts b/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts index a64192be3835..aa726fdc6856 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts @@ -9,7 +9,7 @@ import { toAppSshPtyId } from '../../../providers/ssh-pty-id' import { toSshExecutionHostId } from '../../../../shared/execution-host' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' import { createPtyIpcSpawnState } from './spawn-state' -import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' +import { persistPtyIpcSpawnCommit, publishPtyIpcSpawnCommit } from './spawn-commit-persist' vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -20,15 +20,7 @@ const TARGET = 'ssh-1' const WORKTREE = 'repo1::/worktree' const TAB = 'tab-1' -/** - * Drives the shipped IPC spawn commit rather than the store primitives it calls. - * - * The store-level suite could not catch this: it exercised bind-then-upsert, and this path does the - * opposite — it writes the lease row first so a force-quit in the renderer's debounce window cannot - * strand a running remote shell without one, then binds the pane. Supersession is fenced on the - * pane's binding, so under this real order it bailed on the predecessor every time and never re-ran, - * and each reconnect left one more reattachable lease for `reattachKnownPtys` to `pty.attach`. - */ +/** Exercises the shipped binding-then-publication order so reconnects retire earlier leases. */ async function commitSshSpawn( store: ReturnType, args: { relayPtyId: string; leafId: string } @@ -45,7 +37,7 @@ async function commitSshSpawn( const ctx = createPtyIpcSpawnState(deps, spawnArgs) ctx.result = { id: toAppSshPtyId(TARGET, args.relayPtyId) } ctx.validatedLeafId = args.leafId - await persistPtyIpcSpawnCommit(ctx) + publishPtyIpcSpawnCommit(ctx, await persistPtyIpcSpawnCommit(ctx)) } /** One pane's layout, so the two host partitions can be given different bindings for one leaf. */ diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 79af82d7cf8c..56f59ed8ce46 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -17,66 +17,21 @@ import { admitRendererAgentLaunchAuthority } from '../pane/launch-authority' import type { PtyIpcSpawnState } from './spawn-state' -import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' +import { persistPtyIpcSpawnCommit, publishPtyIpcSpawnCommit } from './spawn-commit-persist' import { registerPersistedPtySpawn } from '../pane/spawn-registration' import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' +import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' +import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args - const { rendererPreSignaled, rendererAlreadyRegistered, committedSize } = - await persistPtyIpcSpawnCommit(ctx) - - // Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback). - // Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot. - if (ctx.deps.runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { - const snapshotSeedSize = - typeof ctx.result.snapshotCols === 'number' && typeof ctx.result.snapshotRows === 'number' - ? { cols: ctx.result.snapshotCols, rows: ctx.result.snapshotRows } - : undefined - if (typeof ctx.result.snapshot === 'string' && ctx.result.snapshot.length > 0) { - // Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md). - ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.snapshot, snapshotSeedSize, { - ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' - ? { kittyKeyboardFlags: ctx.result.snapshotKittyKeyboardFlags } - : {}), - ...(ctx.result.snapshotTerminalOwner - ? { terminalOwner: ctx.result.snapshotTerminalOwner } - : {}) - }) - } else if ( - ctx.result.coldRestore && - typeof ctx.result.coldRestore.scrollback === 'string' && - ctx.result.coldRestore.scrollback.length > 0 - ) { - const coldRestoreSeedSize = - typeof ctx.result.coldRestore.cols === 'number' && - typeof ctx.result.coldRestore.rows === 'number' - ? { cols: ctx.result.coldRestore.cols, rows: ctx.result.coldRestore.rows } - : undefined - ctx.deps.runtime.seedHeadlessTerminal( - ctx.result.id, - ctx.result.coldRestore.scrollback, - coldRestoreSeedSize, - { - cwd: ctx.result.coldRestore.cwd, - oscLinks: ctx.result.coldRestore.oscLinks, - preferProviderIfExisting: true - } - ) - } else if (typeof ctx.result.replay === 'string' && ctx.result.replay.length > 0) { - // Why: relay reattach replay is the only restore main never ingests; skip this seed and park-reveal would replace it with a suffix fragment. - ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.replay) - } + if (ctx.nativeWindowsConptySpawn) { + markNativeWindowsConptyPty(ctx.result.id) } - // Why after the seed: a seed skips an existing model, and live bytes may have lazily created - // one at the 80x24 default before the spawn reply revealed the session's real grid. - reflowHeadlessTerminalToCommittedGrid({ - result: ctx.result, - committedSize, - reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind( - ctx.deps.runtime - ) - }) + // Seed before the first disk await so live output appends to the restored history. + seedHeadlessTerminalFromSpawnResult(ctx.deps.runtime, ctx.result, ctx.validatedPaneKey) + seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) + const committedSize = await persistPtyIpcSpawnCommit(ctx) if ( typeof args.worktreeId === 'string' && args.worktreeId.length > 0 && @@ -121,7 +76,19 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + if (!(error instanceof Error) || error.message !== 'agent_session_exited_during_start') { + throw error + } + }) + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) + ctx.pendingRegistrationPtyId = null + // The renderer drains this incarnation's buffered output and exit without publishing it live. + return resolvePaneSpawnReservation( + ctx.paneSpawnReservationKey, + ctx.paneSpawnReservation, + ctx.result + ) } ctx.pendingRegistrationPtyId = null } else if (ctx.pendingRegistrationPtyId) { @@ -131,6 +98,15 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise 0) { + const size = + typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number' + ? { cols: result.snapshotCols, rows: result.snapshotRows } + : undefined + runtime.seedHeadlessTerminal(result.id, result.snapshot, size, { + ...(typeof result.snapshotKittyKeyboardFlags === 'number' + ? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags } + : {}), + ...(result.snapshotTerminalOwner ? { terminalOwner: result.snapshotTerminalOwner } : {}) + }) + } else if ( + result.coldRestore && + typeof result.coldRestore.scrollback === 'string' && + result.coldRestore.scrollback.length > 0 + ) { + const size = + typeof result.coldRestore.cols === 'number' && typeof result.coldRestore.rows === 'number' + ? { cols: result.coldRestore.cols, rows: result.coldRestore.rows } + : undefined + runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, size, { + cwd: result.coldRestore.cwd, + oscLinks: result.coldRestore.oscLinks, + preferProviderIfExisting: true + }) + } else if (typeof result.replay === 'string' && result.replay.length > 0) { + runtime.seedHeadlessTerminal(result.id, result.replay) + } +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 9e71ec4fe0c2..39a9f01cef0a 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -16,6 +16,7 @@ import { rendererSerializerReadiness } from '../pane/serializer-state' import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' +import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' @@ -35,6 +36,18 @@ import { registerPersistedPtySpawn } from '../pane/spawn-registration' export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: ctx.daemonShellOverride + }) + ) { + markNativeWindowsConptyPty(ctx.result.id) + } + // Seed before the first disk await so live output appends to the restored history. + seedHeadlessTerminalFromSpawnResult(ctx.deps.runtime, ctx.result, ctx.spawnIdentityPaneKey) + seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) try { ctx.stablePaneBindingPersisted = await persistAdmittedStablePaneBinding({ store: ctx.hostSessionBinding?.store, @@ -58,11 +71,6 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { // reply omits isReattach; derive it once so the size commit and the reservation agree. const adoptedResult = { ...ctx.result, isReattach: true } const owner = ctx.result.agentSessionEnsure.owner - ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) - ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) - if (ctx.result.incarnationId) { - ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) - } const rejectedRegistration = registerPersistedPtySpawn( ctx.deps.runtime, ctx.hostSessionBinding?.store ?? ctx.deps.store, @@ -80,6 +88,11 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (rejectedRegistration) { await rejectedRegistration } + ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) + ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } if (!args.connectionId) { ctx.deps.options?.onCodexHomePtySpawned?.({ id: ctx.result.id, @@ -108,46 +121,6 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { agentSessionEnsure: ctx.result.agentSessionEnsure } } - ptyOwnership.set(ctx.result.id, args.connectionId ?? null) - if (ctx.result.incarnationId) { - ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) - } - // Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero. - if ( - isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: ctx.daemonShellOverride - }) - ) { - markNativeWindowsConptyPty(ctx.result.id) - } - const persistSshLease = (): void => - claimSshPaneLease({ - store: ctx.deps.store, - connectionId: args.connectionId, - ptyId: ctx.result.id, - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: args.leafId - }) - if (!ctx.hostSessionBinding) { - persistSshLease() - } - commitRuntimePtySize(ctx, ctx.result) - if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { - ptySizes.delete(ctx.effectiveSessionAppId) - } - recordCodexPaneAccountForSpawn({ - ptyId: ctx.result.id, - isDaemonHostSpawn: ctx.isDaemonHostSpawn, - isReattach: ctx.result.isReattach === true, - pinnedByResume: ctx.codexResumeHomeSelected, - launchCodexHomePath: ctx.selectedCodexHomePath, - launchEnv: args.env, - target: ctx.codexSelectionTarget, - settings: ctx.deps.getSettings?.() - }) if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) { try { const { store, worktreeId, tabId, leafId, expectedSourceBinding } = ctx.hostSessionBinding @@ -186,10 +159,6 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { agentSessionOperationOutcome: 'unknown' as const }) } - persistSshLease() - } - if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { - ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) } if (args.worktreeId) { const rejectedRegistration = registerPersistedPtySpawn( @@ -222,6 +191,35 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) } + if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { + ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) + } + ptyOwnership.set(ctx.result.id, args.connectionId ?? null) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + claimSshPaneLease({ + store: ctx.deps.store, + connectionId: args.connectionId, + ptyId: ctx.result.id, + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: args.leafId + }) + commitRuntimePtySize(ctx, ctx.result) + if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { + ptySizes.delete(ctx.effectiveSessionAppId) + } + recordCodexPaneAccountForSpawn({ + ptyId: ctx.result.id, + isDaemonHostSpawn: ctx.isDaemonHostSpawn, + isReattach: ctx.result.isReattach === true, + pinnedByResume: ctx.codexResumeHomeSelected, + launchCodexHomePath: ctx.selectedCodexHomePath, + launchEnv: args.env, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). diff --git a/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts index 8e356ed06e47..b1497f049a48 100644 --- a/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts +++ b/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts @@ -80,31 +80,46 @@ describe('maintenance compatibility checkpoint', () => { expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') }) - it('keeps the writer fenced when promotion fails after publishing canonical JSON', async () => { - const { store, authority, dataFile, databaseFile, profileId, readState } = - await createWorkerMaintenanceFixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - await authority.writeJsonCompatibilityExportAsync(dataFile) - const opened = openProfileStateDatabase(databaseFile, profileId) - try { - opened.db.exec(` + it.each(['maintenance', 'update-preflight'] as const)( + 'resumes saving after a rolled-back %s export leaves both JSON versions accepted', + async (phase) => { + const { store, authority, dataFile, databaseFile, profileId, readState } = + await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await authority.writeJsonCompatibilityExportAsync(dataFile) + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + opened.db.exec(` CREATE TRIGGER reject_acceptance BEFORE INSERT ON profile_state_meta WHEN NEW.key = 'legacy_json_acceptance' AND json_type(NEW.value, '$.pending') IS NULL BEGIN SELECT RAISE(ABORT, 'injected promotion failure'); END `) - } finally { - opened.db.close() - } - store.updateSettings({ theme: 'dark' }) + } finally { + opened.db.close() + } + store.updateSettings({ theme: 'dark' }) - await expect(store.beginProfileMaintenance()).rejects.toMatchObject({ - outcome: 'indeterminate' - }) - expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('dark') - expect(readState().settings.theme).toBe('dark') - await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( - 'finalized' - ) - }) + await expect( + phase === 'maintenance' + ? store.beginProfileMaintenance() + : store.writeLatestProfileStateJsonCompatibilityExportAsync() + ).rejects.toMatchObject({ outcome: 'known-failure' }) + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('dark') + expect(readState().settings.theme).toBe('dark') + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + const repaired = openProfileStateDatabase(databaseFile, profileId) + try { + repaired.db.exec('DROP TRIGGER reject_acceptance') + } finally { + repaired.db.close() + } + await (await store.beginProfileMaintenance()).resume() + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') + } + ) }) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts b/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts index 3aee8de1dfb6..31e4d173b6f3 100644 --- a/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts +++ b/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts @@ -64,14 +64,20 @@ function paths(profile?: ProfileFixtureLocation) { } } -export async function createWorkerMaintenanceFixture(profile?: ProfileFixtureLocation) { +export async function createWorkerMaintenanceFixture( + profile?: ProfileFixtureLocation, + onFailure?: (error: Error) => void +) { const input = paths(profile) const bootstrap = new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) bootstrap.writeSerializedState( Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(input.directory))) ) const state = bootstrap.readInitialState().takeParsedState?.() - const authority = new ProfileStateWorkerAuthority(bootstrap.retireForWorker(), workerOptions) + const authority = new ProfileStateWorkerAuthority(bootstrap.retireForWorker(), { + ...workerOptions, + onFailure + }) await authority.ready const store = new Store({ dataFile: input.dataFile, diff --git a/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts index ed260caee2a4..84a849b3b264 100644 --- a/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts +++ b/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts @@ -16,9 +16,9 @@ vi.mock('../../ssh/ssh-config-parser', () => ({ describe('failed maintenance recovery', () => { it.each(['maintenance', 'final', 'freeze'] as const)( - '%s cancels a backup awaited by an earlier admitted flush', + '%s cancels an active backup after a routine flush completes', async (kind) => { - const { store, authority } = await createWorkerMaintenanceFixture() + const { store } = await createWorkerMaintenanceFixture() const started = maintenanceBarrier() const canceled = maintenanceBarrier() vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( @@ -36,14 +36,7 @@ describe('failed maintenance recovery', () => { return { value: undefined } }) await started.promise - const draining = maintenanceBarrier() - const drain = authority.drainBackups.bind(authority) - vi.spyOn(authority, 'drainBackups').mockImplementationOnce((cancel) => { - draining.resolve() - return drain(cancel) - }) - const earlier = store.flushPendingOrThrowAsync() - await draining.promise + await store.flushPendingOrThrowAsync() const stop = kind === 'maintenance' ? store.beginProfileMaintenance() @@ -51,7 +44,7 @@ describe('failed maintenance recovery', () => { ? store.flushFinalOrThrowAsync() : store.freezeWritesAsync() await canceled.promise - await Promise.all([earlier, stop]) + await stop } ) diff --git a/src/main/persistence/loading-store/profile-state-maintenance.test.ts b/src/main/persistence/loading-store/profile-state-maintenance.test.ts index f5dcb451243b..8dcb39eef54f 100644 --- a/src/main/persistence/loading-store/profile-state-maintenance.test.ts +++ b/src/main/persistence/loading-store/profile-state-maintenance.test.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' -import { getActiveViewPreferenceFile } from '../../active-view-preference' +import { ActiveViewPreference, getActiveViewPreferenceFile } from '../../active-view-preference' import * as backupWorker from '../profile-state/profile-state-backup-worker' import { profileStateDatabaseBackups } from '../profile-state/profile-state-backup-path' import { @@ -86,10 +86,12 @@ describe('profile maintenance admission', () => { const { store, authority, readState } = await createWorkerMaintenanceFixture() const started = maintenanceBarrier() const release = maintenanceBarrier() - vi.spyOn(authority, 'drainBackups').mockImplementationOnce(async () => { - started.resolve() - await release.promise - }) + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( + async () => { + started.resolve() + await release.promise + } + ) store.updateSettings({ theme: 'dark' }) const older = store.flushPendingOrThrowAsync() await started.promise @@ -120,10 +122,12 @@ describe('profile maintenance admission', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) const started = maintenanceBarrier() const release = maintenanceBarrier() - vi.spyOn(authority, 'drainBackups').mockImplementationOnce(async () => { - started.resolve() - await release.promise - }) + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( + async () => { + started.resolve() + await release.promise + } + ) store.updateSettings({ theme: 'dark' }) const accepted = store.flushPendingOrThrowAsync() await started.promise @@ -198,10 +202,12 @@ describe('profile maintenance admission', () => { const { store, authority, readState } = await createWorkerMaintenanceFixture() const started = maintenanceBarrier() const release = maintenanceBarrier() - vi.spyOn(authority, 'drainBackups').mockImplementationOnce(async () => { - started.resolve() - await release.promise - }) + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( + async () => { + started.resolve() + await release.promise + } + ) store.updateSettings({ theme: 'dark' }) const accepted = store.flushPendingOrThrowAsync() await started.promise diff --git a/src/main/persistence/loading-store/profile-state-store-backups.test.ts b/src/main/persistence/loading-store/profile-state-store-backups.test.ts index 185b449c5920..2a78bd94782d 100644 --- a/src/main/persistence/loading-store/profile-state-store-backups.test.ts +++ b/src/main/persistence/loading-store/profile-state-store-backups.test.ts @@ -116,6 +116,7 @@ describe('Store automatic SQLite recovery snapshots', () => { await state.store.flushPendingOrThrowAsync() } + await state.authority.drainBackups() expect(scope === 'complete' ? fullWrite : selectiveWrite).toHaveBeenCalledOnce() expect(scope === 'complete' ? selectiveWrite : fullWrite).not.toHaveBeenCalled() const backups = profileStateDatabaseBackups(state.databasePath) @@ -133,6 +134,34 @@ describe('Store automatic SQLite recovery snapshots', () => { ).toEqual(backups.map((backup) => basename(backup.path)).sort()) }) + it('acknowledges a routine flush while the previous recovery backup is still running', async () => { + const state = await fixture() + const realSnapshot = backupExecution.runProfileStateBackup + const started = Promise.withResolvers() + const gate = Promise.withResolvers() + releases.push(gate.resolve) + vi.spyOn(backupExecution, 'runProfileStateBackup').mockImplementationOnce(async (job) => { + started.resolve() + await gate.promise + await realSnapshot(job) + }) + state.store.updateSettings({ theme: 'dark' }) + state.store.flushOrThrow() + await started.promise + state.store.patchWorkspaceSession({ activeWorktreeId: 'newer-than-backup' }) + let settled = false + const flush = state.store.flushPendingOrThrowAsync().then(() => { + settled = true + }) + await vi.waitFor(() => expect(settled).toBe(true)) + expect(readSnapshot(state.databasePath).state.workspaceSession.activeWorktreeId).toBe( + 'newer-than-backup' + ) + expect(profileStateDatabaseBackups(state.databasePath)).toHaveLength(1) + gate.resolve() + await Promise.all([flush, state.authority.drainBackups()]) + }) + it.each(['quit', 'profile mutation'] as const)( '%s waits for its owned backup across Store close', async (kind) => { @@ -201,6 +230,7 @@ describe('Store automatic SQLite recovery snapshots', () => { await expect(state.store.flushPendingOrThrowAsync()).resolves.toBeUndefined() } + await state.authority.drainBackups() expect(snapshot).toHaveBeenCalledOnce() expect(log).toHaveBeenCalledWith( '[persistence] Failed to back up profile state database:', diff --git a/src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts b/src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts new file mode 100644 index 000000000000..dd6754c1d73d --- /dev/null +++ b/src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts @@ -0,0 +1,146 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-types' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'retiring-pty', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +class RetirementRuntime extends OrcaRuntimeService { + readVisibleState() { + return this.readVisibleTerminalState(binding.ptyId) + } + snapshot(): RuntimeMobileSessionTabsSnapshot | undefined { + return this.mobileSessionTabsByWorktree.get(binding.worktreeId) + } + generations(): number { + return this.ptyLifecycleGenerationById.size + } + retirements(): number { + return this.pendingPtySurfaceRetirementsByPtyId.size + } + async closeTab(): Promise { + const snapshot = this.snapshot() + const tab = snapshot?.tabs[0] + if (!snapshot || tab?.type !== 'terminal') { + throw new Error('missing test tab') + } + await this.closeHeadlessMobileTerminalTab(binding.worktreeId, snapshot, tab) + } + publish(layoutOnly = false): void { + this.storeMobileSessionSnapshot(binding.worktreeId, { + worktree: binding.worktreeId, + publicationEpoch: 'retirement-test', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `${binding.tabId}::${binding.leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${binding.tabId}::${binding.leafId}`, + parentTabId: binding.tabId, + leafId: binding.leafId, + ptyId: layoutOnly ? null : binding.ptyId, + ...(layoutOnly + ? { + parentLayout: { + root: { type: 'leaf' as const, leafId: binding.leafId }, + activeLeafId: binding.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [binding.leafId]: binding.ptyId } + } + } + : {}), + title: 'Terminal', + isActive: true + } + ] + }) + } +} + +it.each(['read', 'replacement', 'legacy-replacement'] as const)( + 'publishes an exited terminal retirement with concurrent %s', + async (action) => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + const runtime = new RetirementRuntime(store) + runtime.registerPty(binding.ptyId, binding.worktreeId, null, binding) + runtime.publish() + const gate = authority.pause() + const exiting = runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { + providerExitObserved: true + }) + await gate.started.promise + if (action === 'read') { + await runtime.readVisibleState() + } else if (action === 'legacy-replacement') { + runtime.onPtySpawned(binding.ptyId) + } else { + runtime.onPtySpawned(binding.ptyId, 'dddddddd-dddd-4ddd-8ddd-dddddddddddd') + } + gate.finish.resolve() + await exiting + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + expect(runtime.snapshot()?.tabs).toHaveLength(action === 'read' ? 0 : 1) + if (action === 'read') { + expect(runtime.retirements()).toBe(0) + } + } +) + +it('publishes an exit whose only live ownership is the mobile parent layout', async () => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + const runtime = new RetirementRuntime(store) + runtime.publish(true) + const gate = authority.pause() + const exiting = runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { + providerExitObserved: true + }) + await gate.started.promise + gate.finish.resolve() + await exiting + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + expect(runtime.snapshot()?.tabs).toEqual([]) + expect(runtime.generations()).toBe(0) +}) + +it('completes a durable close and refuses a split queued behind it', async () => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + const runtime = new RetirementRuntime(store) + const kill = vi.fn(() => true) + runtime.setPtyController({ write: () => true, kill, getForegroundProcess: async () => null }) + runtime.registerPty(binding.ptyId, binding.worktreeId, null, binding) + runtime.publish() + const gate = authority.pause() + const close = runtime.closeTab() + await gate.started.promise + const split = store.persistPtyBinding({ + ...binding, + leafId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + ptyId: 'concurrent-split', + expectedSourceBinding: binding + }) + gate.finish.resolve() + await close + await expect(split).resolves.toBe(false) + expect(kill).toHaveBeenCalledExactlyOnceWith(binding.ptyId) + expect(runtime.snapshot()?.tabs).toEqual([]) + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() +}) diff --git a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts index adf6be51d411..ef146779b4bf 100644 --- a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts @@ -9,6 +9,16 @@ import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' import { registerPersistedPtySpawn } from '../../ipc/pty/pane/spawn-registration' import { toSshExecutionHostId } from '../../../shared/execution-host' +import { clearProviderPtyState } from '../../ipc/pty/provider/state-cleanup' +import { ptyOwnership, ptyIncarnationById } from '../../ipc/pty/provider/ownership-state' +import { ptySizes } from '../../ipc/pty/delivery/visibility-state' +import { + paneSpawnReservationsByOwnerKey, + reservePaneSpawn, + reserveIdlePaneSpawn, + resolvePaneSpawnReservation, + type PaneSpawnReservation +} from '../../ipc/pty/pane/spawn-reservation' vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ @@ -19,14 +29,19 @@ vi.mock('../../ssh/ssh-config-parser', () => ({ sshConfigHostsToTargets: () => [] })) -it.each([ - { controller: 'runtime', connectionId: null, exitCode: 0 }, - { controller: 'runtime', connectionId: 'test-host', exitCode: 0 }, - { controller: 'ipc', connectionId: null, exitCode: -1 }, - { controller: 'ipc', connectionId: 'test-host', exitCode: 0 } -])( - 'retires an exited $controller binding on $connectionId after disk finishes', - async ({ controller, connectionId, exitCode }) => { +it.each( + [ + { controller: 'runtime', connectionId: null, exitCode: 0 }, + { controller: 'runtime', connectionId: 'test-host', exitCode: 0 }, + { controller: 'ipc', connectionId: null, exitCode: -1 }, + { controller: 'ipc', connectionId: 'test-host', exitCode: 0 } + ].flatMap((test) => [ + { ...test, stableOwner: false }, + { ...test, stableOwner: true } + ]) +)( + 'retires an exited $controller binding on $connectionId after disk finishes (stable: $stableOwner)', + async ({ controller, connectionId, exitCode, stableOwner }) => { const { store, authority, readState } = await fixture() const runtime = new OrcaRuntimeService(store) const binding = { @@ -38,15 +53,31 @@ it.each([ : 'pty-exited-during-durable-bind', incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' } + const owner = stableOwner + ? { + ...binding, + hasPersistedBinding: true as const, + persistedIncarnationId: 'previous-incarnation' + } + : null + if (owner) { + await store.persistPtyBinding( + { ...binding, incarnationId: owner.persistedIncarnationId }, + connectionId ? toSshExecutionHostId(connectionId) : undefined + ) + } runtime.onPtySpawned(binding.ptyId, binding.incarnationId) runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) runtime.assertPtyRegistrationAllowed(binding.ptyId, binding.incarnationId) let commit: () => Promise + const reservationKey = JSON.stringify([connectionId, binding.worktreeId, binding.leafId]) + let reservation: PaneSpawnReservation | undefined if (controller === 'runtime') { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; its runtime/store are real and preflight-only dependencies are unreachable. const deps = { runtime, store, options: {} } as PtyRuntimeControllerDeps const ctx = createRuntimePtySpawnState(deps, { ...binding, connectionId, cols: 80, rows: 24 }) ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.stablePaneOwner = owner ctx.hostSessionBinding = { store, ...binding } ctx.metadataLeafId = binding.leafId commit = () => commitRuntimePtySpawn(ctx) @@ -55,18 +86,46 @@ it.each([ const deps = { runtime, store } as PtySpawnIpcDeps const ctx = createPtyIpcSpawnState(deps, { ...binding, connectionId, cols: 80, rows: 24 }) ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.stablePaneOwner = owner ctx.metadataLeafId = binding.leafId ctx.validatedLeafId = binding.leafId + reservation = reservePaneSpawn(reservationKey) + ctx.paneSpawnReservationKey = reservationKey + ctx.paneSpawnReservation = reservation commit = () => commitPtyIpcSpawn(ctx) } const gate = authority.pause() - const pending = expect(commit()).rejects.toThrow('agent_session_exited_during_start') + const pending = + controller === 'ipc' + ? expect(commit()).resolves.toMatchObject({ + id: binding.ptyId, + incarnationId: binding.incarnationId + }) + : expect(commit()).rejects.toThrow('agent_session_exited_during_start') await gate.started.promise + const nextReservation = reservation ? reserveIdlePaneSpawn(reservationKey) : undefined + clearProviderPtyState(binding.ptyId) + ptyOwnership.delete(binding.ptyId) await runtime.onPtyExit(binding.ptyId, exitCode, binding.incarnationId, { providerExitObserved: true }) gate.finish.resolve() await pending + expect(ptyOwnership.has(binding.ptyId)).toBe(false) + expect(ptyIncarnationById.has(binding.ptyId)).toBe(false) + expect(ptySizes.has(binding.ptyId)).toBe(false) + expect( + store + .getSshRemotePtyLeases(connectionId ?? undefined) + .some((lease) => lease.ptyId.includes('pty-exited-during-durable-bind')) + ).toBe(false) + if (reservation) { + await expect(reservation.promise).resolves.toMatchObject({ id: binding.ptyId }) + const next = await nextReservation + expect(next).not.toBe(reservation) + resolvePaneSpawnReservation(reservationKey, next, { id: 'next-spawn' }) + expect(paneSpawnReservationsByOwnerKey.has(reservationKey)).toBe(false) + } const state = readState() const session = connectionId ? state.workspaceSessionsByHostId[toSshExecutionHostId(connectionId)] diff --git a/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts new file mode 100644 index 000000000000..1698980c4af4 --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts @@ -0,0 +1,81 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'mobile-pending-spawn', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +class MobileCreateRuntime extends OrcaRuntimeService { + waitForCreatedSurface() { + this.pendingMobileTerminalCreatesByKey.set(`${binding.worktreeId}::${binding.tabId}`, { + activate: true, + paired: true, + selectIfNoActiveTab: true + }) + return this.waitForMobileTerminalSurface(binding.worktreeId, binding.tabId, { + requireReady: true + }) + } +} + +it.each(['ipc', 'runtime'])( + 'publishes the preallocated handle to a pending mobile %s create', + async (controller) => { + const { store, authority } = await fixture() + const runtime = new MobileCreateRuntime(store) + const preAllocatedHandle = runtime.createPreAllocatedTerminalHandle() + const surface = runtime.waitForCreatedSurface() + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + let commit: () => Promise + if (controller === 'ipc') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; its real runtime/store and publication callback are provided. + const deps = { runtime, store, sendPtySpawnedToRenderer: vi.fn() } as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + ctx.preAllocatedHandle = preAllocatedHandle + commit = () => commitPtyIpcSpawn(ctx) + } else { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; its real runtime/store and publication callback are provided. + const deps = { runtime, store, sendPtySpawnedToRenderer: vi.fn() } as PtyRuntimeControllerDeps + const ctx = createRuntimePtySpawnState(deps, { + ...binding, + cols: 80, + rows: 24, + preAllocatedHandle + }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = commit() + await gate.started.promise + gate.finish.resolve() + await pending + const result = await surface + expect(result.tab.terminal).toBe(preAllocatedHandle) + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + } +) diff --git a/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts new file mode 100644 index 000000000000..4596d973190d --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts @@ -0,0 +1,74 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' +import { ptyIncarnationById, deletePtyOwnership } from '../../ipc/pty/provider/ownership-state' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'replaced-during-save', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +const replacementIncarnation = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +afterEach(() => deletePtyOwnership(binding.ptyId)) + +it.each(['ipc', 'runtime'])( + 'keeps replacement provider identity when an exited %s spawn finishes saving', + async (controller) => { + const { store, authority } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + let commit: () => Promise + if (controller === 'ipc') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; the runtime/store are real and preflight-only dependencies are unreachable. + const deps = { runtime, store } as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + commit = () => commitPtyIpcSpawn(ctx) + } else { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; the runtime/store are real and preflight-only dependencies are unreachable. + const deps = { runtime, store } as PtyRuntimeControllerDeps + const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = expect(commit()).rejects.toThrow('agent_session_exited_during_start') + await gate.started.promise + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + runtime.onPtySpawned(binding.ptyId, replacementIncarnation) + runtime.seedHeadlessTerminal(binding.ptyId, 'replacement history', { cols: 112, rows: 37 }) + ptyIncarnationById.set(binding.ptyId, replacementIncarnation) + gate.finish.resolve() + await pending + expect(ptyIncarnationById.get(binding.ptyId)).toBe(replacementIncarnation) + expect(await runtime.serializeMainTerminalBuffer(binding.ptyId)).toMatchObject({ + cols: 112, + rows: 37 + }) + await runtime.onPtyExit(binding.ptyId, 0, replacementIncarnation, { + providerExitObserved: true + }) + } +) diff --git a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts new file mode 100644 index 000000000000..8bb06841c32f --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' +import type { PtySpawnResult } from '../../providers/types' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'restoring-pty', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +const restores: Pick[] = [ + { snapshot: 'restored history\r\n' }, + { + coldRestore: { + scrollback: 'restored history\r\n', + lastTitle: 'Restored', + cwd: '/fixture/local' + } + }, + { replay: 'restored history\r\n' } +] + +function unexpectedPreflight(): never { + throw new Error('Spawn commit must not rerun preflight') +} + +describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => { + it.each(restores)( + 'keeps restored history before output during the binding save: %j', + async (restore) => { + const { store, authority } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + const deps: PtySpawnIpcDeps = { + runtime, + store, + sendPtySpawnedToRenderer: vi.fn(), + getLocalPtyStartupPromise: unexpectedPreflight, + adoptStablePane: unexpectedPreflight, + assertFolderWorkspacePtyPathUsable: unexpectedPreflight, + resolvePtySpawnStartupCwd: unexpectedPreflight, + localStartupCwdDirectoryExists: unexpectedPreflight, + prepareCodexResumeHome: unexpectedPreflight, + noCodexResumeLaunch: unexpectedPreflight, + resolveCodexResumeLaunch: unexpectedPreflight, + reconcileSharedRuntimeResumeHome: unexpectedPreflight, + stripSequencedStartupResumeArgv: unexpectedPreflight, + transitionSpawnHiddenRendererPtyDeliveryState: unexpectedPreflight, + trustedTerminalHandleEnv: new Set(), + syncPtyBackgroundedDelivery: unexpectedPreflight, + stopReplacedPty: unexpectedPreflight + } + let commit: () => Promise + const result = { id: binding.ptyId, incarnationId: binding.incarnationId, ...restore } + if (controller === 'ipc') { + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = result + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + commit = () => commitPtyIpcSpawn(ctx) + } else { + const runtimeDeps: PtyRuntimeControllerDeps = { + ...deps, + getLocalPtyProviderStartupPromise: unexpectedPreflight, + requestSerializedBuffer: unexpectedPreflight, + shutdownProviderAndDetectExit: unexpectedPreflight, + rememberSyntheticKillExit: unexpectedPreflight, + rememberRetiredRejectedPty: unexpectedPreflight, + sendPtyExitToRenderer: unexpectedPreflight, + finishPtyShutdown: unexpectedPreflight, + retiredRejectedPtyIds: new Map(), + reversibleStopOwnersByPtyId: new Map(), + get mainWindow() { + return unexpectedPreflight() + } + } + const ctx = createRuntimePtySpawnState(runtimeDeps, { ...binding, cols: 80, rows: 24 }) + ctx.result = result + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = commit() + await gate.started.promise + runtime.onPtyData(binding.ptyId, 'live output\r\n', Date.now()) + gate.finish.resolve() + await pending + const snapshot = await runtime.serializeMainTerminalBuffer(binding.ptyId) + expect(snapshot?.data).toContain('restored history') + expect(snapshot?.data).toContain('live output') + expect(snapshot?.data.indexOf('restored history')).toBeLessThan( + snapshot?.data.indexOf('live output') ?? -1 + ) + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { + providerExitObserved: true + }) + } + ) +}) diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts index 10007ded90ae..a285d51977ba 100644 --- a/src/main/persistence/loading-store/write-flush-barriers.ts +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -196,10 +196,8 @@ export async function flushCurrentStateAsync( ? runtime.activeViewPreference.flushAsync() : runtime.activeViewPreference.flushPendingAsync(signal)) await writeGithubCacheSnapshotAsync(owner, final, signal) - if (final || drainToStableGeneration) { - await runtime.profileStateAuthority?.drainBackups?.( - final || runtime.profileMaintenancePending - ) + if (final || runtime.profileMaintenancePending) { + await runtime.profileStateAuthority?.drainBackups?.(true) } } if (signal?.aborted) { diff --git a/src/main/persistence/profile-state/profile-state-access-identity.ts b/src/main/persistence/profile-state/profile-state-access-identity.ts index 498856c4f5cb..2d5d3d1fcffb 100644 --- a/src/main/persistence/profile-state/profile-state-access-identity.ts +++ b/src/main/persistence/profile-state/profile-state-access-identity.ts @@ -1,9 +1,7 @@ import { readFileSync } from 'node:fs' import { runProcessSync } from '../../../shared/child-process/run-process' -import { - getProcessStartedAtMs, - parseLinuxProcStartTicks -} from '../../daemon/daemon-process-start-time' +import { parseLinuxProcStartTicks } from '../../daemon/daemon-process-start-time' +import { getPsProcessIdentity } from '../../daemon/daemon-process-identity-query' let bootIdentity: string | null | undefined let machineIdentity: string | null | undefined @@ -77,6 +75,10 @@ function readProcessIdentity(pid: number): string | null { return null } } - const startedAtMs = getProcessStartedAtMs(pid) - return startedAtMs === null ? null : `wall-time-ms:${startedAtMs}` + if (process.platform !== 'darwin') { + return null + } + const startedAtMs = getPsProcessIdentity(pid, { utc: true })?.startedAtMs + // Local wall times are ambiguous during daylight-saving transitions. + return startedAtMs == null ? null : `darwin-utc-start-ms:${startedAtMs}` } diff --git a/src/main/persistence/profile-state/profile-state-access-owner.ts b/src/main/persistence/profile-state/profile-state-access-owner.ts index e8ed7733adbe..9144d22224a2 100644 --- a/src/main/persistence/profile-state/profile-state-access-owner.ts +++ b/src/main/persistence/profile-state/profile-state-access-owner.ts @@ -6,7 +6,6 @@ import { readlinkSync, readdirSync, realpathSync, - renameSync, rmdirSync, unlinkSync, writeFileSync @@ -14,6 +13,11 @@ import { import { hostname } from 'node:os' import { dirname, join } from 'node:path' import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' +import { renameFileWithWindowsRetry } from '../../codex-accounts/fs-utils' +import { + START_TIME_TOLERANCE_MS, + startTimesWithinTolerance +} from '../../daemon/daemon-process-start-time' import { profileStateAccessBootIdentity, profileStateAccessMachineIdentity, @@ -111,7 +115,9 @@ function readOwner(path: string): AccessOwner | undefined { processStartIdentity: 'processStartIdentity' in owner && typeof owner.processStartIdentity === 'string' && - /^(?:linux-start-ticks|wall-time-ms):\d+$/.test(owner.processStartIdentity) && + /^(?:linux-start-ticks|darwin-utc-start-ms|wall-time-ms):\d+$/.test( + owner.processStartIdentity + ) && Number.isSafeInteger(Number(owner.processStartIdentity.split(':')[1])) ? owner.processStartIdentity : null @@ -167,7 +173,13 @@ function ownerExited(owner: AccessOwner): boolean { actualStart !== null && recordedStart != null && actualStart.split(':')[0] === recordedStart.split(':')[0] && - actualStart !== recordedStart + (actualStart.startsWith('darwin-utc-start-ms:') + ? !startTimesWithinTolerance( + Number(actualStart.split(':')[1]), + Number(recordedStart.split(':')[1]), + START_TIME_TOLERANCE_MS + ) + : actualStart !== recordedStart) ) } @@ -258,7 +270,7 @@ export function publishAccessOwner(paths: ProfileStateAccessPaths, exclusive: bo bestEffortFsyncDirectorySync(candidate) for (let attempt = 0; ; attempt += 1) { try { - renameSync(candidate, target) + renameFileWithWindowsRetry(candidate, target) published = true break } catch (error) { diff --git a/src/main/persistence/profile-state/profile-state-access.test.ts b/src/main/persistence/profile-state/profile-state-access.test.ts index fa114535127f..5def614309e3 100644 --- a/src/main/persistence/profile-state/profile-state-access.test.ts +++ b/src/main/persistence/profile-state/profile-state-access.test.ts @@ -353,6 +353,52 @@ describe('profile state owner reclamation', () => { expect(fs.existsSync(owner)).toBe(true) }) + it.each([ + ['darwin-utc-start-ms:100000', 'darwin-utc-start-ms:101000', false], + ['darwin-utc-start-ms:100000', 'darwin-utc-start-ms:101501', true], + ['wall-time-ms:100000', 'darwin-utc-start-ms:3700000', false] + ] as const)('compares macOS start identities safely: %s / %s', (recorded, actual, exited) => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue(actual) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity: recorded + }) + if (exited) { + acquireProfileStateMaintenance(path).release() + expect(fs.existsSync(owner)).toBe(false) + } else { + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + } + }) + + it('retries a transient Windows owner publication lock', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + if (!platform) { + throw new Error('Missing platform descriptor') + } + const path = root() + const rename = fs.renameSync + const publish = vi + .spyOn(fs, 'renameSync') + .mockImplementationOnce(() => { + throw Object.assign(new Error('scanner holds directory'), { code: 'EPERM' }) + }) + .mockImplementation(rename) + vi.spyOn(Atomics, 'wait').mockReturnValue('timed-out') + try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const admission = acquireProfileStateRuntimeAdmission(path) + expect(publish).toHaveBeenCalledTimes(2) + admission.release() + expect(fs.readdirSync(profileStateAccessPaths(path).participants)).toEqual([]) + } finally { + Object.defineProperty(process, 'platform', platform) + } + }) + it('recognizes an exited owner after a hostname change on the same kernel boot', () => { const path = root() vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') diff --git a/src/main/persistence/profile-state/profile-state-authority-exports.ts b/src/main/persistence/profile-state/profile-state-authority-exports.ts index 1d7aafbe3b2d..39e4027415c4 100644 --- a/src/main/persistence/profile-state/profile-state-authority-exports.ts +++ b/src/main/persistence/profile-state/profile-state-authority-exports.ts @@ -15,17 +15,6 @@ import type Database from '../../sqlite/sync-database' import { writeVersionedProfileStateExport } from './profile-state-versioned-export' import { ProfileStateRevisionConflictError } from './profile-state-document-validation' -/** Preparation may leave a recovery export, but cannot change SQLite acceptance or canonical JSON. */ -export class ProfileStateExportPreparationError extends Error { - constructor(cause: unknown) { - super( - `Profile state compatibility export preparation failed: ${cause instanceof Error ? cause.message : String(cause)}`, - { cause } - ) - this.name = 'ProfileStateExportPreparationError' - } -} - function readExportSnapshot(db: Database.Database, expectedRevision?: number) { const snapshot = readProfileStateSnapshot(db) if (expectedRevision !== undefined && snapshot.revision !== expectedRevision) { @@ -56,14 +45,9 @@ export function writeProfileStateAuthorityCompatibilityExport( if (snapshot.revision === 0) { return undefined } - let retained: string | undefined - try { - writeCompatibilityRecoveryExport(targetPath, snapshot) - retained = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : undefined - mkdirSync(dirname(targetPath), { recursive: true }) - } catch (error) { - throw new ProfileStateExportPreparationError(error) - } + writeCompatibilityRecoveryExport(targetPath, snapshot) + const retained = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : undefined + mkdirSync(dirname(targetPath), { recursive: true }) stageProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision, retained) writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, snapshot.json) acceptProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision) @@ -79,19 +63,14 @@ export async function writeProfileStateAuthorityCompatibilityExportAsync( if (snapshot.revision === 0) { return undefined } - let retained: string | undefined - try { - writeCompatibilityRecoveryExport(targetPath, snapshot) - retained = await readFile(targetPath, 'utf8').catch((error: unknown) => { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return undefined - } - throw error - }) - await mkdir(dirname(targetPath), { recursive: true }) - } catch (error) { - throw new ProfileStateExportPreparationError(error) - } + writeCompatibilityRecoveryExport(targetPath, snapshot) + const retained = await readFile(targetPath, 'utf8').catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + }) + await mkdir(dirname(targetPath), { recursive: true }) stageProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision, retained) await writeFileDurable(durableWriteTempPath(targetPath), targetPath, snapshot.json) acceptProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision) diff --git a/src/main/persistence/profile-state/profile-state-current-json-command.test.ts b/src/main/persistence/profile-state/profile-state-current-json-command.test.ts new file mode 100644 index 000000000000..9b3a83d788c7 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-current-json-command.test.ts @@ -0,0 +1,171 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { rollbackProfileState } from './profile-state-recovery-command' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import { migrateProfileStateToSqlite } from './profile-state-migration' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { profileStateJsonExportPath } from './profile-state-export-path' +const roots: string[] = [] +const profileId = 'current-json-recovery' +const originalState = { settings: { theme: 'dark', httpProxyUrl: 'sealed:original' } } +const editedState = { + settings: { + theme: 'light', + httpProxyUrl: 'sealed:older-build', + electronHttp1CompatibilityMode: true + }, + futureDomain: { opaque: [null, '\ud800', { futureKey: 'keep me' }] }, + accounts: { token: 'sealed:account-token' } +} +const editedJson = `${JSON.stringify(editedState, null, 2)}\n` + +beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-current-json-')) + roots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + const exportPath = profileStateJsonExportPath(dataFile, 1) + const originalJson = JSON.stringify(originalState) + writeFileSync(dataFile, originalJson) + const migration = migrateProfileStateToSqlite({ + dataFile, + databaseFile, + profileId, + expectedLegacyJson: originalJson, + serializedState: originalJson + }) + migration.authority.close() + const backupPath = profileStateDatabaseBackupPath( + databaseFile, + createProfileStateDatabaseBackupId() + ) + writeFileSync(backupPath, readFileSync(databaseFile)) + writeFileSync(dataFile, editedJson) + return { root, directory, dataFile, databaseFile, exportPath, backupPath, profileId } +} + +function rollback(profile: ReturnType) { + const maintenance = acquireProfileStateMaintenance(profile.root) + try { + expect(() => acquireProfileStateRuntimeAdmission(profile.root)).toThrow('in use') + return rollbackProfileState(profile.root, { kind: 'current-json' }, maintenance) + } finally { + maintenance.release() + } +} + +function snapshot(profile: ReturnType) { + return [profile.dataFile, profile.databaseFile, profile.exportPath, profile.backupPath].map( + (path) => readFileSync(path) + ) +} + +describe('adopting JSON edited by an older build', () => { + it('preserves both authorities and retained exports before adopting exact JSON bytes', async () => { + const profile = fixture() + const before = snapshot(profile) + const previousQuarantine = join(profile.directory, 'profile-state-corrupt-earlier') + mkdirSync(previousQuarantine) + writeFileSync(join(previousQuarantine, 'evidence'), 'preserve earlier recovery') + expect(() => bootstrapProfileStateAuthority(profile)).toThrow('acceptance marker') + const result = rollback(profile) + expect(result).toMatchObject({ + revision: null, + storage: 'json', + restoredPath: profile.dataFile + }) + expect(readFileSync(profile.dataFile, 'utf8')).toBe(editedJson) + expect(existsSync(profile.databaseFile)).toBe(false) + expect(existsSync(profile.exportPath)).toBe(false) + expect(existsSync(profile.backupPath)).toBe(false) + expect(readFileSync(join(previousQuarantine, 'evidence'), 'utf8')).toBe( + 'preserve earlier recovery' + ) + for (const [index, path] of [ + profile.dataFile, + profile.databaseFile, + profile.exportPath, + profile.backupPath + ].entries()) { + expect(readFileSync(join(result.quarantineDirectory, basename(path)))).toEqual(before[index]) + } + const reopened = bootstrapProfileStateAuthority(profile) + expect(reopened.migrated).toBe(true) + try { + const restored: unknown = JSON.parse(reopened.authority?.readSerializedState() ?? 'null') + expect(restored).toMatchObject(editedState) + } finally { + reopened.authority?.close() + } + acquireProfileStateRuntimeAdmission(profile.root).release() + }) + + it.each(['invalid JSON', '[]', 'null'])( + 'refuses invalid current JSON without changing either authority: %s', + async (raw) => { + const profile = fixture() + writeFileSync(profile.dataFile, raw) + const before = snapshot(profile) + expect(() => rollback(profile)).toThrow('Profile state JSON') + expect(snapshot(profile)).toEqual(before) + } + ) + + it('refuses a missing canonical JSON without choosing a retained export', async () => { + const profile = fixture() + rmSync(profile.dataFile) + const before = readFileSync(profile.databaseFile) + expect(() => rollback(profile)).toThrow('ENOENT') + expect(readFileSync(profile.databaseFile)).toEqual(before) + expect(existsSync(profile.exportPath)).toBe(true) + }) + + it('refuses while a profile owner holds admission', async () => { + const profile = fixture() + const before = snapshot(profile) + const admission = acquireProfileStateRuntimeAdmission(profile.root) + try { + expect(() => rollback(profile)).toThrow('in use') + expect(snapshot(profile)).toEqual(before) + } finally { + admission.release() + } + }) + + it('refuses an unresolved move without removing its journal', async () => { + const profile = fixture() + const before = snapshot(profile) + const moves = join(profile.root, 'profile-move-intents') + mkdirSync(moves) + const journal = join(moves, '00000000-0000-0000-0000-000000000001.json') + writeFileSync(journal, '{"partial":true}') + expect(() => rollback(profile)).toThrow('pending project move') + expect(snapshot(profile)).toEqual(before) + expect(readFileSync(journal, 'utf8')).toBe('{"partial":true}') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts b/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts index e4b057edd3f5..1d8762e55243 100644 --- a/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts +++ b/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts @@ -50,13 +50,15 @@ it('can publish an updater JSON export at a reused revision after SQLite rollbac const profileId = 'rollback-export' const stores: Store[] = [] try { + const originalAuthority = new ProfileStateSqliteAuthority(databasePath, profileId) const original = new Store({ dataFile, - profileStateAuthority: new ProfileStateSqliteAuthority(databasePath, profileId) + profileStateAuthority: originalAuthority }) stores.push(original) original.updateSettings({ theme: 'light' }) await original.flushPendingOrThrowAsync() + await originalAuthority.drainBackups() const backup = profileStateDatabaseBackups(databasePath)[0] expect(backup).toBeDefined() original.updateSettings({ theme: 'dark' }) diff --git a/src/main/persistence/profile-state/profile-state-live-store-factory.ts b/src/main/persistence/profile-state/profile-state-live-store-factory.ts index bfec215106d3..82bf99bf0631 100644 --- a/src/main/persistence/profile-state/profile-state-live-store-factory.ts +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.ts @@ -9,7 +9,11 @@ import { ProfileStateWorkerAuthority } from './profile-state-worker-authority' /** Publish live state only after its exact bootstrap revision has a worker owner. */ export async function createLiveProfileStateStore( options: ProfileStateStoreFactoryOptions, - workerOptions: { workerPath?: string; backupWorkerPath?: string } = {} + workerOptions: { + workerPath?: string + backupWorkerPath?: string + onFailure?: (error: Error) => void + } = {} ): Promise { const { initialState: initial, ...prepared } = prepareProfileStateStore(options) if (!initial) { diff --git a/src/main/persistence/profile-state/profile-state-read-snapshot.ts b/src/main/persistence/profile-state/profile-state-read-snapshot.ts index e6be7ad4e2d6..415c17210717 100644 --- a/src/main/persistence/profile-state/profile-state-read-snapshot.ts +++ b/src/main/persistence/profile-state/profile-state-read-snapshot.ts @@ -1,5 +1,17 @@ import type Database from '../../sqlite/sync-database' +export class ProfileStateReadRollbackError extends Error { + readonly code = 'profile-state-read-rollback-failed' as const + + constructor( + cause: unknown, + readonly rollbackError: unknown + ) { + super('Profile state read failed without releasing its transaction', { cause }) + this.name = 'ProfileStateReadRollbackError' + } +} + /** Reuse a caller's transaction without committing or rolling it back. */ export function withProfileStateReadSnapshot(db: Database.Database, read: () => T): T { const ownsTransaction = !db.isTransaction @@ -13,11 +25,11 @@ export function withProfileStateReadSnapshot(db: Database.Database, read: () } return result } catch (error) { - if (ownsTransaction) { + if (ownsTransaction && db.isTransaction) { try { db.exec('ROLLBACK') - } catch { - // Preserve the original read error if rollback itself is unavailable. + } catch (rollbackError) { + throw new ProfileStateReadRollbackError(error, rollbackError) } } throw error diff --git a/src/main/persistence/profile-state/profile-state-recovery-command.ts b/src/main/persistence/profile-state/profile-state-recovery-command.ts index dca8253528ad..0715f9f3561e 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-command.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-command.ts @@ -54,9 +54,10 @@ export function rollbackProfileState( if (selector.kind === 'sqlite') { return restoreDatabaseBackup(userDataPath, result, selector.backupId, maintenance) } - const revision = selector.revision - const exportPath = profileStateJsonExportPath(result.dataFile, revision) - if (!result.exportPaths.includes(exportPath)) { + const revision = selector.kind === 'json' ? selector.revision : null + const exportPath = + revision === null ? result.dataFile : profileStateJsonExportPath(result.dataFile, revision) + if (revision !== null && !result.exportPaths.includes(exportPath)) { throw new ProfileStateRecoveryCommandError( 'invalid_argument', `Profile-state export revision ${revision} is unavailable. Use profile state exports to inspect retained revisions.` @@ -68,6 +69,7 @@ export function rollbackProfileState( dataFile: result.dataFile, exportPath, profileId: result.profileId, + ...(revision === null ? { reason: 'profile-state-adopt-current-json' } : {}), beforeRestore: () => invalidateHttp1CompatibilityMarker(userDataPath) }) syncHttp1CompatibilityMarkerAfterRollback(userDataPath, result.dataFile, result.profileId) diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.ts b/src/main/persistence/profile-state/profile-state-startup-authority.ts index 1da0d8117955..ef8d597d8de2 100644 --- a/src/main/persistence/profile-state/profile-state-startup-authority.ts +++ b/src/main/persistence/profile-state/profile-state-startup-authority.ts @@ -17,6 +17,7 @@ export type ProfileStateStartupAuthorityOptions = Omit< runtime: ProfileStateStartupRuntime authorityMode: ProfileStateStoreAuthorityMode storageAuthority: AutomationStorageAuthority + onPersistenceFailure?: (error: Error) => void } export class ProfileStateStartupAuthorityError extends Error { @@ -58,5 +59,10 @@ export async function createProfileStateStoreForStartup( ) { throw new ProfileStateStartupAuthorityError() } - return createLiveProfileStateStore(options) + return createLiveProfileStateStore(options, { + onFailure: + options.onPersistenceFailure ?? + ((error) => + console.error('[persistence] Saving has stopped. Restart Orca before continuing.', error)) + }) } diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.test.ts b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts index 699f084883be..e82ee6ba56fa 100644 --- a/src/main/persistence/profile-state/profile-state-startup-failure.test.ts +++ b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts @@ -47,6 +47,8 @@ describe('profile-state startup failure formatting', () => { 'Orca cannot safely choose a profile-state authority: both profile stores are present' ) expect(message).toContain('neither is selected automatically') + expect(message).toContain('orca profile state rollback --current-json') + expect(message).toContain('does not merge') expect(message).toContain('orca profile state exports') expect(message).toContain('orca profile state rollback --backup ') expect( diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.ts b/src/main/persistence/profile-state/profile-state-startup-failure.ts index a4c278bb7e72..6b67b42c145b 100644 --- a/src/main/persistence/profile-state/profile-state-startup-failure.ts +++ b/src/main/persistence/profile-state/profile-state-startup-failure.ts @@ -75,6 +75,7 @@ export function formatProfileStateStartupFailure(error: unknown): string | undef `Orca cannot safely choose a profile-state authority: ${error.message}`, 'An older build may have changed the JSON file. Both copies are preserved; neither is selected automatically.', 'Stop Orca and copy the profile directory before choosing which state to keep.', + 'To keep the current JSON, including edits from an older build, run `orca profile state rollback --current-json`. This archives both copies and does not merge their contents.', 'Run `orca profile state exports` to inspect retained recovery points.', 'Use `orca profile state rollback --backup ` or `orca profile state rollback --revision ` only after selecting the state you want to restore.' ].join('\n') diff --git a/src/main/persistence/profile-state/profile-state-worker-authority.test.ts b/src/main/persistence/profile-state/profile-state-worker-authority.test.ts index 49ea0150cb56..cc42f3505815 100644 --- a/src/main/persistence/profile-state/profile-state-worker-authority.test.ts +++ b/src/main/persistence/profile-state/profile-state-worker-authority.test.ts @@ -17,6 +17,23 @@ vi.mock('../../ssh/ssh-config-parser', () => ({ })) describe('worker authority close admission', () => { + it('reports a failed maintenance resume without accepting a changed database', async () => { + const notify = vi.fn() + const { authority, peer } = await createWorkerMaintenanceFixture(undefined, notify) + const maintenance = await authority.pauseForMaintenance() + const other = peer() + other.writeSerializedDomains([{ domain: 'ui', payload: '{"external":true}' }]) + other.close() + + await expect(maintenance.resume()).rejects.toMatchObject({ + code: 'profile-state-revision-conflict' + }) + expect(notify).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'profile-state-revision-conflict' }) + ) + expect(() => authority.assertWritable()).toThrow() + }) + it('refuses new commands while its existing backup drains', async () => { const { authority, directory, readState } = await createWorkerMaintenanceFixture() const before = readState() diff --git a/src/main/persistence/profile-state/profile-state-worker-authority.ts b/src/main/persistence/profile-state/profile-state-worker-authority.ts index 1c219623d7c3..efd93652c171 100644 --- a/src/main/persistence/profile-state/profile-state-worker-authority.ts +++ b/src/main/persistence/profile-state/profile-state-worker-authority.ts @@ -21,7 +21,11 @@ export class ProfileStateWorkerAuthority implements AsyncProfileStateAuthority { constructor( private readonly initialization: ProfileStateWriterInitialization, - private readonly options: { workerPath?: string; backupWorkerPath?: string } = {} + private readonly options: { + workerPath?: string + backupWorkerPath?: string + onFailure?: (error: Error) => void + } = {} ) { this.writer = new ProfileStateWriteWorkerClient(initialization, options) this.backups = this.createBackups() @@ -117,7 +121,7 @@ export class ProfileStateWorkerAuthority implements AsyncProfileStateAuthority { consumed = true this.writer = new ProfileStateWriteWorkerClient( { ...this.initialization, revision }, - this.options + { ...this.options, reportInitializationFailure: true } ) this.backups = this.createBackups() this.closing = undefined diff --git a/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts b/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts new file mode 100644 index 000000000000..56e21da43e56 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts @@ -0,0 +1,177 @@ +import { build } from 'esbuild' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateJsonAcceptance +} from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' + +let bundleRoot: string +let workerPath: string +const fixtures: { root: string; client: ProfileStateWriteWorkerClient }[] = [] +beforeAll(async () => { + bundleRoot = mkdtempSync(join(tmpdir(), 'orca-export-worker-bundle-')) + workerPath = join(bundleRoot, 'writer.cjs') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts') + ], + outfile: workerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) +afterEach(async () => { + for (const { root, client } of fixtures.splice(0)) { + await client.close().catch(() => {}) + rmSync(root, { recursive: true, force: true }) + } +}) +afterAll(() => rmSync(bundleRoot, { recursive: true, force: true })) + +async function fixture(fault: 'rename' | 'commit' | 'exit' | 'read-rollback' | 'none') { + const root = mkdtempSync(join(tmpdir(), 'orca-export-worker-')) + const databasePath = join(root, 'profile-state.db') + const dataFile = join(root, 'orca-data.json') + const profileId = 'export-failure' + const original = '{"settings":{"theme":"light"}}' + writeFileSync(dataFile, original) + const withDatabase = ( + run: (db: ReturnType['db']) => T + ): T => { + const { db } = openProfileStateDatabase(databasePath, profileId) + try { + return run(db) + } finally { + db.close() + } + } + withDatabase((db) => + importProfileStateJson(db, original, { + acceptedLegacyJsonHash: hashProfileStateJson(original) + }) + ) + const bootstrap = new ProfileStateSqliteAuthority(databasePath, profileId) + bootstrap.readSerializedState() + bootstrap.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"dark"}' }]) + const wrapper = join(root, 'fault-worker.cjs') + const faultSource = + fault === 'commit' || fault === 'read-rollback' + ? ` + const DatabaseSync = process.versions.bun + ? require('bun:sqlite').Database + : require('node:sqlite').DatabaseSync + const { existsSync } = require('node:fs') + let writing = false + const exec = DatabaseSync.prototype.exec + DatabaseSync.prototype.exec = function(sql) { + if (${JSON.stringify(fault)} === 'read-rollback' && + existsSync(${JSON.stringify(join(root, 'armed'))}) && + (sql === 'COMMIT' || sql === 'ROLLBACK')) { + throw new Error('injected read transaction release failure') + } + const result = exec.call(this, sql) + if (sql === 'BEGIN IMMEDIATE' && existsSync(${JSON.stringify(join(root, 'armed'))})) writing = true + if (writing && sql === 'COMMIT') throw new Error('injected post-COMMIT failure') + return result + } + ` + : fault === 'none' + ? '' + : ` + const fs = require('node:fs/promises') + const rename = fs.rename + let injected = false + fs.rename = async function(source, target) { + if (!injected && target === ${JSON.stringify(dataFile)}) { + injected = true + if (${JSON.stringify(fault)} === 'exit') { + await rename(source, target) + process.exit(19) + } + throw Object.assign(new Error('injected publication failure'), { code: 'ENOSPC' }) + } + return rename(source, target) + } + ` + writeFileSync(wrapper, `${faultSource}\nrequire(${JSON.stringify(workerPath)})`) + const onFailure = vi.fn() + const client = new ProfileStateWriteWorkerClient(bootstrap.retireForWorker(), { + workerPath: wrapper, + onFailure + }) + fixtures.push({ root, client }) + await client.ready + writeFileSync(join(root, 'armed'), '') + const readAccepted = () => { + const reader = new ProfileStateSqliteAuthority(databasePath, profileId) + try { + return reader.readAcceptedState(readFileSync(dataFile, 'utf8'))?.takeParsedState?.() + } finally { + reader.close() + } + } + return { client, dataFile, original, withDatabase, readAccepted, onFailure } +} + +it.each(['rename', 'staging', 'promotion'] as const)( + 'keeps the real worker usable after known compatibility %s failure', + async (phase) => { + const f = await fixture(phase === 'rename' ? phase : 'none') + if (phase !== 'rename') { + f.withDatabase((db) => + db.exec(` + CREATE TRIGGER reject_acceptance BEFORE INSERT ON profile_state_meta + WHEN NEW.key = 'legacy_json_acceptance' + ${phase === 'promotion' ? "AND json_type(NEW.value, '$.pending') IS NULL" : ''} + BEGIN SELECT RAISE(ABORT, 'injected marker failure'); END + `) + ) + } + await expect(f.client.writeJsonCompatibilityExportAsync(f.dataFile)).rejects.toMatchObject({ + outcome: 'known-failure' + }) + expect(JSON.parse(readFileSync(f.dataFile, 'utf8')).settings.theme).toBe( + phase === 'promotion' ? 'dark' : 'light' + ) + expect(f.readAccepted()).toEqual({ settings: { theme: 'dark' } }) + expect(f.onFailure).not.toHaveBeenCalled() + expect(await f.client.assertCurrentRevision()).toBe(2) + f.withDatabase((db) => db.exec('DROP TRIGGER IF EXISTS reject_acceptance')) + await f.client.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"system"}' }]) + await f.client.writeJsonCompatibilityExportAsync(f.dataFile) + expect(f.readAccepted()).toEqual({ settings: { theme: 'system' } }) + expect(f.withDatabase(readProfileStateJsonAcceptance)).toEqual({ + jsonHash: hashProfileStateJson(readFileSync(f.dataFile, 'utf8')), + acceptedRevision: 3 + }) + } +) + +it.each(['commit', 'exit', 'read-rollback'] as const)( + 'keeps an unacknowledged export %s fenced even when its files remain recoverable', + async (fault) => { + const f = await fixture(fault) + const failure = await f.client + .writeJsonCompatibilityExportAsync(f.dataFile) + .catch((error: unknown) => error) + expect(failure).toMatchObject({ outcome: 'indeterminate' }) + await expect( + f.client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toBe(failure) + await f.client.close() + expect(f.onFailure).toHaveBeenCalledExactlyOnceWith(failure) + expect(f.readAccepted()).toEqual({ settings: { theme: 'dark' } }) + expect(JSON.parse(readFileSync(f.dataFile, 'utf8')).settings.theme).toBe( + fault === 'exit' ? 'dark' : 'light' + ) + } +) diff --git a/src/main/persistence/profile-state/profile-state-writer-connection.ts b/src/main/persistence/profile-state/profile-state-writer-connection.ts index 41eb703806f6..bd956f48592d 100644 --- a/src/main/persistence/profile-state/profile-state-writer-connection.ts +++ b/src/main/persistence/profile-state/profile-state-writer-connection.ts @@ -36,7 +36,12 @@ export class ProfileStateWriterConnection { constructor( initialization: ProfileStateWriterInitialization, - options: { workerPath?: string; timeoutMs?: number } = {} + private readonly options: { + workerPath?: string + timeoutMs?: number + onFailure?: (error: Error) => void + reportInitializationFailure?: boolean + } = {} ) { this.initialRevision = initialization.revision this.timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS @@ -277,10 +282,21 @@ export class ProfileStateWriterConnection { } private fault(error: Error): void { - this.failure ??= error + if (this.failure) { + return + } + this.failure = error this.settle(undefined, this.failure) if (!this.didExit) { void this.worker?.terminate().catch(() => {}) } + // Startup failures already reject ready; admitted writers must also alert idle callers. + if (this.latestRevision !== undefined || this.options.reportInitializationFailure) { + try { + this.options.onFailure?.(error) + } catch (notificationError) { + console.error('[persistence] Could not report stopped saving:', notificationError) + } + } } } diff --git a/src/main/persistence/profile-state/profile-state-writer-errors.ts b/src/main/persistence/profile-state/profile-state-writer-errors.ts index effff79f3acf..28237862ce2a 100644 --- a/src/main/persistence/profile-state/profile-state-writer-errors.ts +++ b/src/main/persistence/profile-state/profile-state-writer-errors.ts @@ -4,6 +4,7 @@ import { } from './profile-state-document-validation' import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' import { ProfileStateIndeterminateWriteError } from './profile-state-write-transaction' +import { ProfileStateReadRollbackError } from './profile-state-read-snapshot' import type { ProfileStateWriterErrorData, ProfileStateWriterFailureOutcome @@ -22,7 +23,10 @@ export class ProfileStateWriterError extends Error { } export function profileStateWriterFailureOutcome(error: unknown): ProfileStateWriterFailureOutcome { - if (error instanceof ProfileStateIndeterminateWriteError) { + if ( + error instanceof ProfileStateIndeterminateWriteError || + error instanceof ProfileStateReadRollbackError + ) { return 'indeterminate' } if (error instanceof ProfileStateWriterError) { @@ -54,7 +58,8 @@ export function encodeProfileStateWriterError( if ( error instanceof ProfileStateDatabaseOpenError || error instanceof ProfileStateWriterError || - error instanceof ProfileStateIndeterminateWriteError + error instanceof ProfileStateIndeterminateWriteError || + error instanceof ProfileStateReadRollbackError ) { return { code: error.code, message: error.message, outcome } } diff --git a/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts b/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts index 954ebd44d970..80a92f001181 100644 --- a/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' const clients: ProfileStateWriteWorkerClient[] = [] @@ -13,7 +13,12 @@ afterEach(async () => { } }) -function clientFor(response: string, initialization = '{ id: 0, ok: true, revision: 1 }') { +function clientFor( + response: string, + initialization = '{ id: 0, ok: true, revision: 1 }', + onFailure?: (error: Error) => void, + startup = '' +) { const root = mkdtempSync(join(tmpdir(), 'orca-writer-protocol-')) roots.push(root) const workerPath = join(root, 'writer.cjs') @@ -23,11 +28,12 @@ function clientFor(response: string, initialization = '{ id: 0, ok: true, revisi const { parentPort } = require('node:worker_threads') parentPort.postMessage(${initialization}) parentPort.on('message', (request) => { ${response} }) + ${startup} ` ) const client = new ProfileStateWriteWorkerClient( { databasePath: join(root, 'unused.db'), profileId: 'protocol-test', revision: 1 }, - { workerPath, timeoutMs: 1000 } + { workerPath, timeoutMs: 1000, onFailure } ) clients.push(client) return client @@ -83,7 +89,8 @@ describe('writer protocol refuses uncertain acknowledgements', () => { }) it('faults an unanswered request and rejects later work without retry', async () => { - const client = clientFor('') + const notify = vi.fn() + const client = clientFor('', undefined, notify) await client.ready await expect( client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) @@ -91,5 +98,51 @@ describe('writer protocol refuses uncertain acknowledgements', () => { await expect(client.assertCurrentRevision()).rejects.toMatchObject({ code: 'profile-state-writer-timeout' }) + await client.close() + expect(notify).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'profile-state-writer-timeout' }) + ) + }) + + it('reports an idle writer exit even without a subsequent save', async () => { + const notify = vi.fn() + const client = clientFor('', undefined, notify, 'setTimeout(() => process.exit(1), 50)') + await client.ready + await vi.waitFor(() => expect(notify).toHaveBeenCalledOnce()) + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ code: 'profile-state-writer-exit' }) + ) + await client.close() + expect(notify).toHaveBeenCalledOnce() + }) + + it('leaves startup failure reporting to the startup caller', async () => { + const notify = vi.fn() + const client = clientFor('', '{ id: 0, ok: true, revision: 2 }', notify) + await expect(client.ready).rejects.toThrow() + await client.close() + expect(notify).not.toHaveBeenCalled() + }) + + it('does not report saving stopped after a recoverable request failure or clean close', async () => { + const notify = vi.fn() + const client = clientFor( + ` + if (request.command === 'close') { + parentPort.postMessage({ id: request.id, ok: true, revision: 1 }) + parentPort.close() + } else { + parentPort.postMessage({ id: request.id, ok: false, + error: { code: 'SQLITE_BUSY', message: 'busy', outcome: 'known-failure' } }) + } + `, + undefined, + notify + ) + await client.ready + await expect(client.assertCurrentRevision()).rejects.toThrow('busy') + expect(() => client.assertWritable()).not.toThrow() + await client.close() + expect(notify).not.toHaveBeenCalled() }) }) diff --git a/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts b/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts index 9837ca66a024..a63eb83e71d9 100644 --- a/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts +++ b/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts @@ -1,7 +1,6 @@ import { parentPort, workerData } from 'node:worker_threads' import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' import { writeVersionedProfileStateExport } from './profile-state-versioned-export' -import { ProfileStateExportPreparationError } from './profile-state-authority-exports' import { encodeProfileStateWriterError, ProfileStateWriterError @@ -116,14 +115,8 @@ async function accept(value: unknown): Promise { try { reply(await execute(value)) } catch (error) { - const failure = encodeProfileStateWriterError( - error, - !(error instanceof ProfileStateExportPreparationError) && - (value.command === 'export-json' || - value.command === 'export-latest' || - value.command === 'export-compatibility' || - value.command === 'close') - ) + // Export staging accepts both JSON versions; only uncertain SQL outcomes retire the writer. + const failure = encodeProfileStateWriterError(error, value.command === 'close') reply({ id: value.id, ok: false, error: failure }) stopping ||= failure.outcome === 'indeterminate' } finally { diff --git a/src/main/runtime/orca-runtime-fit-override-listeners.ts b/src/main/runtime/orca-runtime-fit-override-listeners.ts index 5ef17ce4c2ea..6d4242d77587 100644 --- a/src/main/runtime/orca-runtime-fit-override-listeners.ts +++ b/src/main/runtime/orca-runtime-fit-override-listeners.ts @@ -74,6 +74,7 @@ export class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequ protected providerSnapshotsWithLiveModeTransition = new WeakSet() protected ptyLifecycleGenerationById = new Map() + protected pendingPtySurfaceRetirementsByPtyId = new Map() protected nextPtyLifecycleGeneration = 1 diff --git a/src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts b/src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts index 9d6e5d56ca4b..a348309a5689 100644 --- a/src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts +++ b/src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts @@ -140,6 +140,7 @@ export class OrcaRuntimeWithInvalidateAllHandlesForPty extends OrcaRuntimeWithRe options: { awaitsRegistration?: boolean } = {} ): void { this.invalidatePtyControllerInventoryForLifecycle(ptyId) + this.pendingPtySurfaceRetirementsByPtyId.delete(ptyId) const existingPty = this.ptysById.get(ptyId) if ( existingPty && diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index b457f76a1384..99fd88373faa 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -75,7 +75,10 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte >() for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { for (const tab of snapshot.tabs) { - if (tab.type === 'terminal' && tab.ptyId === ptyId) { + if ( + tab.type === 'terminal' && + (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId) + ) { exactSurfaceByKey.set(`${worktreeId}\0${tab.parentTabId}\0${tab.leafId}`, { worktreeId, parentTabId: tab.parentTabId, @@ -139,11 +142,8 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.providerVisibleStateByPtyId.delete(ptyId) this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentPromptExplicitStatusFloorByPtyId.delete(ptyId) - // Safe against respawn: `getPtyLifecycleGeneration` lazily mints from the - // monotonic `nextPtyLifecycleGeneration`, so a re-read after this delete - // returns a strictly newer number — never a reused one. Every comparison a - // stale frame makes therefore still fails, exactly as the advance above intends. this.ptyLifecycleGenerationById.delete(ptyId) + this.pendingPtySurfaceRetirementsByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) @@ -223,13 +223,17 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte } else { // Why: permanent process exit is absence, not a starting/sleeping tab. // Retire before publishing so paired clients never persist a ghost. - retirement = this.retireMobileSessionSurfacesForPty( - ptyId, - incarnationId, - exactSurfaces - ).catch((error) => { - console.error('[runtime] failed to publish terminal retirement:', error) - }) + const pendingRetirement = {} + this.pendingPtySurfaceRetirementsByPtyId.set(ptyId, pendingRetirement) + retirement = this.retireMobileSessionSurfacesForPty(ptyId, incarnationId, exactSurfaces) + .catch((error) => { + console.error('[runtime] failed to publish terminal retirement:', error) + }) + .finally(() => { + if (this.pendingPtySurfaceRetirementsByPtyId.get(ptyId) === pendingRetirement) { + this.pendingPtySurfaceRetirementsByPtyId.delete(ptyId) + } + }) } const exitedSurfaces: { handle: string; paneKey: string | null }[] = [] diff --git a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts index d90aa1b33441..bafc280d9574 100644 --- a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts +++ b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts @@ -88,8 +88,8 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim incarnationId: string, exactSurfaces: readonly Pick[] ): Promise { - // Physical cleanup already removed this generation; retirement must not recreate it. - const exitGeneration = this.ptyLifecycleGenerationById.get(ptyId) + // Reads can mint a new frame generation while this independent retirement waits for disk. + const pendingRetirement = this.pendingPtySurfaceRetirementsByPtyId.get(ptyId) const terminalHandle = this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId) ?? undefined const retiredSurfaceByKey = new Map() @@ -125,7 +125,7 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim const currentIncarnation = this.ptysById.get(ptyId)?.incarnationId if ( !persisted || - this.ptyLifecycleGenerationById.get(ptyId) !== exitGeneration || + this.pendingPtySurfaceRetirementsByPtyId.get(ptyId) !== pendingRetirement || (currentIncarnation && currentIncarnation !== incarnationId) ) { return diff --git a/src/main/runtime/orca-runtime-register-pty.ts b/src/main/runtime/orca-runtime-register-pty.ts index 37a5434e5c5b..9c544bd97155 100644 --- a/src/main/runtime/orca-runtime-register-pty.ts +++ b/src/main/runtime/orca-runtime-register-pty.ts @@ -27,6 +27,7 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand isWsl?: boolean ): void { this.assertPtyDidNotExitBeforeRegistration(ptyId, binding?.incarnationId) + this.pendingPtySurfaceRetirementsByPtyId.delete(ptyId) this.invalidatePtyControllerInventoryForLifecycle(ptyId, connectionId) const existingPty = this.ptysById.get(ptyId) const replacementHandle = binding?.terminalHandle?.trim() @@ -143,6 +144,9 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand // Why: the renderer's own PTY spawn is the reliable signal that the pending // mobile create's tab is live; publish its surface main-side (#7587). if (binding && paneKey) { + if (replacementHandle?.startsWith('term_')) { + this.registerPreAllocatedHandleForPty(ptyId, replacementHandle) + } this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, binding.tabId) } } diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 97fda41a31f6..b62bdec19f70 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -53,6 +53,7 @@ import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { reportProfileStateWriteFailure } from './profile-state-write-failure' export async function initializeReadyFoundation(): Promise { logStartupMilestone('app-ready') @@ -145,7 +146,8 @@ export async function initializeReadyFoundation(): Promise { profileId: profile.profile.id, runtime: 'desktop', authorityMode: profileStateAuthorityMode, - storageAuthority: state.isServeMode ? 'runtime' : 'desktop' + storageAuthority: state.isServeMode ? 'runtime' : 'desktop', + onPersistenceFailure: reportProfileStateWriteFailure }) state.profileStateStartup = { backend: profileState.backend, diff --git a/src/main/startup/profile-state-recovery-preflight.test.ts b/src/main/startup/profile-state-recovery-preflight.test.ts index 5517580d3987..4d120a4170ea 100644 --- a/src/main/startup/profile-state-recovery-preflight.test.ts +++ b/src/main/startup/profile-state-recovery-preflight.test.ts @@ -137,30 +137,41 @@ describe('Electron recovery preflight', () => { expect(mocks.exit).not.toHaveBeenCalled() }) - it('restores under both locks with an explicit root even when ordinary singleton checks bypass', () => { - const item = fixture() - mocks.requestSingleInstanceLock.mockImplementation(() => { - expect(mocks.setPath).toHaveBeenCalledWith('userData', item.root) - expect(() => acquireProfileStateRuntimeAdmission(item.root)).toThrow() - expect(readFileSync(item.databaseFile, 'utf8')).toBe('broken database') - return true - }) - expect(runProfileStateRecoveryPreflight(item.argv)).toBe(true) - expect(process.env.ORCA_USER_DATA_PATH).toBe(item.root) - expect(process.env.ORCA_BACKGROUND_LAUNCH).toBe('1') - expect(mocks.background).toHaveBeenCalledOnce() - expect(mocks.requestSingleInstanceLock).toHaveBeenCalledOnce() - expect(response()).toMatchObject({ - ok: true, - result: { storage: 'json', revision: 1, restoredPath: item.dataFile } - }) - expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual(item.restored) - expect(existsSync(item.databaseFile)).toBe(false) - expect(existsSync(item.exportFile)).toBe(false) - expect(mocks.exit).toHaveBeenCalledWith(0) - const runtime = acquireProfileStateRuntimeAdmission(item.root) - runtime.release() - }) + it.each(['json', 'current-json'] as const)( + 'restores %s under both locks despite ordinary singleton bypasses', + (kind) => { + const item = fixture() + if (kind === 'current-json') { + writeFileSync(item.dataFile, JSON.stringify(item.restored)) + item.argv[3] = JSON.stringify({ userDataPath: item.root, selector: { kind } }) + } + mocks.requestSingleInstanceLock.mockImplementation(() => { + expect(mocks.setPath).toHaveBeenCalledWith('userData', item.root) + expect(() => acquireProfileStateRuntimeAdmission(item.root)).toThrow() + expect(readFileSync(item.databaseFile, 'utf8')).toBe('broken database') + return true + }) + expect(runProfileStateRecoveryPreflight(item.argv)).toBe(true) + expect(process.env.ORCA_USER_DATA_PATH).toBe(item.root) + expect(process.env.ORCA_BACKGROUND_LAUNCH).toBe('1') + expect(mocks.background).toHaveBeenCalledOnce() + expect(mocks.requestSingleInstanceLock).toHaveBeenCalledOnce() + expect(response()).toMatchObject({ + ok: true, + result: { + storage: 'json', + revision: kind === 'json' ? 1 : null, + restoredPath: item.dataFile + } + }) + expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual(item.restored) + expect(existsSync(item.databaseFile)).toBe(false) + expect(existsSync(item.exportFile)).toBe(false) + expect(mocks.exit).toHaveBeenCalledWith(0) + const runtime = acquireProfileStateRuntimeAdmission(item.root) + runtime.release() + } + ) it('refuses an old native singleton owner without modifying authority or exports', () => { const item = fixture() diff --git a/src/main/startup/profile-state-write-failure.test.ts b/src/main/startup/profile-state-write-failure.test.ts new file mode 100644 index 000000000000..b434b3ca5730 --- /dev/null +++ b/src/main/startup/profile-state-write-failure.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { reportProfileStateWriteFailure } from './profile-state-write-failure' + +const fixture = vi.hoisted(() => ({ + show: vi.fn(), + background: false, + state: { isServeMode: false } +})) +vi.mock('electron', () => ({ dialog: { showMessageBox: fixture.show } })) +vi.mock('../window/foreground-activation-policy', () => ({ + isBackgroundLaunch: () => fixture.background +})) +vi.mock('./main-process-state', () => ({ mainProcessState: fixture.state })) + +beforeEach(() => { + fixture.background = false + fixture.state.isServeMode = false + fixture.show.mockResolvedValue({ response: 0 }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +it('tells desktop users saving stopped without silently restarting the writer', () => { + reportProfileStateWriteFailure(new Error('worker exited')) + expect(fixture.show).toHaveBeenCalledExactlyOnceWith({ + type: 'error', + title: 'Saving stopped', + message: 'Orca has stopped saving this profile.', + detail: 'Recent changes may not be saved. Restart Orca before continuing.', + buttons: ['OK'] + }) +}) + +it.each(['background', 'serve'])('keeps %s runs free of native dialogs', (mode) => { + fixture.background = mode === 'background' + fixture.state.isServeMode = mode === 'serve' + reportProfileStateWriteFailure(new Error('worker exited')) + expect(fixture.show).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('stopped saving'), + expect.any(Error) + ) +}) + +it('handles a failed dialog without an unhandled rejection', async () => { + fixture.show.mockRejectedValue(new Error('window system unavailable')) + reportProfileStateWriteFailure(new Error('worker exited')) + await vi.waitFor(() => expect(console.warn).toHaveBeenCalled()) +}) diff --git a/src/main/startup/profile-state-write-failure.ts b/src/main/startup/profile-state-write-failure.ts new file mode 100644 index 000000000000..81668513ba64 --- /dev/null +++ b/src/main/startup/profile-state-write-failure.ts @@ -0,0 +1,18 @@ +import { dialog } from 'electron' +import { isBackgroundLaunch } from '../window/foreground-activation-policy' +import { mainProcessState } from './main-process-state' + +/** Report a retired writer without treating unacknowledged state as safe to overwrite. */ +export function reportProfileStateWriteFailure(error: Error): void { + const message = 'Orca has stopped saving this profile.' + const detail = 'Recent changes may not be saved. Restart Orca before continuing.' + console.error(`[persistence] ${message} ${detail}`, error) + if (mainProcessState.isServeMode || isBackgroundLaunch()) { + return + } + void dialog + .showMessageBox({ type: 'error', title: 'Saving stopped', message, detail, buttons: ['OK'] }) + .catch((dialogError) => + console.warn('[persistence] Could not show saving failure:', dialogError) + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts index 7936300fb065..2c521261b73a 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts @@ -10,7 +10,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installIpcPtyWindow, restorePtySpecWindow, - type PtyExitPayload + type PtyExitPayload, + type PtyStreamPayload } from './pty-transport-test-harness' const RECYCLED_PTY_ID = 'ssh:target@@pty-1' @@ -20,11 +21,16 @@ const FRESH_INCARNATION_ID = 'incarnation-of-the-shell-now-attaching' describe('createIpcPtyTransport against a relay-recycled PTY id', () => { const originalWindow = (globalThis as { window?: typeof window }).window let onExit: ((payload: PtyExitPayload) => void) | null = null + let onData: ((payload: PtyStreamPayload) => void) | null = null beforeEach(() => { vi.resetModules() onExit = null + onData = null installIpcPtyWindow(originalWindow, { + data: (callback) => { + onData = callback + }, exit: (callback) => { onExit = callback } @@ -82,6 +88,42 @@ describe('createIpcPtyTransport against a relay-recycled PTY id', () => { expect(paneExit).toHaveBeenCalledWith(3) }) + it('delivers an authentication failure and exit after the main-side binding cleanup', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = vi.mocked(window.api.pty.spawn) + let release!: () => void + const cleanup = new Promise((resolve) => { + release = resolve + }) + spawn.mockImplementationOnce(async () => { + onData?.({ id: RECYCLED_PTY_ID, data: 'Authentication failed: sign in again.\r\n' }) + onExit?.({ id: RECYCLED_PTY_ID, code: 1, incarnationId: FRESH_INCARNATION_ID }) + await cleanup + return { id: RECYCLED_PTY_ID, incarnationId: FRESH_INCARNATION_ID } + }) + const output = vi.fn() + const exit = vi.fn() + const error = vi.fn() + const connected = vi.fn() + const transport = createIpcPtyTransport() + const pending = transport.connect({ + url: '', + callbacks: { + onData: output, + onExit: exit, + onError: error, + onConnect: connected + } + }) + release() + expect(await pending).toEqual({ id: RECYCLED_PTY_ID, exitedBeforeAttach: true }) + expect(output).toHaveBeenCalledWith('Authentication failed: sign in again.\r\n') + expect(exit).toHaveBeenCalledWith(1) + expect(output.mock.invocationCallOrder[0]).toBeLessThan(exit.mock.invocationCallOrder[0]) + expect(error).not.toHaveBeenCalled() + expect(connected).not.toHaveBeenCalled() + }) + // Absence is unknown, never a mismatch — so an SSH host predating the field, and the relay's own // unnamed `{ id, code: -1 }` drop, keep exactly the behaviour #16970 shipped. (`remote:` runtime // PTYs are not covered by this: their exits never traverse `pty:exit` at all.) diff --git a/src/shared/cli-argument-boundary.ts b/src/shared/cli-argument-boundary.ts index f4252c03e4ac..a3ad092c3c83 100644 --- a/src/shared/cli-argument-boundary.ts +++ b/src/shared/cli-argument-boundary.ts @@ -9,6 +9,7 @@ export const CLI_BOOLEAN_FLAGS = new Set([ 'comments', 'connect', 'current', + 'current-json', 'debug', 'dry-run', 'enter', diff --git a/src/shared/profile-state-recovery-command.test.ts b/src/shared/profile-state-recovery-command.test.ts new file mode 100644 index 000000000000..8036ecac0859 --- /dev/null +++ b/src/shared/profile-state-recovery-command.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { profileStateRecoveryRequestSchema } from './profile-state-recovery-command' + +describe('offline recovery source selection', () => { + it.each([ + { kind: 'current-json' }, + { kind: 'json', revision: 1 }, + { kind: 'sqlite', backupId: 'selected-backup' } + ])('accepts an explicit source: %j', (selector) => { + expect( + profileStateRecoveryRequestSchema.parse({ userDataPath: '/profile', selector }).selector + ).toEqual(selector) + }) + + it.each([ + { kind: 'current-json', revision: 1 }, + { kind: 'current-json', backupId: 'selected-backup' }, + { kind: 'current-json', path: '../different-profile/orca-data.json' }, + { kind: 'json', revision: null }, + { kind: 'json', revision: 0 }, + { kind: 'sqlite' }, + { kind: 'json' } + ])('rejects ambiguous or incomplete sources: %j', (selector) => { + expect( + profileStateRecoveryRequestSchema.safeParse({ userDataPath: '/profile', selector }).success + ).toBe(false) + }) +}) diff --git a/src/shared/profile-state-recovery-command.ts b/src/shared/profile-state-recovery-command.ts index a7733017567b..726e034ef937 100644 --- a/src/shared/profile-state-recovery-command.ts +++ b/src/shared/profile-state-recovery-command.ts @@ -6,6 +6,7 @@ export const PROFILE_STATE_RECOVERY_RESULT_PREFIX = '[profile-state-recovery] ' const positiveInteger = z.number().int().positive().max(Number.MAX_SAFE_INTEGER) const selectorSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('json'), revision: positiveInteger }).strict(), + z.object({ kind: z.literal('current-json') }).strict(), z.object({ kind: z.literal('sqlite'), backupId: z.string().min(1) }).strict() ]) @@ -26,7 +27,8 @@ const exportsSchema = z.object({ .readonly() }) const rollbackSchema = exportsSchema.extend({ - revision: positiveInteger, + // Canonical JSON edited outside SQLite has no database revision. + revision: positiveInteger.nullable(), quarantineDirectory: z.string(), removedDatabaseFiles: z.array(z.string()).readonly(), storage: z.enum(['json', 'sqlite']), From 19057c75393bd09b8c5d379a7788a6f756284edc Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 14:36:11 -0700 Subject: [PATCH 40/43] test: qualify persistence fixes independently of Bun --- .../profile-state-store-backups.test.ts | 14 +++--- .../pty-spawn-commit-dependencies-fixture.ts | 45 +++++++++++++++++++ .../pty-spawn-handle-publication.test.ts | 8 +--- .../pty-spawn-restore-durability.test.ts | 44 ++---------------- 4 files changed, 58 insertions(+), 53 deletions(-) create mode 100644 src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts diff --git a/src/main/persistence/loading-store/profile-state-store-backups.test.ts b/src/main/persistence/loading-store/profile-state-store-backups.test.ts index 2a78bd94782d..f8214c937110 100644 --- a/src/main/persistence/loading-store/profile-state-store-backups.test.ts +++ b/src/main/persistence/loading-store/profile-state-store-backups.test.ts @@ -136,15 +136,17 @@ describe('Store automatic SQLite recovery snapshots', () => { it('acknowledges a routine flush while the previous recovery backup is still running', async () => { const state = await fixture() - const realSnapshot = backupExecution.runProfileStateBackup + const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync const started = Promise.withResolvers() const gate = Promise.withResolvers() releases.push(gate.resolve) - vi.spyOn(backupExecution, 'runProfileStateBackup').mockImplementationOnce(async (job) => { - started.resolve() - await gate.promise - await realSnapshot(job) - }) + vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockImplementationOnce( + async (db, target) => { + started.resolve() + await gate.promise + await realSnapshot(db, target) + } + ) state.store.updateSettings({ theme: 'dark' }) state.store.flushOrThrow() await started.promise diff --git a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts new file mode 100644 index 000000000000..b1d185c38b19 --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts @@ -0,0 +1,45 @@ +import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' +import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { Store } from './store' + +function unexpectedPreflight(): never { + throw new Error('Spawn commit must not rerun preflight') +} + +export function createPtySpawnCommitDependencies( + runtime: OrcaRuntimeService, + store: Store +): PtySpawnIpcDeps & PtyRuntimeControllerDeps { + return { + runtime, + store, + sendPtySpawnedToRenderer: () => {}, + getLocalPtyStartupPromise: unexpectedPreflight, + getLocalPtyProviderStartupPromise: unexpectedPreflight, + adoptStablePane: unexpectedPreflight, + assertFolderWorkspacePtyPathUsable: unexpectedPreflight, + resolvePtySpawnStartupCwd: unexpectedPreflight, + localStartupCwdDirectoryExists: unexpectedPreflight, + prepareCodexResumeHome: unexpectedPreflight, + noCodexResumeLaunch: unexpectedPreflight, + resolveCodexResumeLaunch: unexpectedPreflight, + reconcileSharedRuntimeResumeHome: unexpectedPreflight, + stripSequencedStartupResumeArgv: unexpectedPreflight, + transitionSpawnHiddenRendererPtyDeliveryState: unexpectedPreflight, + trustedTerminalHandleEnv: new Set(), + syncPtyBackgroundedDelivery: unexpectedPreflight, + stopReplacedPty: unexpectedPreflight, + requestSerializedBuffer: unexpectedPreflight, + shutdownProviderAndDetectExit: unexpectedPreflight, + rememberSyntheticKillExit: unexpectedPreflight, + rememberRetiredRejectedPty: unexpectedPreflight, + sendPtyExitToRenderer: unexpectedPreflight, + finishPtyShutdown: unexpectedPreflight, + retiredRejectedPtyIds: new Map(), + reversibleStopOwnersByPtyId: new Map(), + get mainWindow() { + return unexpectedPreflight() + } + } +} diff --git a/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts index 1698980c4af4..9fb0d65a2cb8 100644 --- a/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts @@ -3,10 +3,9 @@ import { fixture } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' -import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' -import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-fixture' vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ @@ -45,10 +44,9 @@ it.each(['ipc', 'runtime'])( const preAllocatedHandle = runtime.createPreAllocatedTerminalHandle() const surface = runtime.waitForCreatedSurface() runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + const deps = createPtySpawnCommitDependencies(runtime, store) let commit: () => Promise if (controller === 'ipc') { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; its real runtime/store and publication callback are provided. - const deps = { runtime, store, sendPtySpawnedToRenderer: vi.fn() } as PtySpawnIpcDeps const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } ctx.metadataLeafId = binding.leafId @@ -56,8 +54,6 @@ it.each(['ipc', 'runtime'])( ctx.preAllocatedHandle = preAllocatedHandle commit = () => commitPtyIpcSpawn(ctx) } else { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; its real runtime/store and publication callback are provided. - const deps = { runtime, store, sendPtySpawnedToRenderer: vi.fn() } as PtyRuntimeControllerDeps const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, diff --git a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts index 8bb06841c32f..26737df9dd0d 100644 --- a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts @@ -3,11 +3,10 @@ import { fixture } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' -import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' import type { PtySpawnResult } from '../../providers/types' import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' -import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-fixture' vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ @@ -37,10 +36,6 @@ const restores: Pick[] = { replay: 'restored history\r\n' } ] -function unexpectedPreflight(): never { - throw new Error('Spawn commit must not rerun preflight') -} - describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => { it.each(restores)( 'keeps restored history before output during the binding save: %j', @@ -48,25 +43,7 @@ describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => { const { store, authority } = await fixture() const runtime = new OrcaRuntimeService(store) runtime.onPtySpawned(binding.ptyId, binding.incarnationId) - const deps: PtySpawnIpcDeps = { - runtime, - store, - sendPtySpawnedToRenderer: vi.fn(), - getLocalPtyStartupPromise: unexpectedPreflight, - adoptStablePane: unexpectedPreflight, - assertFolderWorkspacePtyPathUsable: unexpectedPreflight, - resolvePtySpawnStartupCwd: unexpectedPreflight, - localStartupCwdDirectoryExists: unexpectedPreflight, - prepareCodexResumeHome: unexpectedPreflight, - noCodexResumeLaunch: unexpectedPreflight, - resolveCodexResumeLaunch: unexpectedPreflight, - reconcileSharedRuntimeResumeHome: unexpectedPreflight, - stripSequencedStartupResumeArgv: unexpectedPreflight, - transitionSpawnHiddenRendererPtyDeliveryState: unexpectedPreflight, - trustedTerminalHandleEnv: new Set(), - syncPtyBackgroundedDelivery: unexpectedPreflight, - stopReplacedPty: unexpectedPreflight - } + const deps = createPtySpawnCommitDependencies(runtime, store) let commit: () => Promise const result = { id: binding.ptyId, incarnationId: binding.incarnationId, ...restore } if (controller === 'ipc') { @@ -76,22 +53,7 @@ describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => { ctx.validatedLeafId = binding.leafId commit = () => commitPtyIpcSpawn(ctx) } else { - const runtimeDeps: PtyRuntimeControllerDeps = { - ...deps, - getLocalPtyProviderStartupPromise: unexpectedPreflight, - requestSerializedBuffer: unexpectedPreflight, - shutdownProviderAndDetectExit: unexpectedPreflight, - rememberSyntheticKillExit: unexpectedPreflight, - rememberRetiredRejectedPty: unexpectedPreflight, - sendPtyExitToRenderer: unexpectedPreflight, - finishPtyShutdown: unexpectedPreflight, - retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), - get mainWindow() { - return unexpectedPreflight() - } - } - const ctx = createRuntimePtySpawnState(runtimeDeps, { ...binding, cols: 80, rows: 24 }) + const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) ctx.result = result ctx.metadataLeafId = binding.leafId ctx.hostSessionBinding = { store, ...binding } From 7034db2f7f5442bf18a1a15c30f9b55a77213924 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 15:03:35 -0700 Subject: [PATCH 41/43] fix: preserve live terminals through failed binding saves --- ...ervice-reset-credit-target-routing.test.ts | 17 ++- .../pty-serializer-settlement-mapping.test.ts | 5 +- src/main/ipc/pty/ipc/spawn-commit-persist.ts | 19 +-- src/main/ipc/pty/ipc/spawn-commit.ts | 3 +- src/main/ipc/pty/pane/spawn-registration.ts | 47 +++++++ src/main/ipc/pty/runtime/spawn-commit.ts | 20 ++- .../pty-reattach-failure-routing.test.ts | 122 ++++++++++++++++++ .../pty-spawn-replacement-durability.test.ts | 78 ++++++++++- src/main/runtime/orca-runtime-register-pty.ts | 5 +- ...dle-name-only-real-pty.integration.test.ts | 6 +- 10 files changed, 282 insertions(+), 40 deletions(-) create mode 100644 src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts diff --git a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts index f5b130ac8a6c..1485794c5f97 100644 --- a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts +++ b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { existsSync } from 'node:fs' import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' import { createManagedHome, @@ -414,7 +415,7 @@ describe('CodexAccountService config sync', () => { expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) }) - it('keeps reset attempts fail-closed when removal cannot persist their purge', async () => { + it('reports account removal while keeping reset attempts guarded after a failed purge', async () => { const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') const account = { id: 'account-1', @@ -459,11 +460,17 @@ describe('CodexAccountService config sync', () => { } as never, createRuntimeHome() as never ) - vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockRejectedValueOnce( - new Error('disk full') - ) + const failure = new Error('disk full') + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockRejectedValueOnce(failure) - await expect(service.removeAccount('account-1')).rejects.toThrow('disk full') + await expect(service.removeAccount('account-1')).resolves.toMatchObject({ accounts: [] }) + expect(store.getSettings().codexManagedAccounts).toEqual([]) + expect(existsSync(managedHomePath)).toBe(false) + expect(warning).toHaveBeenCalledWith( + '[codex-accounts] Removed account, but credit ledger cleanup failed:', + failure + ) await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow('unknown outcome') expect(consume).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index 47082d6e745c..f8c7df570534 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -248,7 +248,10 @@ describe('registerPtyHandlers', () => { }) ).rejects.toThrow(/ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED/) - expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { immediate: true }) + expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { + immediate: true, + expectedIncarnationId: incarnationId + }) expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled() expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled() expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId) diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 692bb30c6721..5d9ad086920e 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -9,10 +9,10 @@ import { pendingPtyIdBySerializerGeneration, rendererSerializerReadiness } from '../pane/serializer-state' -import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' import { resolveCommittedPtySize, type PtyGrid } from '../delivery/attached-pty-size' -import { clearProviderPtyState } from '../provider/state-cleanup' +import { discardUnpersistedPtySpawn } from '../pane/spawn-registration' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { PtyIpcSpawnState } from './spawn-state' @@ -68,18 +68,11 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise

{ + if (args.connectionId && ctx.deps.store) { + ctx.deps.store.removeSshRemotePtyLease(args.connectionId, relayResultId) } - clearProviderPtyState(ctx.result.id) - deletePtyOwnership(ctx.result.id) - } - if (!ctx.result.isReattach && args.connectionId && ctx.deps.store) { - ctx.deps.store.removeSshRemotePtyLease(args.connectionId, relayResultId) - } + }) throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { agentSessionOperationOutcome: 'unknown' as const }) diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 56f59ed8ce46..77491c9fb11b 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -18,13 +18,14 @@ import { } from '../pane/launch-authority' import type { PtyIpcSpawnState } from './spawn-state' import { persistPtyIpcSpawnCommit, publishPtyIpcSpawnCommit } from './spawn-commit-persist' -import { registerPersistedPtySpawn } from '../pane/spawn-registration' +import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/spawn-registration' import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args + admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) if (ctx.nativeWindowsConptySpawn) { markNativeWindowsConptyPty(ctx.result.id) } diff --git a/src/main/ipc/pty/pane/spawn-registration.ts b/src/main/ipc/pty/pane/spawn-registration.ts index 4427e75dc152..d96154f7df86 100644 --- a/src/main/ipc/pty/pane/spawn-registration.ts +++ b/src/main/ipc/pty/pane/spawn-registration.ts @@ -1,7 +1,54 @@ import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' +import { isCurrentPtyExit, ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { clearProviderPtyState } from '../provider/state-cleanup' import { retirePersistedStablePaneOwner } from './stable-owner' +export function admitPtyReattachOwnership( + runtime: OrcaRuntimeService | undefined, + result: PtySpawnResult, + connectionId: string | null | undefined +): void { + if (!result.isReattach && result.agentSessionEnsure?.disposition !== 'adopted') { + return + } + runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId) + // A failed local save must not strand a live process already admitted by its host. + ptyOwnership.set(result.id, connectionId ?? ptyOwnership.get(result.id) ?? null) + if (result.incarnationId) { + ptyIncarnationById.set(result.id, result.incarnationId) + } +} + +export async function discardUnpersistedPtySpawn( + provider: IPtyProvider, + result: PtySpawnResult, + onDiscarded?: () => void +): Promise { + if ( + result.isReattach || + result.agentSessionEnsure?.disposition === 'adopted' || + !isCurrentPtyExit(result) + ) { + return + } + try { + await provider.shutdown(result.id, { + immediate: true, + ...(result.incarnationId ? { expectedIncarnationId: result.incarnationId } : {}) + }) + } catch (error) { + console.warn('[pty] failed to clean up PTY after persistence failure:', error) + } + // A replacement may arrive while the execution host finishes shutting down the predecessor. + if (isCurrentPtyExit(result)) { + clearProviderPtyState(result.id) + ptyOwnership.delete(result.id) + onDiscarded?.() + } +} + // Successful registration must not yield before the remaining spawn publication. export function registerPersistedPtySpawn( runtime: OrcaRuntimeService | undefined, diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 39a9f01cef0a..856039e7d5a3 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -1,5 +1,5 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' import { commitRuntimePtySize } from './spawn-commit-pty-size' import { @@ -26,15 +26,19 @@ import { } from '../../../runtime/terminal-model-query-authority' import { toSshExecutionHostId } from '../../../../shared/execution-host' import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' -import { clearProviderPtyState } from '../provider/state-cleanup' import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { RuntimePtySpawnState } from './spawn-state' -import { registerPersistedPtySpawn } from '../pane/spawn-registration' +import { + admitPtyReattachOwnership, + discardUnpersistedPtySpawn, + registerPersistedPtySpawn +} from '../pane/spawn-registration' export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args + admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) if ( isNativeWindowsLocalPtySpawn({ @@ -143,15 +147,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { } } catch (err) { console.error('[pty] failed to persist runtime PTY binding after spawn:', err) - if (!ctx.result.isReattach) { - deletePtyOwnership(ctx.result.id) - try { - await ctx.provider.shutdown(ctx.result.id, { immediate: true }) - } catch (shutdownErr) { - console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) - } - clearProviderPtyState(ctx.result.id) - } + await discardUnpersistedPtySpawn(ctx.provider, ctx.result) if (err instanceof Error && err.message === 'terminal_split_source_not_found') { throw err } diff --git a/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts new file mode 100644 index 000000000000..16f2978e04b5 --- /dev/null +++ b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-fixture' +import { clearProviderPtyState } from '../../ipc/pty/provider/state-cleanup' +import { ptyIncarnationById, ptyOwnership } from '../../ipc/pty/provider/ownership-state' +import { toSshExecutionHostId } from '../../../shared/execution-host' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const connectionId = 'reattach-host' +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'ssh:reattach-host@@surviving-pty' +} +const incarnation = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const successorIncarnation = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +afterEach(() => { + clearProviderPtyState(binding.ptyId) + ptyOwnership.delete(binding.ptyId) +}) + +describe.each(['ipc', 'runtime'])('%s failed reattach routing', (controller) => { + it.each( + [undefined, incarnation].flatMap((incarnationId) => + ['live', 'exited', 'replaced'].map((outcome) => ({ incarnationId, outcome })) + ) + )( + 'preserves host evidence after a failed save: $outcome, $incarnationId', + async ({ incarnationId, outcome }) => { + const { store, authority, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.onPtySpawned(binding.ptyId, incarnationId) + const deps = createPtySpawnCommitDependencies(runtime, store) + const publish = vi.spyOn(deps, 'sendPtySpawnedToRenderer') + const result = { id: binding.ptyId, incarnationId, isReattach: true } + let commit: () => Promise + let shutdown: ReturnType + if (controller === 'ipc') { + const ctx = createPtyIpcSpawnState(deps, { + ...binding, + connectionId, + cols: 80, + rows: 24 + }) + ctx.result = result + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + shutdown = vi.spyOn(ctx.provider, 'shutdown') + commit = () => commitPtyIpcSpawn(ctx) + } else { + const ctx = createRuntimePtySpawnState(deps, { + ...binding, + connectionId, + cols: 80, + rows: 24 + }) + ctx.result = result + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + shutdown = vi.spyOn(ctx.provider, 'shutdown') + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = expect(commit()).rejects.toThrow('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED') + await gate.started.promise + const ownerWhileSaving = ptyOwnership.get(binding.ptyId) + if (outcome !== 'live') { + clearProviderPtyState(binding.ptyId) + ptyOwnership.delete(binding.ptyId) + await runtime.onPtyExit(binding.ptyId, 0, incarnationId, { providerExitObserved: true }) + if (outcome === 'replaced') { + runtime.onPtySpawned(binding.ptyId, successorIncarnation) + ptyOwnership.set(binding.ptyId, 'successor-host') + ptyIncarnationById.set(binding.ptyId, successorIncarnation) + } + } + gate.finish.reject(new Error('disk full')) + await pending + expect(ownerWhileSaving).toBe(connectionId) + expect(ptyOwnership.get(binding.ptyId)).toBe( + outcome === 'live' ? connectionId : outcome === 'replaced' ? 'successor-host' : undefined + ) + expect(ptyIncarnationById.get(binding.ptyId)).toBe( + outcome === 'live' + ? incarnationId + : outcome === 'replaced' + ? successorIncarnation + : undefined + ) + expect(shutdown).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + expect(store.getSshRemotePtyLeases(connectionId)).toEqual([]) + const session = readState().workspaceSessionsByHostId[toSshExecutionHostId(connectionId)] + expect( + session?.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId] + ).toBeUndefined() + if (outcome !== 'exited') { + await runtime.onPtyExit( + binding.ptyId, + 0, + outcome === 'replaced' ? successorIncarnation : incarnationId, + { providerExitObserved: true } + ) + } + } + ) +}) diff --git a/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts index 4596d973190d..719a3bbe9242 100644 --- a/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from 'vitest' -import { fixture } from './profile-state-delayed-authority-fixture' +import { deferred, fixture } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' @@ -7,7 +7,9 @@ import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' -import { ptyIncarnationById, deletePtyOwnership } from '../../ipc/pty/provider/ownership-state' +import { ptyIncarnationById, ptyOwnership } from '../../ipc/pty/provider/ownership-state' +import { clearProviderPtyState } from '../../ipc/pty/provider/state-cleanup' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-fixture' vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ @@ -27,7 +29,77 @@ const binding = { } const replacementIncarnation = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' -afterEach(() => deletePtyOwnership(binding.ptyId)) +afterEach(() => { + clearProviderPtyState(binding.ptyId) + ptyOwnership.delete(binding.ptyId) +}) + +it.each( + ['ipc', 'runtime'].flatMap((controller) => + ['save', 'shutdown'].map((replacementDuring) => ({ controller, replacementDuring })) + ) +)( + 'preserves a successor during $replacementDuring when the predecessor $controller save fails', + async ({ controller, replacementDuring }) => { + const { store, authority } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + ptyIncarnationById.set(binding.ptyId, binding.incarnationId) + const deps = createPtySpawnCommitDependencies(runtime, store) + const shutdownStarted = deferred() + const shutdownFinished = deferred() + const holdShutdown = async () => { + shutdownStarted.resolve() + await shutdownFinished.promise + } + let commit: () => Promise + let shutdown: ReturnType + if (controller === 'ipc') { + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + shutdown = vi.spyOn(ctx.provider, 'shutdown').mockImplementation(holdShutdown) + commit = () => commitPtyIpcSpawn(ctx) + } else { + const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + shutdown = vi.spyOn(ctx.provider, 'shutdown').mockImplementation(holdShutdown) + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = expect(commit()).rejects.toThrow('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED') + await gate.started.promise + if (replacementDuring === 'shutdown') { + gate.finish.reject(new Error('disk full')) + await shutdownStarted.promise + } + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + runtime.onPtySpawned(binding.ptyId, replacementIncarnation) + ptyIncarnationById.set(binding.ptyId, replacementIncarnation) + ptyOwnership.set(binding.ptyId, 'successor-host') + if (replacementDuring === 'save') { + gate.finish.reject(new Error('disk full')) + } + shutdownFinished.resolve() + await pending + if (replacementDuring === 'save') { + expect(shutdown).not.toHaveBeenCalled() + } else { + expect(shutdown).toHaveBeenCalledExactlyOnceWith(binding.ptyId, { + immediate: true, + expectedIncarnationId: binding.incarnationId + }) + } + expect(ptyIncarnationById.get(binding.ptyId)).toBe(replacementIncarnation) + expect(ptyOwnership.get(binding.ptyId)).toBe('successor-host') + await runtime.onPtyExit(binding.ptyId, 0, replacementIncarnation, { + providerExitObserved: true + }) + } +) it.each(['ipc', 'runtime'])( 'keeps replacement provider identity when an exited %s spawn finishes saving', diff --git a/src/main/runtime/orca-runtime-register-pty.ts b/src/main/runtime/orca-runtime-register-pty.ts index 9c544bd97155..31c1bc386279 100644 --- a/src/main/runtime/orca-runtime-register-pty.ts +++ b/src/main/runtime/orca-runtime-register-pty.ts @@ -144,7 +144,10 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand // Why: the renderer's own PTY spawn is the reliable signal that the pending // mobile create's tab is live; publish its surface main-side (#7587). if (binding && paneKey) { - if (replacementHandle?.startsWith('term_')) { + if ( + replacementHandle?.startsWith('term_') && + this.handleByPtyId.get(ptyId) !== replacementHandle + ) { this.registerPreAllocatedHandleForPty(ptyId, replacementHandle) } this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, binding.tabId) diff --git a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts index 0d265a0a5bbb..562ab23ee3ec 100644 --- a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts +++ b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts @@ -109,10 +109,8 @@ async function terminalWait( describe.skipIf(process.platform === 'win32')('tui-idle against a real agent pty', () => { it('does not satisfy while the real process streams under a name-only title', async () => { const { runtime, transcript, handle } = await startRealAgentPane('quiet', 60_000) - await new Promise((resolve) => setTimeout(resolve, 500)) - - // The OSC title really did reach the runtime as control bytes, not literal text. - expect(transcript.join('')).toContain(']0;Codex') + // Wait for real control bytes before measuring idle behavior. + await expect.poll(() => transcript.join(''), { timeout: 5_000 }).toContain(']0;Codex') const outcome = await terminalWait(runtime, handle, 8_000) expect(outcome.satisfied).toBe(false) From ca8620eda43ddf939b5cea9c267941379bd073d6 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 20:34:01 -0700 Subject: [PATCH 42/43] refactor(persistence): simplify store setup and name flush policies --- .../profile-state-maintenance.ts | 5 +- .../loading-store/write-flush-barriers.ts | 36 +++++++++------ .../profile-state-automation-runs-writer.ts | 7 +-- .../profile-state-store-factory.ts | 46 ++++++------------- 4 files changed, 41 insertions(+), 53 deletions(-) diff --git a/src/main/persistence/loading-store/profile-state-maintenance.ts b/src/main/persistence/loading-store/profile-state-maintenance.ts index 111c188821aa..c4632f26fb1e 100644 --- a/src/main/persistence/loading-store/profile-state-maintenance.ts +++ b/src/main/persistence/loading-store/profile-state-maintenance.ts @@ -138,7 +138,10 @@ async function pauseProfileState( signal?.throwIfAborted() if (flush) { // Cancel between commands so a dispatched commit retains a known outcome. - await flushCurrentStateAsync(domains.flushBarriers, false, undefined, true, true, true) + await flushCurrentStateAsync(domains.flushBarriers, { + requireInitialGenerationDurable: true, + fullCheckpoint: true + }) signal?.throwIfAborted() await authority?.writeJsonCompatibilityExportAsync?.(runtime.dataFile) } diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts index a285d51977ba..aec8a6d5b5f3 100644 --- a/src/main/persistence/loading-store/write-flush-barriers.ts +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -53,7 +53,7 @@ export class WriteFlushBarrierOperations { } if (runtime.profileStateAuthority?.asynchronous) { runtime.writeGeneration++ - void flushCurrentStateAsync(this, false, undefined, false).catch((error) => + void flushCurrentStateAsync(this, { drainToStableGeneration: false }).catch((error) => console.error('[persistence] Failed to flush state:', error) ) return @@ -100,7 +100,7 @@ export class WriteFlushBarrierOperations { ...runtime.pendingProfileFlushes, runtime.profileStateAuthority?.drainBackups?.(true) ]) - await flushCurrentStateAsync(this, true) + await flushCurrentStateAsync(this, { final: true }) if (options.exportJsonCompatibility) { await runtime.profileStateAuthority?.writeJsonCompatibilityExportAsync?.(runtime.dataFile) } @@ -120,7 +120,7 @@ export class WriteFlushBarrierOperations { return Promise.resolve() } // Best-effort callers must not livelock while the live app keeps mutating state. - return flushCurrentStateAsync(this, false, undefined, false).catch(() => {}) + return flushCurrentStateAsync(this, { drainToStableGeneration: false }).catch(() => {}) } flushPendingOrThrowAsync( @@ -130,13 +130,11 @@ export class WriteFlushBarrierOperations { if (runtime.writesFrozen || runtime.profileMaintenancePending || runtime.quitFlushStarted) { return Promise.reject(new Error('Cannot flush while persistence is finalized')) } - return flushCurrentStateAsync( - this, - false, - options.signal, - options.drainToStableGeneration, - true - ) + return flushCurrentStateAsync(this, { + signal: options.signal, + drainToStableGeneration: options.drainToStableGeneration, + requireInitialGenerationDurable: true + }) } } @@ -165,11 +163,19 @@ export async function flushDurableStateOrThrowAsync( export async function flushCurrentStateAsync( owner: WriteFlushBarrierOperations, - final: boolean, - signal?: AbortSignal, - drainToStableGeneration = true, - requireInitialGenerationDurable = false, - fullCheckpoint = final + { + final = false, + signal, + drainToStableGeneration = true, + requireInitialGenerationDurable = false, + fullCheckpoint = final + }: { + final?: boolean + signal?: AbortSignal + drainToStableGeneration?: boolean + requireInitialGenerationDurable?: boolean + fullCheckpoint?: boolean + } ): Promise { const { runtime, writes } = owner[writeFlushBarrierOperationsContext] return runProfileStateFlush(runtime, async () => { diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts b/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts index c84598d7add9..31f4eb4f1d1c 100644 --- a/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts +++ b/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts @@ -110,7 +110,6 @@ function applyIncomingAutomationRuns( existingRows.set(parsed.id, parsed) } - const incomingIds = new Set() const upsert = db.prepare( `INSERT INTO ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} (run_id, ordinal, payload, content_hash, revision, updated_at) VALUES (?, ?, ?, ?, ?, ?) @@ -120,8 +119,8 @@ function applyIncomingAutomationRuns( ) if (incoming.presence === AUTOMATION_RUNS_ARRAY) { for (const run of incoming.runs) { - incomingIds.add(run.id) const existing = existingRows.get(run.id) + existingRows.delete(run.id) if (existing?.ordinal === run.ordinal && existing.contentHash === run.contentHash) { continue } @@ -130,9 +129,7 @@ function applyIncomingAutomationRuns( } const remove = db.prepare(`DELETE FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} WHERE run_id = ?`) for (const id of existingRows.keys()) { - if (!incomingIds.has(id)) { - remove.run(id) - } + remove.run(id) } db.prepare( diff --git a/src/main/persistence/profile-state/profile-state-store-factory.ts b/src/main/persistence/profile-state/profile-state-store-factory.ts index 7c8a41c9adc6..1b833acb5b80 100644 --- a/src/main/persistence/profile-state/profile-state-store-factory.ts +++ b/src/main/persistence/profile-state/profile-state-store-factory.ts @@ -45,9 +45,20 @@ export function createProfileStateStore( options: ProfileStateStoreFactoryOptions ): ProfileStateStoreFactoryResult { const { initialState, ...prepared } = prepareProfileStateStore(options) - return { - ...prepared, - store: initialState ? createSqliteStore(options, initialState) : createLegacyStore(options) + try { + return { + ...prepared, + store: new Store({ + dataFile: options.dataFile, + storageAuthority: options.storageAuthority, + profileStateAuthority: initialState?.authority, + initialAuthorityState: initialState + }) + } + } catch (error) { + // Store construction owns the authority only after its load boundary succeeds. + initialState?.authority.close?.() + throw error } } @@ -108,32 +119,3 @@ export function prepareProfileStateStore( migrated: bootstrap.migrated } } - -function createLegacyStore(options: ProfileStateStoreFactoryOptions): Store { - return new Store({ - dataFile: options.dataFile, - ...(options.storageAuthority === undefined - ? {} - : { storageAuthority: options.storageAuthority }) - }) -} - -function createSqliteStore( - options: ProfileStateStoreFactoryOptions, - initialState: ProfileStateAuthorityInitialState -): Store { - try { - return new Store({ - dataFile: options.dataFile, - profileStateAuthority: initialState.authority, - initialAuthorityState: initialState, - ...(options.storageAuthority === undefined - ? {} - : { storageAuthority: options.storageAuthority }) - }) - } catch (error) { - // Store construction owns the authority only after its load boundary succeeds. - initialState.authority.close?.() - throw error - } -} From 80e8a8669a434751cd8bba3bf2db056bc2bb083e Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 25 Sep 2026 22:39:04 -0700 Subject: [PATCH 43/43] test(e2e): retry collected startup evaluation promises --- .../helpers/electron-main-evaluate-retry.ts | 12 +++---- .../electron-main-evaluate-retry.unit.test.ts | 34 ++++++++++++++----- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/tests/e2e/helpers/electron-main-evaluate-retry.ts b/tests/e2e/helpers/electron-main-evaluate-retry.ts index 502e93152a0f..c7794c407287 100644 --- a/tests/e2e/helpers/electron-main-evaluate-retry.ts +++ b/tests/e2e/helpers/electron-main-evaluate-retry.ts @@ -1,13 +1,13 @@ const MAIN_EVALUATE_ATTEMPTS = 5 const MAIN_EVALUATE_RETRY_MS = 200 -/** - * Playwright raises this message for any main-process CDP failure that is neither - * a JS error nor a closed session, so it does not mean anything navigated — it is - * also what a handle the main process has not finished publishing looks like. - */ +// Startup can invalidate the CDP context or collect a pending evaluation promise. function isTransientMainEvaluateError(error: unknown): boolean { - return error instanceof Error && error.message.includes('Execution context was destroyed') + return ( + error instanceof Error && + (error.message.includes('Execution context was destroyed') || + error.message.includes('Resulting promise was garbage collected')) + ) } function waitBeforeRetry(): Promise { diff --git a/tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts b/tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts index 7909a4d6eab3..c6735e2fd1f8 100644 --- a/tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts +++ b/tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import { retryTransientMainEvaluate } from './electron-main-evaluate-retry' -const transient = (): Error => - new Error('Execution context was destroyed, most likely because of a navigation.') +const transientMessages = [ + 'Execution context was destroyed, most likely because of a navigation.', + 'electronApplication.evaluate: Resulting promise was garbage collected.' +] describe('retryTransientMainEvaluate', () => { it('returns the first successful read without retrying', async () => { @@ -16,13 +18,13 @@ describe('retryTransientMainEvaluate', () => { expect(calls).toBe(1) }) - it('rides out the startup window that made the paired-client launch flaky', async () => { + it.each(transientMessages)('retries a transient startup failure: %s', async (message) => { let calls = 0 await expect( retryTransientMainEvaluate(async () => { calls += 1 if (calls < 3) { - throw transient() + throw new Error(message) } return '/isolated/home' }) @@ -41,14 +43,30 @@ describe('retryTransientMainEvaluate', () => { expect(calls).toBe(1) }) - it('gives up rather than looping forever when the app never becomes evaluable', async () => { + it.each(transientMessages)( + 'bounds retries when evaluation keeps failing: %s', + async (message) => { + let calls = 0 + await expect( + retryTransientMainEvaluate(async () => { + calls += 1 + throw new Error(message) + }) + ).rejects.toThrow(message) + expect(calls).toBe(5) + } + ) + + it('does not retry a closed application', async () => { let calls = 0 await expect( retryTransientMainEvaluate(async () => { calls += 1 - throw transient() + throw new Error( + 'electronApplication.evaluate: Target page, context or browser has been closed' + ) }) - ).rejects.toThrow(/Execution context was destroyed/) - expect(calls).toBe(5) + ).rejects.toThrow(/has been closed/) + expect(calls).toBe(1) }) })