From 82412dab8b33db3d50dc1feff169341ca89acd90 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 25 Sep 2026 22:47:33 -0700 Subject: [PATCH] Persist profile state in SQLite with background writes (#22612) Migrate profile state to SQLite and move writes and backups into a background worker. Acknowledge terminal, SSH and automation changes only after durable saves. Preserve JSON import, recovery, rollback and compatibility exports. Validate migration, worker failures, maintenance, cross-profile moves and terminal lifetime races with unit, integration and end-to-end coverage. --- .../build-plugins/plain-node-entry-guard.ts | 30 +- config/electron-builder.config.cjs | 2 + config/scripts/build-orcad.mjs | 36 +- .../cli-main-module-bundle-parity.test.ts | 31 +- .../electron-vite-output-contract.test.ts | 31 +- config/scripts/plain-node-entry-guard.test.ts | 60 +- config/scripts/profile-state-worker-smoke.mjs | 134 +++ .../profile-state-worker-smoke.test.mjs | 91 ++ config/tsconfig.cli.json | 62 ++ electron.vite.config.ts | 24 +- src/cli/handler-group-manifest.ts | 5 + .../agent-hooks-load-boundary.test.ts | 42 + src/cli/handlers/agent-hooks.test.ts | 290 +++++- src/cli/handlers/agent-hooks.ts | 133 ++- .../profile-state-recovery-admission.test.ts | 245 +++++ src/cli/handlers/profile-state.test.ts | 487 ++++++++++ src/cli/handlers/profile-state.ts | 153 ++++ src/cli/index.ts | 3 +- src/cli/profile-state-location.ts | 13 + src/cli/runtime-client-deferral.test.ts | 4 +- src/cli/runtime/launch.ts | 6 +- .../profile-state-recovery-launch.test.ts | 154 ++++ .../runtime/profile-state-recovery-launch.ts | 78 ++ src/cli/specs/index.ts | 4 +- src/cli/specs/profile-state.test.ts | 23 + src/cli/specs/profile-state.ts | 30 + src/main/active-view-preference.ts | 21 + .../agent-auth-restart-preservation.test.ts | 6 +- src/main/agent-auth-restart-preservation.ts | 2 +- .../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 | 12 +- .../automations/run-completion-watcher.ts | 10 +- src/main/automations/service.ts | 138 ++- .../codex-accounts/async-file-rename.test.ts | 87 ++ .../codex-accounts/codex-account-selection.ts | 11 +- .../codex-reset-credit-coordinator.ts | 11 +- .../codex-reset-credit-ledger.test.ts | 131 +++ .../codex-reset-credit-ledger.ts | 89 +- src/main/codex-accounts/fs-utils.ts | 41 +- ...vice-account-selection-and-removal.test.ts | 96 +- .../service-reset-credit-durability.test.ts | 45 +- ...ervice-reset-credit-home-ownership.test.ts | 2 +- ...ervice-reset-credit-target-routing.test.ts | 23 +- .../codex-accounts/service-test-harness.ts | 8 +- src/main/daemon/daemon-launch-paths.ts | 5 +- .../daemon/daemon-process-identity-query.ts | 14 +- .../daemon-process-identity-time-zone.test.ts | 32 + .../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 | 34 +- src/main/index.ts | 45 +- .../ipc/orca-profile-project-transfer-args.ts | 25 + .../orca-profiles-switch-persistence.test.ts | 115 +++ src/main/ipc/orca-profiles.test.ts | 197 +++- src/main/ipc/orca-profiles.ts | 91 +- src/main/ipc/pty-dead-owner-respawn.test.ts | 13 +- .../pty-pane-reservation-settlement.test.ts | 5 +- src/main/ipc/pty-pane-restart-replace.test.ts | 1 + .../pty-persisted-incarnation-repair.test.ts | 9 +- .../pty-serializer-settlement-mapping.test.ts | 5 +- .../ipc/pty/host-env/fresh-spawn-routing.ts | 10 +- src/main/ipc/pty/ipc/spawn-commit-persist.ts | 144 ++- ...spawn-commit-ssh-lease-cardinality.test.ts | 18 +- src/main/ipc/pty/ipc/spawn-commit.ts | 113 +-- src/main/ipc/pty/pane/spawn-registration.ts | 81 ++ src/main/ipc/pty/pane/spawn-telemetry.ts | 24 + 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 +- .../ipc/pty/pane/terminal-spawn-restore.ts | 46 + src/main/ipc/pty/runtime/spawn-commit.ts | 171 ++-- src/main/ipc/session.ts | 21 +- .../profile-active-transfer-worker.test.ts | 127 +++ .../profile-active-transfer.test.ts | 232 +++++ .../orca-profiles/profile-active-transfer.ts | 39 + .../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-persistence-deadline.test.ts | 37 + .../profile-persistence-deadline.ts | 31 +- .../profile-project-domain-changes.ts | 144 +++ .../profile-project-domain-move-intent.ts | 58 ++ .../profile-project-domain-state.ts | 84 ++ .../profile-project-domain-transfer.test.ts | 546 +++++++++++ .../profile-project-move-intent.ts | 124 +++ .../profile-project-move-record.ts | 141 +++ .../profile-project-state-file.ts | 164 +++- ...profile-project-transfer-migration.test.ts | 314 +++++++ .../profile-project-transfer-migration.ts | 27 + .../profile-project-transfer.test.ts | 439 ++++++++- .../orca-profiles/profile-project-transfer.ts | 103 ++- .../orca-profiles/profile-storage-paths.ts | 33 +- src/main/orcad/orcad-entry.test.ts | 38 + src/main/orcad/orcad-entry.ts | 87 +- src/main/orcad/orcad-health.test.ts | 18 + src/main/orcad/orcad-health.ts | 11 +- src/main/orcad/orcad-launch-contract.test.ts | 19 +- src/main/orcad/orcad-lifecycle.ts | 64 +- .../orcad/orcad-profile-state-startup.test.ts | 115 +++ src/main/orcad/orcad-profile-state-startup.ts | 64 ++ .../orcad-profile-state-telemetry.test.ts | 46 + .../orcad/orcad-profile-state-telemetry.ts | 47 + src/main/orcad/orcad-push-startup.test.ts | 56 +- ...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 +- ...rsistence-loading-store-extraction.test.ts | 68 +- ...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 +- .../feature-interaction-recording.ts | 4 +- .../applying-settings/ui-state-update.ts | 7 +- .../ssh-pty-consumer-recovery.ts | 53 +- .../ssh-pty-lease-operations.ts | 30 +- .../loading-store/automation-persistence.ts | 44 +- .../loading-store/backup-recovery-rotation.ts | 24 +- .../loading-store/loaded-state-parsing.ts | 91 +- .../metadata-lineage-operations.ts | 11 +- .../primary-state-write-context.ts | 17 + .../loading-store/primary-state-write-json.ts | 87 ++ .../primary-state-write-runtime.ts | 69 ++ .../loading-store/primary-state-write-sync.ts | 98 ++ .../primary-state-write-worker.ts | 106 +++ .../loading-store/primary-state-writes.ts | 415 +++++---- .../loading-store/profile-preferences.ts | 4 +- .../profile-state-authority-writes.ts | 59 ++ .../loading-store/profile-state-authority.ts | 127 +++ .../profile-state-caller-cancellation.test.ts | 87 ++ .../profile-state-checkpoints.test.ts | 238 +++++ ...profile-state-delayed-authority-fixture.ts | 130 +++ .../profile-state-direct-flush.test.ts | 188 ++++ .../profile-state-flush-lifetime.ts | 34 + .../profile-state-import-lifetime.test.ts | 117 +++ ...le-state-maintenance-compatibility.test.ts | 125 +++ ...le-state-maintenance-final-failure.test.ts | 89 ++ .../profile-state-maintenance-fixture.ts | 109 +++ ...profile-state-maintenance-recovery.test.ts | 145 +++ .../profile-state-maintenance.test.ts | 297 ++++++ .../profile-state-maintenance.ts | 198 ++++ ...-state-pty-retirement-finalization.test.ts | 199 ++++ .../profile-state-selective-write.ts | 99 ++ .../profile-state-settings-writes.test.ts | 320 +++++++ .../profile-state-sqlite-authority.test.ts | 858 ++++++++++++++++++ .../profile-state-store-backups.test.ts | 249 +++++ .../profile-state-update-quit.test.ts | 242 +++++ .../profile-state-worker-coordination.test.ts | 263 ++++++ ...file-state-worker-secret-retention.test.ts | 124 +++ .../profile-state-write-batching.test.ts | 251 +++++ .../pty-binding-async-durability.test.ts | 267 ++++++ ...y-binding-created-surface-rollback.test.ts | 135 +++ .../pty-binding-fast-lane.test.ts | 1 + .../loading-store/pty-binding-fast-lane.ts | 7 + .../loading-store/pty-binding-persistence.ts | 281 ++---- .../pty-binding-session-update.ts | 146 +++ .../pty-binding-write-rollback.ts | 94 ++ .../pty-reattach-failure-routing.test.ts | 122 +++ .../pty-retirement-async-durability.test.ts | 119 +++ ...retirement-publication-during-read.test.ts | 146 +++ .../pty-spawn-commit-dependencies-fixture.ts | 45 + .../pty-spawn-exit-durability.test.ts | 238 +++++ .../pty-spawn-handle-publication.test.ts | 77 ++ .../pty-spawn-replacement-durability.test.ts | 146 +++ .../pty-spawn-restore-durability.test.ts | 79 ++ .../secret-sentinel-substitution.test.ts | 134 ++- .../secret-sentinel-substitution.ts | 91 +- .../loading-store/session-host-partitions.ts | 9 +- .../session-snapshot-operations.ts | 50 +- .../ssh-lease-async-durability.test.ts | 128 +++ .../ssh-lease-durable-mutation.ts | 42 + .../ssh-lease-recovery-operations.ts | 47 +- .../loading-store/ssh-profile-operations.ts | 12 +- .../state-serialization-secret-handling.ts | 141 ++- .../loading-store/store-domain-composition.ts | 17 + .../loading-store/store-runtime-state.ts | 21 + src/main/persistence/loading-store/store.ts | 316 +++++-- .../workspace-session-snapshot-publication.ts | 14 +- .../worktree-identity-metadata.ts | 11 +- .../loading-store/write-flush-barriers.ts | 283 +++--- .../loading-store/write-scheduling.ts | 57 +- .../profile-state-cutover-fixture.test.ts | 164 ++++ .../profile-state-cutover-fixture.ts | 291 ++++++ .../profile-state-cutover-soak.test.ts | 350 +++++++ .../profile-state-access-identity.test.ts | 30 + .../profile-state-access-identity.ts | 84 ++ .../profile-state-access-owner.ts | 314 +++++++ .../profile-state-access-process.test.ts | 150 +++ .../profile-state-access.test.ts | 527 +++++++++++ .../profile-state/profile-state-access.ts | 99 ++ .../profile-state-active-location.ts | 49 + .../profile-state-authority-bootstrap.test.ts | 537 +++++++++++ .../profile-state-authority-bootstrap.ts | 192 ++++ ...ile-state-authority-export-fencing.test.ts | 43 + .../profile-state-authority-exports.ts | 89 ++ ...ile-state-automation-runs-equality.test.ts | 177 ++++ ...profile-state-automation-runs-migration.ts | 46 + .../profile-state-automation-runs-model.ts | 49 + .../profile-state-automation-runs-payload.ts | 72 ++ .../profile-state-automation-runs-reader.ts | 151 +++ ...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 | 158 ++++ .../profile-state-automation-runs.ts | 22 + ...e-state-automation-storage-upgrade.test.ts | 380 ++++++++ .../profile-state/profile-state-backup-job.ts | 33 + .../profile-state-backup-path.test.ts | 76 ++ .../profile-state-backup-path.ts | 67 ++ .../profile-state-backup-rotation.test.ts | 275 ++++++ .../profile-state-backup-rotation.ts | 130 +++ ...ofile-state-backup-temporary-files.test.ts | 91 ++ .../profile-state-backup-temporary-files.ts | 71 ++ .../profile-state-backup-worker-entry.ts | 30 + .../profile-state-backup-worker.test.ts | 236 +++++ .../profile-state-backup-worker.ts | 87 ++ ...e-state-bootstrap-publication-race.test.ts | 178 ++++ ...ofile-state-complete-domain-writes.test.ts | 122 +++ .../profile-state-complete-replacements.ts | 36 + .../profile-state-crash-recovery.test.ts | 210 +++++ ...profile-state-current-json-command.test.ts | 171 ++++ .../profile-state-database-errors.ts | 17 + ...rofile-state-database-export-crash.test.ts | 112 +++ .../profile-state-database-publication.ts | 38 + .../profile-state-database-quarantine.ts | 89 ++ .../profile-state-database-recovery.test.ts | 296 ++++++ .../profile-state-database-recovery.ts | 113 +++ ...ile-state-database-rollback-export.test.ts | 159 ++++ .../profile-state-database-schema.ts | 41 + .../profile-state-database-snapshot.test.ts | 324 +++++++ .../profile-state-database-snapshot.ts | 123 +++ .../profile-state-database-validation.ts | 146 +++ .../profile-state-database.test.ts | 339 +++++++ .../profile-state/profile-state-database.ts | 268 ++++++ .../profile-state-document-reader.ts | 99 ++ .../profile-state-document-validation.ts | 117 +++ .../profile-state-documents.test.ts | 370 ++++++++ .../profile-state/profile-state-documents.ts | 232 +++++ .../profile-state-domain-equality.test.ts | 144 +++ .../profile-state-domain-reader.test.ts | 152 ++++ .../profile-state-domain-reader.ts | 115 +++ .../profile-state-domain-write-validation.ts | 78 ++ .../profile-state-domain-writes.test.ts | 585 ++++++++++++ .../profile-state-domain-writes.ts | 231 +++++ .../profile-state-export-path.ts | 36 + .../profile-state-fragment-validation.test.ts | 132 +++ .../profile-state-json-acceptance.ts | 153 ++++ ...-state-json-compatibility-recovery.test.ts | 239 +++++ ...profile-state-large-recovery-crash.test.ts | 149 +++ .../profile-state-legacy-backup-path.ts | 16 + .../profile-state-live-store-factory.test.ts | 216 +++++ .../profile-state-live-store-factory.ts | 58 ++ .../profile-state/profile-state-migration.ts | 86 ++ .../profile-state-offline-settings.test.ts | 135 +++ .../profile-state-offline-settings.ts | 198 ++++ .../profile-state-parsed-snapshot.test.ts | 158 ++++ .../profile-state-read-concurrency.test.ts | 174 ++++ .../profile-state-read-snapshot.ts | 37 + .../profile-state-recovery-batch.test.ts | 227 +++++ .../profile-state-recovery-command.ts | 142 +++ .../profile-state-recovery-copy.test.ts | 193 ++++ .../profile-state-recovery-copy.ts | 81 ++ ...le-state-recovery-crash-boundaries.test.ts | 378 ++++++++ .../profile-state-recovery-crash-process.ts | 178 ++++ .../profile-state-recovery-required.ts | 81 ++ .../profile-state/profile-state-recovery.ts | 85 ++ .../profile-state-revision-readmission.ts | 20 + .../profile-state/profile-state-revision.ts | 31 + .../profile-state-sqlite-authority.ts | 339 +++++++ .../profile-state-startup-authority.test.ts | 329 +++++++ .../profile-state-startup-authority.ts | 68 ++ .../profile-state-startup-failure.test.ts | 96 ++ .../profile-state-startup-failure.ts | 127 +++ ...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 | 460 ++++++++++ .../profile-state-store-factory.ts | 121 +++ ...profile-state-streaming-validation.test.ts | 145 +++ .../profile-state-versioned-export.test.ts | 113 +++ .../profile-state-versioned-export.ts | 52 ++ .../profile-state-worker-authority.test.ts | 76 ++ .../profile-state-worker-authority.ts | 168 ++++ ...ofile-state-worker-export-failures.test.ts | 177 ++++ .../profile-state-write-transaction.test.ts | 102 +++ .../profile-state-write-transaction.ts | 42 + .../profile-state-writer-connection.ts | 302 ++++++ .../profile-state-writer-errors.ts | 94 ++ ...ofile-state-writer-protocol-faults.test.ts | 148 +++ .../profile-state-writer-protocol.ts | 115 +++ .../profile-state-writer-request.ts | 59 ++ .../profile-state-writer-worker-client.ts | 75 ++ .../profile-state-writer-worker-entry.ts | 148 +++ .../profile-state-writer-worker-path.ts | 17 + .../profile-state-writer-worker.test.ts | 341 +++++++ .../pane-identity-migration.ts | 45 +- .../workspace-pane-normalization.ts | 34 +- .../workspace-session-write-rollback.ts | 132 +++ .../automation-definition-operations.ts | 3 + .../automation-run-operations.ts | 5 + ...ted-secret-persistence-concurrency.test.ts | 192 ++++ src/main/protected-secret-persistence.test.ts | 31 + src/main/protected-secret-persistence.ts | 42 +- ...time-terminal-close-continuity-fixtures.ts | 9 +- ...wledged-terminal-tab-retirement-fixture.ts | 18 +- ...knowledged-terminal-tab-retirement.test.ts | 21 +- .../automation-change-publication.test.ts | 31 +- ...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 +- .../mobile-session-terminal-retirement.ts | 10 +- .../orca-runtime-automation-operations.ts | 9 +- .../runtime/orca-runtime-automations.test.ts | 2 +- ...ld-headless-mobile-session-browser-tabs.ts | 96 +- ...orca-runtime-build-pty-terminal-summary.ts | 10 +- ...time-close-headless-mobile-terminal-tab.ts | 12 +- .../orca-runtime-close-mobile-session-tab.ts | 26 +- .../orca-runtime-fit-override-listeners.ts | 1 + src/main/runtime/orca-runtime-get-status.ts | 16 +- ...-runtime-invalidate-all-handles-for-pty.ts | 1 + ...obile-close-preserved-resurrection.test.ts | 9 +- src/main/runtime/orca-runtime-on-pty-exit.ts | 26 +- ...me-persist-terminal-surface-retirements.ts | 145 ++- src/main/runtime/orca-runtime-register-pty.ts | 7 + ...rca-runtime-stop-terminals-for-worktree.ts | 94 +- ...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 +- .../coordinator-task-dispatch.ts | 5 +- ...inator-terminal-census-unavailable.test.ts | 84 ++ src/main/runtime/orchestration/coordinator.ts | 3 + ...retirement-proof-publication-order.test.ts | 28 +- ...y-inventory-partial-relay-liveness.test.ts | 6 +- .../runtime/runtime-automation-controller.ts | 13 +- .../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 +- ...inal-list-pending-pty-registration.test.ts | 144 +++ ...rminal-retirement-async-durability.test.ts | 70 ++ ...dle-name-only-real-pty.integration.test.ts | 6 +- ...workspace-session-failed-write-rollback.ts | 74 -- .../worktree-terminal-mutation-lock.test.ts | 4 + .../worktree-terminal-mutation-lock.ts | 4 + src/main/sqlite/sync-database.ts | 26 +- .../ssh-reattach-pane-cardinality.test.ts | 46 +- 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 | 157 ++-- 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 +- .../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-relay-session-recovery-races.test.ts | 177 ++-- .../ssh/ssh-relay-session-test-fixtures.ts | 19 +- src/main/ssh/ssh-relay-session.ts | 99 +- ...rowser-process-user-agent-ordering.test.ts | 290 +++++- 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 | 24 + .../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 + .../startup/main-process-preflight-failure.ts | 41 + src/main/startup/main-process-preflight.ts | 35 +- src/main/startup/main-process-quit.ts | 17 +- .../startup/main-process-ready-foundation.ts | 31 +- .../main-process-ready-identity-write.test.ts | 11 + ...-process-ready-persistence-cleanup.test.ts | 94 ++ src/main/startup/main-process-ready.ts | 37 +- src/main/startup/main-process-state.ts | 21 + .../startup/main-window-core-services.test.ts | 114 +++ src/main/startup/main-window-core-services.ts | 7 +- .../pre-gone-crash-sampling-wiring.test.ts | 4 +- .../profile-state-recovery-preflight.test.ts | 280 ++++++ .../profile-state-recovery-preflight.ts | 76 ++ .../profile-state-write-failure.test.ts | 53 ++ .../startup/profile-state-write-failure.ts | 18 + src/main/startup/startup-diagnostics.test.ts | 15 +- src/main/startup/startup-diagnostics.ts | 8 +- src/main/updater.quit-and-install.test.ts | 92 ++ src/main/updater.ts | 4 +- src/main/updater/updater-install-support.ts | 51 +- src/main/updater/updater-setup.ts | 4 +- src/main/updater/updater-state.ts | 4 + .../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/preload/api/orca-profiles-bridge.ts | 35 +- .../app-restart-checkpoint-routing.test.ts | 95 ++ src/preload/renderer-restart-wiring.test.ts | 132 ++- src/preload/renderer-restart-wiring.ts | 64 +- ...transport-recycled-pty-incarnation.test.ts | 44 +- .../direct-ssh-connect-reply-routing.test.ts | 291 ++++++ .../ipc-events/direct-ssh-state-ipc-bridge.ts | 17 +- .../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 +- .../web/web-runtime-client-heartbeat.test.ts | 3 + src/shared/cli-argument-boundary.ts | 1 + src/shared/orcad-artifacts.ts | 2 + .../profile-state-recovery-command.test.ts | 28 + src/shared/profile-state-recovery-command.ts | 72 ++ 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 +- .../helpers/electron-main-evaluate-retry.ts | 12 +- .../electron-main-evaluate-retry.unit.test.ts | 34 +- .../e2e/helpers/electron-process-shutdown.ts | 21 +- tests/e2e/helpers/orca-restart.ts | 36 +- tests/e2e/helpers/persisted-profile-state.ts | 60 ++ .../persisted-profile-state.unit.test.ts | 71 ++ .../ssh-reconnect-failure-observation.ts | 120 +++ .../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 +- ...terminal-parked-scrollback-restart.spec.ts | 52 +- ...ote-terminal-serve-restart-binding.spec.ts | 113 ++- ...rsisted-session-production-upgrade.spec.ts | 432 ++++++++- tests/e2e/plugin-startup-budget.spec.ts | 39 +- ...le-state-automatic-backup-recovery.spec.ts | 222 +++++ ...state-terminal-restart-persistence.spec.ts | 591 ++++++++++++ ...emote-terminal-tab-retirement.unit.test.ts | 20 +- ...ettled-worker-tab-survives-restart.spec.ts | 28 +- .../ssh-docker-reconnect-pane-restore.spec.ts | 30 +- .../ssh-docker-resource-accumulation.spec.ts | 50 +- ...ssh-docker-transport-drop-recovery.spec.ts | 13 +- ...minal-binding-crash-hydration.unit.test.ts | 101 +++ ...inal-duplicate-pty-renderer-reveal.spec.ts | 64 +- .../e2e/terminal-restart-persistence.spec.ts | 184 +--- ...kspace-session-corrupt-tab-salvage.spec.ts | 46 +- 490 files changed, 41290 insertions(+), 3485 deletions(-) rename src/cli/main-module-bundle-parity.test.ts => config/scripts/cli-main-module-bundle-parity.test.ts (60%) 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/cli/handlers/agent-hooks-load-boundary.test.ts 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.test.ts create mode 100644 src/cli/specs/profile-state.ts 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 create mode 100644 src/main/codex-accounts/async-file-rename.test.ts create mode 100644 src/main/codex-accounts/codex-reset-credit-ledger.test.ts create mode 100644 src/main/daemon/daemon-process-identity-time-zone.test.ts create mode 100644 src/main/ipc/orca-profile-project-transfer-args.ts create mode 100644 src/main/ipc/orca-profiles-switch-persistence.test.ts create mode 100644 src/main/ipc/pty/pane/spawn-registration.ts create mode 100644 src/main/ipc/pty/pane/spawn-telemetry.ts create mode 100644 src/main/ipc/pty/pane/terminal-spawn-restore.ts create mode 100644 src/main/orca-profiles/profile-active-transfer-worker.test.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-persistence-deadline.test.ts 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/orca-profiles/profile-project-move-intent.ts create mode 100644 src/main/orca-profiles/profile-project-move-record.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-json.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/primary-state-write-worker.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-caller-cancellation.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-checkpoints.test.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-direct-flush.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-flush-lifetime.ts create mode 100644 src/main/persistence/loading-store/profile-state-import-lifetime.test.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-final-failure.test.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-recovery.test.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-pty-retirement-finalization.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/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/profile-state-write-batching.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-created-surface-rollback.test.ts create mode 100644 src/main/persistence/loading-store/pty-binding-session-update.ts create mode 100644 src/main/persistence/loading-store/pty-binding-write-rollback.ts create mode 100644 src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts create mode 100644 src/main/persistence/loading-store/pty-retirement-async-durability.test.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-commit-dependencies-fixture.ts create mode 100644 src/main/persistence/loading-store/pty-spawn-exit-durability.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/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-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-identity.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-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-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-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-temporary-files.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-temporary-files.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-complete-replacements.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-current-json-command.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-publication.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-large-recovery-crash.test.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-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-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-batch.test.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-copy.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-copy.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-readmission.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-streaming-validation.test.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-versioned-export.ts create mode 100644 src/main/persistence/profile-state/profile-state-worker-authority.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-worker-authority.ts create mode 100644 src/main/persistence/profile-state/profile-state-worker-export-failures.test.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/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/orchestration/coordinator-terminal-census-unavailable.test.ts create mode 100644 src/main/runtime/runtime-durable-store-fixture.ts create mode 100644 src/main/runtime/terminal-list-pending-pty-registration.test.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/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-process-preflight-failure.ts create mode 100644 src/main/startup/main-process-ready-persistence-cleanup.test.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/main/startup/profile-state-write-failure.test.ts create mode 100644 src/main/startup/profile-state-write-failure.ts create mode 100644 src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts create mode 100644 src/shared/profile-state-recovery-command.test.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/ssh-reconnect-failure-observation.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 create mode 100644 tests/e2e/terminal-binding-crash-hydration.unit.test.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 7dc017b9c98..2589a4921e9 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 @@ -41,7 +59,9 @@ 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', + 'profile-state-writer-worker-entry' ] as const export const GUARDED_ENTRY_NAMES = [ diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 84c31376833..abf94296035 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 99a53fa0981..0b882689f94 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 @@ -295,7 +304,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/src/cli/main-module-bundle-parity.test.ts b/config/scripts/cli-main-module-bundle-parity.test.ts similarity index 60% rename from src/cli/main-module-bundle-parity.test.ts rename to config/scripts/cli-main-module-bundle-parity.test.ts index a109fcb9fdb..ed810e34382 100644 --- a/src/cli/main-module-bundle-parity.test.ts +++ b/config/scripts/cli-main-module-bundle-parity.test.ts @@ -1,6 +1,8 @@ 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') @@ -18,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 = /(? { @@ -30,12 +32,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,14 +47,25 @@ 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([]) }) + 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: join('src', 'cli', 'profile-state-location.ts'), + module: 'persistence/profile-state/profile-state-active-location' + }) expect(findMainImports().length).toBeGreaterThanOrEqual(2) - expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(2) + expect(Object.keys(findElectronViteMainEntries()).length).toBeGreaterThanOrEqual(2) }) }) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 285b58f2ac7..fe6108a6ff1 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/scripts/plain-node-entry-guard.test.ts b/config/scripts/plain-node-entry-guard.test.ts index 673b796fd43..843d185b101 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/config/scripts/profile-state-worker-smoke.mjs b/config/scripts/profile-state-worker-smoke.mjs new file mode 100644 index 00000000000..a866c9da554 --- /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 00000000000..06f45e9fd6d --- /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 12d4d2fe785..e8d48d62980 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -20,6 +20,68 @@ "../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/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", + "../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-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", + "../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-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", + "../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", + "../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 3cd58f4a25f..87156caf6e4 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([ @@ -246,6 +249,12 @@ 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' + ), + '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'), @@ -254,16 +263,9 @@ 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') + ...Object.fromEntries( + CLI_MAIN_ENTRY_NAMES.map((module) => [module, resolve(`src/main/${module}.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 bda6799a8bc..bf1f1e313d1 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-load-boundary.test.ts b/src/cli/handlers/agent-hooks-load-boundary.test.ts new file mode 100644 index 00000000000..ff11e2a2d50 --- /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.test.ts b/src/cli/handlers/agent-hooks.test.ts index 279a8900bec..1d883a65145 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 4fcfe64f9b7..11902e985c2 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,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 type { ProfileStateOfflineLocation } from '../../main/persistence/profile-state/profile-state-offline-settings' type AgentHookCommandResult = { enabled: boolean @@ -27,28 +29,15 @@ 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 { - 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') +async function getDataPath(): Promise { + return ( + (await getProfileStateLocation())?.dataFile ?? join(getDefaultUserDataPath(), 'orca-data.json') + ) +} + +async function getProfileStateLocation(): Promise { + const { getActiveProfileStateLocation } = await import('../profile-state-location.js') + return getActiveProfileStateLocation() } function isRecord(value: unknown): value is Record { @@ -92,11 +81,29 @@ function writePersistedState(dataPath: string, state: PersistedState): void { } } -function readHookSettingsFromDisk(): Pick< - GlobalSettings, - 'agentStatusHooksEnabled' | 'disabledTuiAgents' +async function readHookSettingsFromDisk(): Promise< + Pick > { - const state = readPersistedState(getDataPath()) + const { acquireProfileStateRuntimeAdmission } = + await import('../../main/persistence/profile-state/profile-state-access.js') + const admission = acquireProfileStateRuntimeAdmission(getDefaultUserDataPath()) + try { + return await readAdmittedHookSettingsFromDisk() + } finally { + admission.release() + } +} + +async function readAdmittedHookSettingsFromDisk(): Promise< + Pick +> { + 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(await getDataPath()) return { agentStatusHooksEnabled: state.settings?.agentStatusHooksEnabled !== false, disabledTuiAgents: normalizeDisabledTuiAgents(state.settings?.disabledTuiAgents) @@ -123,11 +130,32 @@ async function readHookSettings( return readHookSettingsFromDisk() } -function updateEnabledOnDisk(enabled: boolean): { +async function updateEnabledOnDisk(enabled: boolean): Promise<{ settingsPath: string settings: Pick -} { - const dataPath = getDataPath() +}> { + 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 await updateAdmittedEnabledOnDisk(enabled) + } finally { + maintenance.release() + } +} + +async function updateAdmittedEnabledOnDisk(enabled: boolean): Promise<{ + settingsPath: string + settings: Pick +}> { + 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 = await getDataPath() const state = readPersistedState(dataPath) state.settings = { ...getDefaultPersistedState(homedir()).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 { @@ -193,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) @@ -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,23 +258,33 @@ 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 = { - enabled: readHookSettingsFromDisk().agentStatusHooksEnabled, - settingsPath: getDataPath(), + enabled: (await readHookSettingsFromDisk()).agentStatusHooksEnabled, + settingsPath: await getDataPath(), appliedBy: 'offline', statuses: getManagedAgentHookStatuses() } 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 00000000000..1da200843c9 --- /dev/null +++ b/src/cli/handlers/profile-state-recovery-admission.test.ts @@ -0,0 +1,245 @@ +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 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) + 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 00000000000..c6eb514d539 --- /dev/null +++ b/src/cli/handlers/profile-state.test.ts @@ -0,0 +1,487 @@ +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('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) + 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.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', ...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') + } + ) + + 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 00000000000..b84cd694df6 --- /dev/null +++ b/src/cli/handlers/profile-state.ts @@ -0,0 +1,153 @@ +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}`, + result.revision === null ? 'source: current JSON' : `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) + 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 (['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) } + } + 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 33566dcf837..b2e01c04e56 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 00000000000..1e9325872a1 --- /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-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts index fdf77082729..1487e2364f7 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/launch.ts b/src/cli/runtime/launch.ts index a326ae333f5..ebf10c2aaa9 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 00000000000..36ae0e7868f --- /dev/null +++ b/src/cli/runtime/profile-state-recovery-launch.test.ts @@ -0,0 +1,154 @@ +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('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, + 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() + }) + + 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 new file mode 100644 index 00000000000..dd0d68ff2e4 --- /dev/null +++ b/src/cli/runtime/profile-state-recovery-launch.ts @@ -0,0 +1,78 @@ +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.', + { + exitCode: response.code, + signal: response.signal, + timedOut: response.timedOut, + outputTruncated: response.outputTruncated ?? false, + stderr: response.stderr.trim().slice(-4096) + } + ) +} diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index ee9db22dc1a..b817749f425 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.test.ts b/src/cli/specs/profile-state.test.ts new file mode 100644 index 00000000000..ef331390ecf --- /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 new file mode 100644 index 00000000000..030a5b57b22 --- /dev/null +++ b/src/cli/specs/profile-state.ts @@ -0,0 +1,30 @@ +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 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.', + '--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 --current-json' + ] + } +] diff --git a/src/main/active-view-preference.ts b/src/main/active-view-preference.ts index eb6098f8c1d..41115306e95 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/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index 70063128068..291d294d9ba 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 a3e24ef8abe..d00641088cc 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/automations/automation-dispatch-request.ts b/src/main/automations/automation-dispatch-request.ts new file mode 100644 index 00000000000..348a1b9ecee --- /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 a149f28ea38..f6130fbf223 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 ef08c84de49..1f813a62d0c 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 00000000000..22d13899e3b --- /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 4639a872887..d6d7cadd118 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 233deb3e782..27179f1cf56 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 4a5fffa1b16..286abb2f252 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 00000000000..ea5403801e0 --- /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 8d4e3aed290..5a0ed60582c 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 f6a103408c2..b3b60786654 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 31c1aea0bb4..954c3ee13bb 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() }) @@ -341,7 +349,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 +358,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/run-completion-watcher.ts b/src/main/automations/run-completion-watcher.ts index 4ac125082fb..ab53efaee58 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') + ) ) } diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 227a290783b..1ff8eafbd6b 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/codex-accounts/async-file-rename.test.ts b/src/main/codex-accounts/async-file-rename.test.ts new file mode 100644 index 00000000000..a453378a78d --- /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 ad97d80696b..24785e79dc1 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 { @@ -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) - 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/codex-reset-credit-coordinator.ts b/src/main/codex-accounts/codex-reset-credit-coordinator.ts index 5d6233d46eb..cd0c8ad8569 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 00000000000..32f13e33d78 --- /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 625731f7d38..77590bcfe36 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/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index e0610cc79c7..295dfac9b66 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' @@ -168,7 +170,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 @@ -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 a2d7d9a4bb7..861859223ea 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() + 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 { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - store as never, - rateLimits as never, - runtimeHome as never - ) + const ledgerError = new Error('credit ledger unreadable') + if (ledger === 'unreadable') { + store.getCodexResetCreditAttemptLedger.mockImplementation(() => { + throw ledgerError + }) + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = await service.removeAccount('account-1') + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) - expect(result.accounts).toHaveLength(0) - expect(result.activeAccountId).toBe(null) - expect(existsSync(managedHomePath)).toBe(false) - expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() - }) + 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/codex-accounts/service-reset-credit-durability.test.ts b/src/main/codex-accounts/service-reset-credit-durability.test.ts index ac6cd8f2ef7..e524bda2aeb 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 b5c534be417..eebc256a8c6 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 ebfe4e3ac1c..1485794c5f9 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, @@ -329,7 +330,7 @@ describe('CodexAccountService config sync', () => { limits })! const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ + await store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { @@ -379,7 +380,7 @@ describe('CodexAccountService config sync', () => { limits })! const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ + await store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { @@ -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', @@ -438,7 +439,7 @@ describe('CodexAccountService config sync', () => { limits })! const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ + await store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { @@ -459,11 +460,17 @@ describe('CodexAccountService config sync', () => { } as never, createRuntimeHome() as never ) - vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockImplementationOnce(() => { - throw 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/codex-accounts/service-test-harness.ts b/src/main/codex-accounts/service-test-harness.ts index 4587788ec67..8b81cb2125f 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/daemon/daemon-launch-paths.ts b/src/main/daemon/daemon-launch-paths.ts index 049800daff4..532bc75c574 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-process-identity-query.ts b/src/main/daemon/daemon-process-identity-query.ts index 479aa821ecf..d4eee6b1b84 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 00000000000..aee3e4f4420 --- /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/daemon/daemon-pty-spawn-preparations.ts b/src/main/daemon/daemon-pty-spawn-preparations.ts index 633f0d1c7f5..af52051a483 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 7cb8414026e..ffb4ea852cc 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 1ab19b0c97e..a10d054926c 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 aea1c50b2db..6f39197261f 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 bf18d6c0092..34563b0196a 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 83b0eabc5ed..05e849327b7 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 { 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 @@ -43,12 +47,28 @@ 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. */ export async function renameDurable(tmpPath: string, finalPath: string): Promise { - await rename(tmpPath, finalPath) + await renameFileWithWindowsRetryAsync(tmpPath, finalPath) await syncDirectory(dirname(finalPath)) } @@ -96,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 @@ -132,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 @@ -202,9 +221,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 522e59b908f..d4de6cac7e9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,7 @@ -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 { isBackgroundLaunch } from './window/foreground-activation-policy' import { focusExistingWindow as focusExistingWindowAction, setMainWindowOpener @@ -13,6 +14,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 +114,37 @@ if (preflightReady) { registerMainProcessIpcHandlers() installMainProcessQuitHandlers() void app.whenReady().then(async () => { - await initializeMainProcessReady({ - openMainWindow, - handleMacAppActivation - }) + try { + await initializeMainProcessReady({ + openMainWindow, + handleMacAppActivation + }) + } catch (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', { + failure_class: failureClass + }) + } + console.error(`[profile-state] ${message}`) + if (!state.isServeMode && !isBackgroundLaunch()) { + try { + await presentProfileStateStartupRecoveryDialog({ + message, + ...(failureClass === 'recovery-required' || failureClass === 'ambiguous-authority' + ? { 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 00000000000..c2882baf6c4 --- /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-switch-persistence.test.ts b/src/main/ipc/orca-profiles-switch-persistence.test.ts new file mode 100644 index 00000000000..e948d4054e7 --- /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.test.ts b/src/main/ipc/orca-profiles.test.ts index 215b559327d..2a8b24851d3 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', () => ({ @@ -54,21 +57,36 @@ 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', () => ({ 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' +const ipcEvent = { sender: { isDestroyed: () => false, send: vi.fn() } } + describe('registerOrcaProfileHandlers', () => { beforeEach(() => { // Why the port and per-test: userData resolves through AppEnvironment now, and @@ -76,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() @@ -87,6 +106,7 @@ describe('registerOrcaProfileHandlers', () => { seedNewOrcaProfileTelemetryConsentMock.mockReset() setActiveOrcaProfileMock.mockReset() transferOrcaProfileProjectMock.mockReset() + hasOrcaProfileStateDatabaseMock.mockReset().mockReturnValue(false) }) afterEach(() => { @@ -107,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' }) }) @@ -127,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 @@ -155,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' }) @@ -189,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() @@ -206,10 +228,10 @@ 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) + await vi.advanceTimersByTimeAsync(60_000) await rejection expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() @@ -225,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() @@ -236,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') }) @@ -260,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 ', @@ -302,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', @@ -328,11 +352,56 @@ 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() }) + 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('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', @@ -342,7 +411,7 @@ describe('registerOrcaProfileHandlers', () => { await expect( Promise.resolve( - handlers.get('orcaProfiles:transferProject')?.(null, { + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { sourceProfileId: 'personal', targetProfileId: 'work', repoId: 'repo-1', @@ -353,4 +422,84 @@ 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')?.(ipcEvent, { + 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(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed') + 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')?.(ipcEvent, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }) + ) + ).rejects.toThrow('unknown_source_repo') + + expect(store.resumeMaintenance).toHaveBeenCalledOnce() + 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')?.(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 480c6f350f9..694841e544e 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 { @@ -33,8 +33,12 @@ 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 { + flushActiveProfileBeforeFileMutation, + flushActiveProfileBeforeRelaunch +} from '../orca-profiles/profile-persistence-deadline' import { normalizeExecutionHostId } from '../../shared/execution-host' import { createCloudLinkedOrcaProfile, @@ -47,6 +51,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 @@ -55,40 +60,16 @@ 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') } 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') @@ -157,7 +138,12 @@ async function runBeforeProfileRelaunch( } } -function scheduleProfileRelaunch(reason: Extract): void { +type ProfileRelaunchReason = Extract + +function scheduleProfileRelaunch(reason: ProfileRelaunchReason, 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 — @@ -197,7 +183,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) { @@ -217,11 +203,12 @@ export function registerOrcaProfileHandlers( } // Why: the current profile must be persisted before the global index // points startup at the target profile. - await flushActiveProfileBeforeFileMutation(store) - await runBeforeProfileRelaunch(options.onBeforeRelaunch) + // Switching leaves source files intact; relaunch cleanup still needs its live writer. + await flushActiveProfileBeforeRelaunch(store) setActiveOrcaProfile(profileId) + await runBeforeProfileRelaunch(options.onBeforeRelaunch) - scheduleProfileRelaunch('profile-switch') + scheduleProfileRelaunch('profile-switch', event.sender) return { status: 'relaunching' } } @@ -230,7 +217,7 @@ export function registerOrcaProfileHandlers( ipcMain.handle( 'orcaProfiles:transferProject', async ( - _event, + event, rawArgs: TransferOrcaProfileProjectArgs ): Promise => { const args = transferProjectArgsFromUnknown(rawArgs) @@ -241,19 +228,37 @@ 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 = transferOrcaProfileProject(args, getProfileUserDataPath()) + const result = await transferActiveProfileProject( + args, + getProfileUserDataPath(), + store, + async () => { + await runBeforeProfileRelaunch(options.onBeforeRelaunch) + scheduleProfileRelaunch('profile-transfer', event.sender) + } + ) if (result.status === 'transferred') { - store.freezeWrites() await runBeforeProfileRelaunch(options.onBeforeRelaunch) - setActiveOrcaProfile(args.targetProfileId) - scheduleProfileRelaunch('profile-transfer') + 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 } - await flushActiveProfileBeforeFileMutation(store) - return transferOrcaProfileProject(args, getProfileUserDataPath()) + if (args.sourceProfileId !== current.activeProfileId) { + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + 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-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index 4669d7e5528..7a72c0d53b4 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/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index b6aba0855e6..30ef457a81d 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-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index e8e91ea7d45..a1ced7904f8 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(() => []), diff --git a/src/main/ipc/pty-persisted-incarnation-repair.test.ts b/src/main/ipc/pty-persisted-incarnation-repair.test.ts index 743963d0189..c057d6ec5d2 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-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index 56169791d23..70e358a8014 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -256,7 +256,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/host-env/fresh-spawn-routing.ts b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts index 64034d544c2..e03c29e79bc 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/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 7aaa90bf9b8..5d9ad086920 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -1,29 +1,25 @@ 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, 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' -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 = persistAdmittedStablePaneBinding({ + ctx.stablePaneBindingPersisted = await persistAdmittedStablePaneBinding({ store: ctx.deps.store, owner: ctx.stablePaneOwner, result: ctx.result, @@ -40,6 +36,53 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ agentSessionOperationOutcome: 'unknown' as const }) } + const committedSize = resolveCommittedPtySize({ + result: ctx.result, + requested: { cols: args.cols, rows: args.rows }, + cachedBeforeAttach: ctx.sessionSizeBeforeAttach + }) + 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 && + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + ctx.validatedLeafId !== null && + !ctx.stablePaneBindingPersisted + ) { + try { + const binding = { + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: ctx.validatedLeafId, + ptyId: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), + origin: spawnCommitBindingOrigin(ctx.result) + } + 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) + await discardUnpersistedPtySpawn(ctx.provider, ctx.result, () => { + if (args.connectionId && ctx.deps.store) { + ctx.deps.store.removeSshRemotePtyLease(args.connectionId, relayResultId) + } + }) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + } + 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 @@ -59,7 +102,7 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ 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). + // 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. @@ -69,88 +112,24 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ 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) } - // 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). - if ( - ctx.deps.store && - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - ctx.validatedLeafId !== null && - !ctx.stablePaneBindingPersisted - ) { - try { - const binding = { - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: ctx.validatedLeafId, - ptyId: ctx.result.id, - ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), - ...(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) - } - } catch (err) { - console.error('[pty] failed to persist PTY binding after spawn:', err) - if (!ctx.result.isReattach) { - 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) - 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 - }) - } - } - // 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) - } - // 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. + 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 @@ -166,5 +145,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 6266faf0c02..aa726fdc685 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. */ @@ -150,7 +142,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 +218,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/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 90b700f24b2..77491c9fb11 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 @@ -23,65 +17,22 @@ 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 { 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 - 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) - } + admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) + 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 && @@ -101,7 +52,9 @@ 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) { ctx.deps.runtime?.cancelPendingPtyRegistration?.( @@ -131,6 +99,15 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise 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, + store: Store | undefined, + ...args: Parameters +): Promise | undefined { + 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 + ) { + 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 00000000000..ccd3c3d582d --- /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/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 5d25e11f57c..83ebdd50364 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 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 } + } + 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 972f479b492..390bcdd451a 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 e68bcf6ec99..ce3c578cfa0 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/pane/terminal-spawn-restore.ts b/src/main/ipc/pty/pane/terminal-spawn-restore.ts new file mode 100644 index 00000000000..cbe3d337ae3 --- /dev/null +++ b/src/main/ipc/pty/pane/terminal-spawn-restore.ts @@ -0,0 +1,46 @@ +import type { PtySpawnResult } from '../../../providers/types' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { pendingByPaneKey, rendererSerializerReadiness } from './serializer-state' + +export function seedHeadlessTerminalFromSpawnResult( + runtime: OrcaRuntimeService | undefined, + result: PtySpawnResult, + paneKey: string | null +): void { + // A mounted renderer owns richer history than the provider snapshot. + if ( + !runtime || + (paneKey && pendingByPaneKey.has(paneKey)) || + (result.isReattach === true && rendererSerializerReadiness.has(result.id)) + ) { + return + } + if (typeof result.snapshot === 'string' && result.snapshot.length > 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 09d41460263..856039e7d5a 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 { @@ -16,13 +16,8 @@ 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 { 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' import { @@ -31,17 +26,34 @@ 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 { + 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({ + 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 = persistAdmittedStablePaneBinding({ + ctx.stablePaneBindingPersisted = await persistAdmittedStablePaneBinding({ store: ctx.hostSessionBinding?.store, owner: ctx.stablePaneOwner, result: ctx.result, @@ -63,12 +75,9 @@ 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) - } - ctx.deps.runtime?.registerPty( + const rejectedRegistration = registerPersistedPtySpawn( + ctx.deps.runtime, + ctx.hostSessionBinding?.store ?? ctx.deps.store, ctx.result.id, owner.surface.worktreeId, args.connectionId ?? null, @@ -80,6 +89,14 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {}) } ) + 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,81 +125,29 @@ 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 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 - ? ctx.hostSessionBinding.store.persistPtyBinding( - binding, - toSshExecutionHostId(args.connectionId) - ) - : 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') } } 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 } @@ -190,13 +155,11 @@ 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) { - ctx.deps.runtime?.registerPty( + const rejectedRegistration = registerPersistedPtySpawn( + ctx.deps.runtime, + ctx.hostSessionBinding?.store ?? ctx.deps.store, ctx.result.id, args.worktreeId, args.connectionId ?? null, @@ -217,10 +180,42 @@ 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) } + 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). @@ -231,17 +226,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/ipc/session.ts b/src/main/ipc/session.ts index 9fdfd8214b2..855401f96c1 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 00000000000..2e19eaf73f0 --- /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 new file mode 100644 index 00000000000..0cfd80d7eef --- /dev/null +++ b/src/main/orca-profiles/profile-active-transfer.test.ts @@ -0,0 +1,232 @@ +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 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' + +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 = domainState.writeProfileProjectDomainChanges + return vi + .spyOn(domainState, 'writeProfileProjectDomainChanges') + .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' }) + expect(() => store.flushOrThrow()).toThrow('final persistence') + 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' }) + expect(() => store.flushOrThrow()).toThrow('final persistence') + 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' }) + expect(() => store.flushOrThrow()).toThrow('final persistence') + 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' }) + 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 new file mode 100644 index 00000000000..53ba8481da5 --- /dev/null +++ b/src/main/orca-profiles/profile-active-transfer.ts @@ -0,0 +1,39 @@ +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' +import { flushActiveProfileBeforeFileMutation } from './profile-persistence-deadline' + +/** Keep the active Store stopped until file mutation either succeeds or proves unchanged. */ +export async function transferActiveProfileProject( + args: TransferOrcaProfileProjectArgs, + userDataPath: string, + 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') + } + result = transferOrcaProfileProject(args, userDataPath) + } catch (error) { + const needsRecovery = + (!hadDatabase && hasOrcaProfileStateDatabase(args.sourceProfileId, userDataPath)) || + profileHasPendingProjectMove(args.sourceProfileId, userDataPath) + // 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-cloud-auth-status.test.ts b/src/main/orca-profiles/profile-cloud-auth-status.test.ts index 7a28783e9a9..3db3ccb92e9 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 1d02c4d7dcc..09f9ea5b3a0 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 7897e299a21..9ddc52e1dae 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 00000000000..03500f74e26 --- /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-persistence-deadline.test.ts b/src/main/orca-profiles/profile-persistence-deadline.test.ts new file mode 100644 index 00000000000..87e0507c507 --- /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 084a53c40fe..73280914e4a 100644 --- a/src/main/orca-profiles/profile-persistence-deadline.ts +++ b/src/main/orca-profiles/profile-persistence-deadline.ts @@ -1,8 +1,33 @@ 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: Store): Promise { +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) => { @@ -12,7 +37,7 @@ 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([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 new file mode 100644 index 00000000000..30f5aef805e --- /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 { PersistedState } from '../../shared/persisted-state-types' + +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: PersistedState +): 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 00000000000..3441b2d8f3a --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-move-intent.ts @@ -0,0 +1,58 @@ +import { randomUUID } from 'node:crypto' +import type { ProfileProjectDomainMoveIntent } from './profile-project-move-record' +export type { ProfileProjectDomainMoveIntent } from './profile-project-move-record' +import { + profileProjectDomainDigests, + profileProjectDomainFingerprint, + type ProfileProjectDomainChanges +} from './profile-project-domain-changes' +import { + readProfileProjectTransferState, + type ReadProfileProjectTransferResult +} from './profile-project-domain-state' + +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 + ) +} 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 00000000000..e788b4b0def --- /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 00000000000..00ceb64455e --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-transfer.test.ts @@ -0,0 +1,546 @@ +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('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) => + 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 new file mode 100644 index 00000000000..51fd796e5ff --- /dev/null +++ b/src/main/orca-profiles/profile-project-move-intent.ts @@ -0,0 +1,124 @@ +import { randomUUID } from 'node:crypto' +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 { + readProfileStateWithRevision, + writeSerializedProfileState, + type ReadProfileStateResult +} from './profile-project-state-file' +import { getOrcaProfileMoveIntentDirectory } from './profile-storage-paths' +import { readProfileProjectDomainMoveState } from './profile-project-domain-move-intent' +import { writeProfileProjectDomainChanges } from './profile-project-domain-state' +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 { + validateProfileProjectMoveIntent(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, + 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 +} + +function recoverProfileProjectMoveIntent( + userDataPath: string, + intent: ProfileProjectMoveIntent +): void { + const { sourceBefore, targetBefore, sourceAfter, targetAfter } = + intent.version === 2 + ? readProfileProjectDomainMoveState(userDataPath, intent) + : readLegacyMoveState(userDataPath, intent) + + if (sourceAfter && targetAfter) { + removeProfileProjectMoveIntent(userDataPath, intent.id) + return + } + if (sourceBefore && targetBefore) { + removeProfileProjectMoveIntent(userDataPath, intent.id) + return + } + if (sourceBefore && targetAfter) { + 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 + } + if (sourceBefore && !targetAfter && !targetBefore) { + throw new Error(`Profile move ${intent.id} has an unrecognized target state`) + } + if (targetAfter && !sourceAfter) { + // 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) { + 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 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 && + snapshot.serialized !== undefined && + hashProfileStateJson(snapshot.serialized) === hash + ) +} 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 00000000000..68391cd117d --- /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/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index d4f291a09e4..13b8d1afae6 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -15,29 +15,116 @@ 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 { 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 -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 -function recordOrEmpty(value: unknown): Record { - return isRecord(value) ? value : {} -} - -export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { - const defaults = getDefaultPersistedState(homedir()) - const dataFile = getOrcaProfileDataFile(profileId, userDataPath) - if (!existsSync(dataFile)) { - return structuredClone(defaults) + constructor(profileId: string, message?: string) { + super(message ?? `Profile ${profileId} has both SQLite and legacy JSON state`) + this.name = 'AmbiguousProfileStateStorageError' } - const parsed: Partial = JSON.parse(readFileSync(dataFile, 'utf-8')) +} + +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) { + assertProfileStateCanInitialize({ dataFile, databaseFile, profileId }) + } + return hasDatabase ? 'sqlite' : 'json' +} + +/** + * 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 { + if (rawJson === undefined) { + return structuredClone(getDefaultPersistedState(homedir())) + } + return normalizeProfileProjectState(parseProfileStateRoot(rawJson)) +} + +export function normalizeProfileProjectState( + parsed: Partial +): TransferProfileState { + const defaults = getDefaultPersistedState(homedir()) return rebuildRepoBackedProjectState({ ...defaults, ...parsed, @@ -91,15 +178,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 00000000000..582c569dcc7 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer-migration.test.ts @@ -0,0 +1,314 @@ +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 profileProjectDomainState from './profile-project-domain-state' + +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.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) + 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 = profileProjectDomainState.writeProfileProjectDomainChanges + const write = vi + .spyOn(profileProjectDomainState, 'writeProfileProjectDomainChanges') + .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 00000000000..1b380030c65 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer-migration.ts @@ -0,0 +1,27 @@ +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 { + 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 +): ReadProfileProjectTransferResult { + const migrated = migrateProfileStateToSqlite({ + dataFile: getOrcaProfileDataFile(profileId, userDataPath), + databaseFile: getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId, + expectedLegacyJson: snapshot.serialized, + serializedState: snapshot.serialized ?? '{}' + }) + try { + 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 fc40cd03bb6..67c780f7208 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,50 @@ 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 { + persistProfileProjectMoveIntent, + 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: '' } @@ -50,12 +102,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 +413,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 75022d3ad65..18e3a3e90fe 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 { readProfileState, 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, @@ -11,6 +17,13 @@ import { createTransferPayload } from './profile-project-transfer-payload' import { repoPhysicalKey } from './profile-project-worktree-identity' +import { migrateProfileProjectTransferParticipant } from './profile-project-transfer-migration' +import { + persistProfileProjectMoveIntent, + recoverPendingProfileProjectMoves, + removeProfileProjectMoveIntent, + type ProfileProjectMoveIntent +} from './profile-project-move-intent' function assertKnownProfiles(args: TransferOrcaProfileProjectArgs, userDataPath: string): void { const profiles = getOrcaProfileListState(userDataPath).profiles @@ -30,14 +43,15 @@ 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) - 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) { @@ -50,6 +64,27 @@ 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 sourceState = sourceSnapshot.state + const targetState = targetSnapshot.state const targetRepo = createTargetRepo(sourceRepo, targetState, args.mode === 'copy') const payload = createTransferPayload({ sourceState, @@ -57,14 +92,62 @@ export function transferOrcaProfileProject( targetRepo, includeSessions: args.mode === 'move' }) - writeProfileState(args.targetProfileId, userDataPath, applyPayloadToTarget(targetState, payload)) - if (args.mode === 'move') { - writeProfileState( - args.sourceProfileId, + const targetAfterState = applyPayloadToTarget(targetState, payload) + const sourceAfterState = + args.mode === 'move' ? removeSourceRepo(sourceState, sourceRepo.id) : undefined + 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: sourceChanges, + target: targetChanges + }) + persistProfileProjectMoveIntent(userDataPath, moveIntent) + } + if (targetChanges !== undefined) { + writeProfileProjectDomainChanges(args.targetProfileId, userDataPath, targetChanges) + } else { + writeSerializedProfileState( + args.targetProfileId, userDataPath, - removeSourceRepo(sourceState, sourceRepo.id) + 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) + } + } return { status: 'transferred', mode: args.mode, diff --git a/src/main/orca-profiles/profile-storage-paths.ts b/src/main/orca-profiles/profile-storage-paths.ts index 81dc990b0e1..1e8de1a367e 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 00000000000..63ec99ca627 --- /dev/null +++ b/src/main/orcad/orcad-entry.test.ts @@ -0,0 +1,38 @@ +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 = { + flushFinalOrThrowAsync: vi.fn(async () => { + events.push('flush') + }), + freezeWritesAsync: vi.fn(async () => { + events.push('freeze') + }) + } + + await flushOrcadProfileStoreForShutdown(store) + + expect(store.flushFinalOrThrowAsync).toHaveBeenCalledExactlyOnceWith({ + exportJsonCompatibility: true + }) + 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 freezeWritesAsync = vi.fn(async () => {}) + const store = { + flushFinalOrThrowAsync: vi.fn(async () => { + throw flushError + }), + freezeWritesAsync + } + + await expect(flushOrcadProfileStoreForShutdown(store)).rejects.toBe(flushError) + expect(freezeWritesAsync).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index edc4426a2a9..70a028419ce 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -15,17 +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 { startOrcadWithLifecycle } from './orcad-lifecycle' +import { OrcadInstanceLockError } from './orcad-instance-lock' +import { flushOrcadProfileStoreForShutdown, startOrcadWithHost } from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' +import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access' import { changedAiVaultSearchSettings, type AiVaultSearchSettings @@ -110,27 +109,10 @@ export type OrcadHandle = { */ export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() - const userDataPath = resolveUserDataPath() - // 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() ) } @@ -145,10 +127,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 +141,9 @@ async function startOrcadRuntime( await import('../runtime/agent-status-observed-pane-identity') let rpc: InstanceType | null = null + let profileStoreForShutdown: + | { flushFinalOrThrowAsync(): Promise; freezeWritesAsync(): Promise } + | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} registerCleanup(async () => { @@ -169,13 +151,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 +173,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 } = + 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` @@ -192,15 +182,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 +202,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 +241,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 +255,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 +273,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 +351,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 +377,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 c77f18477f0..70e02b37e87 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 d9a567531ac..0c3a3525171 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) }) @@ -57,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 () => { @@ -96,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 913a21c4874..208b805ec79 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 { @@ -33,3 +43,53 @@ export async function startOrcadWithLifecycle( throw error } } + +/** 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(options?: { exportJsonCompatibility?: boolean }): Promise + freezeWritesAsync(): Promise +}): Promise { + try { + await store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + } finally { + 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 new file mode 100644 index 00000000000..c0bea5de6e3 --- /dev/null +++ b/src/main/orcad/orcad-profile-state-startup.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, 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') + +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', async () => { + const store = { getSettings: vi.fn() } + createProfileStateStoreForStartupMock.mockReturnValue({ + store, + authority: { readSerializedState: vi.fn() }, + backend: 'sqlite', + classification: 'json-only', + migrated: true + }) + + const result = await 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) + }) + + 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 new file mode 100644 index 00000000000..78b0339c715 --- /dev/null +++ b/src/main/orcad/orcad-profile-state-startup.ts @@ -0,0 +1,64 @@ +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 async function createOrcadProfileStateStartup( + userDataPath: string +): Promise { + initOrcaProfilePaths() + const profile = ensureActiveOrcaProfile(userDataPath) + const authorityMode = orcadProfileStateAuthorityMode() + const result = await createProfileStateStoreForStartup({ + dataFile: profile.dataFile, + databaseFile: profile.stateDatabaseFile, + profileId: profile.profile.id, + runtime: 'orcad', + authorityMode, + storageAuthority: 'runtime' + }) + const authority = { + backend: result.backend, + classification: result.classification, + authority_mode: authorityMode, + runtime: 'orcad' as const, + migrated: result.migrated + } + 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/orcad/orcad-profile-state-telemetry.test.ts b/src/main/orcad/orcad-profile-state-telemetry.test.ts new file mode 100644 index 00000000000..9d79952a14c --- /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 00000000000..e1cec920b6b --- /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 a8fbbc9fe16..21ad507c016 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' @@ -6,12 +6,15 @@ 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' +import { profileStateAccessPaths } from '../persistence/profile-state/profile-state-access-owner' 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 +23,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 () => {}, @@ -33,16 +36,29 @@ 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 } - } + }) })) 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 +125,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() @@ -140,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-flush-and-save-scheduling.test.ts b/src/main/persistence-flush-and-save-scheduling.test.ts index 2d6180d5fa8..c40b092a02a 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 d71dbc9882c..6e75013c7ab 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 ff819432c54..e3f75b3763f 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 934ab44e1cb..43a4f3e0561 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-loading-store-extraction.test.ts b/src/main/persistence-loading-store-extraction.test.ts index a3c5e248909..094987a91bf 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-pty-binding-leaf-tab-resolution.test.ts b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts index 2f0dc41daaf..7cccd060c44 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 f72234b6823..47e4bd4ae76 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 b092c3d7d9e..25c12fd07b1 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 1b092293c68..afecbf56883 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 62619bbd05b..7674d0aeceb 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/applying-settings/feature-interaction-recording.ts b/src/main/persistence/applying-settings/feature-interaction-recording.ts index 2a29bc64f66..662e79bd40f 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 db06a2738fd..5aad240e554 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/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts index 3b259989906..07770fe70b9 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 f06e5b0ece7..b2e3cef030b 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/automation-persistence.ts b/src/main/persistence/loading-store/automation-persistence.ts index 2bed89ff09b..64abd8ed2a9 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/backup-recovery-rotation.ts b/src/main/persistence/loading-store/backup-recovery-rotation.ts index 7cf0a7a8078..7ed6b21249e 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/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index d2a31419a03..f2d46301f74 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' @@ -79,27 +61,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) - logPersistenceStartupMilestone('persistence-load-start', { + const fileExistedOnLoad = authoritySource + ? serialized !== undefined || parsedInput !== undefined + : serialized !== undefined || existsSync(dataFile) + logStartupMilestone('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) { + logStartupMilestone('persistence-read-done', { + bytes: Buffer.byteLength(raw), + durationMs: Math.round(performance.now() - readStartedAt) + }) + logStartupMilestone('persistence-json-parse-start') + parsed = JSON.parse(raw) + 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 + } + 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 +210,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 +242,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)) { @@ -293,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/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts index 2532ff3b2d3..9800b541373 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 00000000000..24dd5db6bea --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-context.ts @@ -0,0 +1,17 @@ +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 + queuedSnapshot?: { + completion: Promise + capture: { + skipIfClean: boolean + fullCheckpoint: boolean + pendingSnapshotFileWork: Promise | null + } + } +} 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 00000000000..78130afbb1f --- /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 new file mode 100644 index 00000000000..2c79fa2b073 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-runtime.ts @@ -0,0 +1,69 @@ +import type { StoreRuntimeState } from './store-runtime-state' + +export type PrimaryStateWriteOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'backupRotationInFlight' + | 'dataFile' + | 'dirtyProfileStateDomains' + | 'durableMutationPhase' + | 'fatalMutationError' + | 'flushOrThrow' + | 'runDurableMutation' + | 'firstPendingSaveAt' + | 'inFlightAsyncTmpFile' + | 'lastDurableWriteGeneration' + | 'lastWrittenStateHash' + | 'pendingSnapshotFileWork' + | 'pendingAutomationRunsAfter' + | 'pendingWrite' + | 'profileMaintenancePending' + | '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?.asynchronous) { + throw new Error('Live profile persistence requires an awaited revision check') + } + if (authority && !authority.assertCurrentRevision) { + return false + } + 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 new file mode 100644 index 00000000000..fd598b06158 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-sync.ts @@ -0,0 +1,98 @@ +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 } = {} +): boolean { + const { runtime, serialization, backups } = context + if (runtime.fatalMutationError) { + throw runtime.fatalMutationError + } + if (runtime.writesFrozen) { + return false + } + if (runtime.profileStateAuthority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited flush') + } + 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 false + } + if (selective.consumedAutomationRuns) { + runtime.pendingAutomationRunsAfter = undefined + } + runtime.lastWrittenStateHash = null + runtime.protectedSecrets.commitRetentionUpdates(selective.protectedSecretUpdates) + markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) + runtime.profileStateAuthority?.scheduleBackup?.() + 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 true + } + if (runtime.profileStateAuthority) { + 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 true + } + 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) + } + 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 00000000000..71862bc3b44 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-worker.ts @@ -0,0 +1,106 @@ +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) + if (runtime.writeGeneration === generation) { + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.firstPendingSaveAt = null + } + } 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 3820723fffb..db1eed63836 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -1,44 +1,23 @@ -import { mkdirSync, existsSync, unlinkSync } from 'node:fs' -import { mkdir, open, rm } from 'node:fs/promises' -import { durableWriteTempPath, renameDurable, writeFileDurableSync } from '../../durable-file-write' -import { dirname } from 'node:path' +import { unlinkSync } from 'node:fs' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { parseCodexResetCreditAttemptLedger, type CodexResetCreditAttemptLedger } from '../../../shared/codex-reset-credit-attempt-ledger' - -import type { StoreRuntimeState } from './store-runtime-state' +import { + stopAfterFailedPrimaryStateMutation, + 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' +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' const primaryStateWriteOperationsContext = Symbol('PrimaryStateWriteOperations') -type PrimaryStateWriteOperationsContext = { - runtime: PrimaryStateWriteOperationsRuntime - serialization: StateSerializationSecretHandlingOperations - backups: BackupRecoveryRotationOperations -} - export class PrimaryStateWriteOperations { readonly [primaryStateWriteOperationsContext]: PrimaryStateWriteOperationsContext @@ -51,232 +30,242 @@ export class PrimaryStateWriteOperations { } flushOrThrow(): void { - if (this[primaryStateWriteOperationsContext].runtime.quitFlushStarted) { + const context = this[primaryStateWriteOperationsContext] + const { runtime } = context + if (runtime.quitFlushStarted || runtime.profileMaintenancePending) { 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.profileStateAuthority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited flush') } - this[primaryStateWriteOperationsContext].runtime.firstPendingSaveAt = null - const asyncWriteWasInFlight = - this[primaryStateWriteOperationsContext].runtime.pendingWrite !== null + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = 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 }) } flushActiveViewPreferenceOrThrow(): void { + if (this[primaryStateWriteOperationsContext].runtime.profileMaintenancePending) { + throw new Error('Cannot flush active-view persistence during profile maintenance') + } 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) { + return Promise.reject(new Error('Cannot mutate finalized profile persistence')) + } + return enqueuePrimaryStateOperation(this, async () => { + if (runtime.profileStateAuthority?.asynchronous) { + runtime.profileStateAuthority.assertWritable() + } + 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' && + runtime.lastDurableWriteGeneration >= runtime.writeGeneration) + ) { + 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') { + if (mutation.rollback) { + try { + this.runAdmittedMutationCallback('rollback', mutation.rollback) + } catch (rollbackError) { + await stopAfterFailedPrimaryStateMutation(runtime, rollbackError) + throw rollbackError + } + } + } + throw error + } + return mutation.value + }) + } + + 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 ) } - replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { - if (this[primaryStateWriteOperationsContext].runtime.writesFrozen) { - throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') - } + replaceCodexResetCreditAttemptLedgerAndFlush( + ledger: CodexResetCreditAttemptLedger + ): Promise { + const { runtime } = this[primaryStateWriteOperationsContext] const next = parseCodexResetCreditAttemptLedger(ledger) - const previous = this[primaryStateWriteOperationsContext].runtime.state - .codexResetCreditAttemptLedger - ? structuredClone( - this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger - ) - : undefined - this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = next - try { - this[primaryStateWriteOperationsContext].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 - 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): Promise { +export function enqueueWrite( + owner: PrimaryStateWriteOperations, + options: { fullCheckpoint?: boolean; skipIfClean?: boolean; signal?: AbortSignal } = {} +): Promise { + 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 + // 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 () => { + // 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 ( + capture.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 (capture.fullCheckpoint) { + runtime.dirtyProfileStateDomains = null + } + await writeToDiskAsync(owner) + }) + if (batchable) { + context.queuedSnapshot = { completion, capture } + } + // A caller may stop waiting; the admitted write must retain its acknowledgement and ordering. + return waitForPromiseWithSignal(completion, options.signal) +} + +export function enqueuePrimaryStateOperation( + owner: PrimaryStateWriteOperations, + operation: () => Promise +): Promise { + 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([ - 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(operation).finally(() => { + if (context.queuedSnapshot?.completion === write) { + context.queuedSnapshot = undefined + } + }) const trackedWrite = write + .then(() => {}) .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) { - return +export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { + const { runtime } = owner[primaryStateWriteOperationsContext] + if (runtime.fatalMutationError) { + throw runtime.fatalMutationError } - const gen = owner[primaryStateWriteOperationsContext].runtime.writeGeneration - const { payload, stateHash, protectedSecretUpdates } = - owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() - // 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 (runtime.writesFrozen) { + return false + } + const gen = runtime.writeGeneration + if (runtime.profileStateAuthority?.asynchronous) { + return writeProfileStateInWorker( + owner[primaryStateWriteOperationsContext], + runtime.profileStateAuthority ) - return } - const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile - 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 (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { - return - } - owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = tmpFile - try { - await renameDurable(tmpFile, dataFile) - renamed = true - } catch (error) { - if ( - (error as NodeJS.ErrnoException).code !== 'ENOENT' || - owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen - ) { - throw error - } - } finally { - if (owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile === tmpFile) { - owner[primaryStateWriteOperationsContext].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 - ) - } else if (renamed) { - owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = null - } - if (renamed) { - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - gen - ) - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (!renamed) { - 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 - ) - 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) + if (runtime.profileStateAuthority) { + // SQL commits are synchronous so both entry points share the same generation fence. + return writeToDiskSync(owner[primaryStateWriteOperationsContext], { expectedGeneration: gen }) } + return writeJsonProfileState(owner[primaryStateWriteOperationsContext], gen) } export function installPrimaryStateWriteOperationsContext( diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts index 8e910ed235d..b52e748f190 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 00000000000..a7f3b69a8e3 --- /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, '', 'text') + hash.update(serialized.stateHash) + domains.push({ + domain, + payload: serialized.payload.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 00000000000..0ced5ba3248 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-authority.ts @@ -0,0 +1,127 @@ +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' + +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 + + /** 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?: (cancel?: boolean) => 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 + + /** 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. */ + 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 } + | { + 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-caller-cancellation.test.ts b/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts new file mode 100644 index 00000000000..728c63f6b8d --- /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-checkpoints.test.ts b/src/main/persistence/loading-store/profile-state-checkpoints.test.ts new file mode 100644 index 00000000000..1ef5c6ef556 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-checkpoints.test.ts @@ -0,0 +1,238 @@ +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('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) => { + 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-delayed-authority-fixture.ts b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts new file mode 100644 index 00000000000..57a4915ad3f --- /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 new file mode 100644 index 00000000000..9cf3d1ec2d1 --- /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', async () => { + 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() + await 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-flush-lifetime.ts b/src/main/persistence/loading-store/profile-state-flush-lifetime.ts new file mode 100644 index 00000000000..215c1babe85 --- /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-import-lifetime.test.ts b/src/main/persistence/loading-store/profile-state-import-lifetime.test.ts new file mode 100644 index 00000000000..45f4111551b --- /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/profile-state-maintenance-compatibility.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts new file mode 100644 index 00000000000..b1497f049a4 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts @@ -0,0 +1,125 @@ +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', () => ({ + 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') + }) + + 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.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' }) + + 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-final-failure.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts new file mode 100644 index 00000000000..7b9a5e16fa9 --- /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-fixture.ts b/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts new file mode 100644 index 00000000000..31e4d173b6f --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts @@ -0,0 +1,109 @@ +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, + 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, + onFailure + }) + 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-recovery.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts new file mode 100644 index 00000000000..84a849b3b26 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts @@ -0,0 +1,145 @@ +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 an active backup after a routine flush completes', + async (kind) => { + const { store } = 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 + await store.flushPendingOrThrowAsync() + const stop = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'final' + ? store.flushFinalOrThrowAsync() + : store.freezeWritesAsync() + await canceled.promise + await 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 new file mode 100644 index 00000000000..8dcb39eef54 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance.test.ts @@ -0,0 +1,297 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +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 { + 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(ActiveViewPreference.prototype, 'flushPendingAsync').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(ActiveViewPreference.prototype, 'flushPendingAsync').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('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() + vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( + async (_job, options) => { + started.resolve() + await new Promise((resolve) => + options?.signal?.addEventListener('abort', () => resolve(), { once: true }) + ) + canceled.resolve() + await release.promise + throw new Error('backup aborted') + } + ) + 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 canceled.promise + expect(done).toBe(false) + expect(close).not.toHaveBeenCalled() + release.resolve() + await paused + expect(close).toHaveBeenCalledOnce() + expect(profileStateDatabaseBackups(databaseFile)).toHaveLength(0) + }) + + it('drains an accepted flush before rejecting canceled maintenance', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').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 00000000000..c4632f26fb1 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance.ts @@ -0,0 +1,198 @@ +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' +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 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 { + 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') + } + resumeScheduling() + } + } + }) + 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 recoverable +} + +async function canResumeFailedMaintenance( + runtime: StoreRuntimeState, + options: ProfileStateMaintenanceOptions, + error: unknown +): Promise { + try { + await drainProfileFileWork(runtime) + if ( + options.flush === false || + runtime.writesFrozen || + profileStateWriterFailureOutcome(error) === 'indeterminate' + ) { + return false + } + const authority = runtime.profileStateAuthority + if (authority?.asynchronous) { + authority.assertWritable() + } + await (authority?.pauseForMaintenance + ? (await authority.pauseForMaintenance()).resume() + : authority?.assertCurrentRevision?.()) + return true + } catch { + return false + } +} + +async function pauseProfileState( + runtime: StoreRuntimeState, + domains: StoreDomains, + { signal, flush = true }: ProfileStateMaintenanceOptions +): Promise { + const authority = runtime.profileStateAuthority + signal?.throwIfAborted() + await drainProfileFileWork(runtime) + signal?.throwIfAborted() + if (flush) { + // Cancel between commands so a dispatched commit retains a known outcome. + await flushCurrentStateAsync(domains.flushBarriers, { + requireInitialGenerationDurable: true, + fullCheckpoint: true + }) + signal?.throwIfAborted() + await authority?.writeJsonCompatibilityExportAsync?.(runtime.dataFile) + } + signal?.throwIfAborted() + runtime.writesFrozen = true + if (!flush) { + await authority?.close?.() + return { + resume: async () => { + throw new Error('Recovery maintenance requires reloading the profile') + } + } + } + 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 { + await drainProfileStateOperations([ + ...runtime.pendingProfileFlushes, + runtime.pendingWrite, + runtime.pendingSnapshotFileWork, + runtime.pendingGithubCacheWrite, + runtime.activeViewPreference.waitForPendingWrite(), + runtime.profileStateAuthority?.drainBackups?.( + runtime.profileMaintenancePending || runtime.quitFlushStarted + ) + ]) +} + +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-pty-retirement-finalization.test.ts b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts new file mode 100644 index 00000000000..3fe64562997 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts @@ -0,0 +1,199 @@ +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('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') { + vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('disk refused')) + } + await expect( + store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + if (phase === 'mutate') { + throw failure + } + return { + value: undefined, + rollback: () => { + throw failure + } + } + }) + ).rejects.toBe(failure) + 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' }) + 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-selective-write.ts b/src/main/persistence/loading-store/profile-state-selective-write.ts new file mode 100644 index 00000000000..060078ea436 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-selective-write.ts @@ -0,0 +1,99 @@ +import type { ProtectedSecretRetentionUpdate } from '../../protected-secret-persistence' +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' + +export type SelectiveProfileStateWriteResult = { + handled: boolean + aborted: boolean + consumedAutomationRuns: boolean + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] +} + +export type PreparedSelectiveProfileStateWrite = { + replacements: ProfileStateDomainReplacement[] + automationRuns: readonly AutomationRun[] | undefined + consumedAutomationRuns: boolean + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] +} + +export function writeSelectiveProfileState( + authority: ProfileStateAuthority | undefined, + serialization: StateSerializationSecretHandlingOperations, + dirtyDomains: Set | null, + pendingAutomationRunsAfter: readonly AutomationRun[] | undefined, + isCurrent?: () => boolean +): SelectiveProfileStateWriteResult { + const prepared = prepareSelectiveProfileStateWrite( + authority, + serialization, + dirtyDomains, + pendingAutomationRunsAfter + ) + if (!prepared) { + return { + handled: false, + aborted: false, + consumedAutomationRuns: false, + protectedSecretUpdates: [] + } + } + if (isCurrent && !isCurrent()) { + return { + handled: true, + aborted: true, + consumedAutomationRuns: false, + protectedSecretUpdates: [] + } + } + if (prepared.automationRuns !== undefined) { + authority?.writeSerializedAutomationRuns?.(prepared.replacements, prepared.automationRuns) + } else { + authority?.writeSerializedDomains?.(prepared.replacements) + } + 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-settings-writes.test.ts b/src/main/persistence/loading-store/profile-state-settings-writes.test.ts new file mode 100644 index 00000000000..e7f64b6278a --- /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 00000000000..596b75cf219 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts @@ -0,0 +1,858 @@ +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', async () => { + 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( + await 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( + await 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', async () => { + 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() + await authority.drainBackups() + 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 00000000000..f8214c93711 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-store-backups.test.ts @@ -0,0 +1,249 @@ +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() + } + + await state.authority.drainBackups() + 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('acknowledges a routine flush while the previous recovery backup is still running', async () => { + const state = await fixture() + const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync + const started = Promise.withResolvers() + const gate = Promise.withResolvers() + releases.push(gate.resolve) + 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 + 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) => { + 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({ exportJsonCompatibility: true }) + : 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(JSON.parse(readFileSync(state.dataFile, 'utf8'))).toEqual( + readSnapshot(state.databasePath).state + ) + } + ) + + 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() + } + + await state.authority.drainBackups() + 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 00000000000..ec2506c32b9 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-update-quit.test.ts @@ -0,0 +1,242 @@ +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('exports a normal quit for an older build after sessions and settings changed', async () => { + const { store, dataFile, databasePath } = await fixture() + store.updateSettings({ theme: 'dark' }) + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + + 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 () => { + 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/profile-state-worker-coordination.test.ts b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts new file mode 100644 index 00000000000..0f1bdd6ff9d --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts @@ -0,0 +1,263 @@ +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 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +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(() => {}) + 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 fullCapture = vi.spyOn( + StateSerializationSecretHandlingOperations.prototype, + 'buildStateToSave' + ) + 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) + expect(fullCapture).toHaveBeenCalledOnce() + expect(authority.captures).toHaveLength(2) + }) + + 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 00000000000..4d72ce144c6 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts @@ -0,0 +1,124 @@ +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.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({ [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({ [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/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 00000000000..fb6f44a5d8a --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-write-batching.test.ts @@ -0,0 +1,251 @@ +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.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( + 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, 'writeCompleteSerializedDomains').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') + }) +}) 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 00000000000..066cea3cce5 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-async-durability.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, vi } from 'vitest' +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' + +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() + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + await store.flushPendingOrThrowAsync() + 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() + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([]) + gate.finish.resolve() + expect(await pending).toBe(true) + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).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 () => { + 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(() => {}) + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + 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.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(() => {}) + 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('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(() => {}) + 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-created-surface-rollback.test.ts b/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts new file mode 100644 index 00000000000..0feed681bf0 --- /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-fast-lane.test.ts b/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts index 8ac6de70a0d..3f8cc1c7e7d 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 9ae6304ed0f..3f0763c883f 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-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index c3cbff0796a..f902ff7d252 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -1,15 +1,9 @@ +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 { rollbackFailedPtyBinding } from './pty-binding-write-rollback' +import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields' import type { PtyBindingSourceExpectation } from './store' @@ -18,21 +12,22 @@ 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' + | 'dirtyProfileStateDomains' | 'state' | 'writeGeneration' | 'writeTimer' > -type PersistPtyBindingArgs = { +export type PersistPtyBindingArgs = { worktreeId: string tabId: string leafId: string @@ -72,43 +67,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 } } @@ -119,9 +128,19 @@ function writePtyBinding( resolvedHostId: ReturnType, bindingWorktreeId: string, paneKey: string -): void { - const runtime = owner[ptyBindingPersistenceOperationsContext].runtime +): () => void { + const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext] const sessionBeforeBinding = cloneWorkspaceSessionState(session) + const restore = (restoredSession = sessionBeforeBinding): void => { + if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { + runtime.state.workspaceSession = restoredSession + } else { + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: restoredSession + } + } + } try { if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { runtime.state.workspaceSessionsByHostId = { @@ -130,144 +149,44 @@ function writePtyBinding( } } applyPtyBinding(args, session, bindingWorktreeId, paneKey) - runtime.flushOrThrow() - } catch (err) { - if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { - runtime.state.workspaceSession = sessionBeforeBinding - } else { - runtime.state.workspaceSessionsByHostId = { - ...runtime.state.workspaceSessionsByHostId, - [resolvedHostId]: sessionBeforeBinding - } - } - 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 - } - 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 + 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 - }) - ] - 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 } + const boundSession = cloneWorkspaceSessionState(session) + return () => { + const current = sessions.getWorkspaceSession(resolvedHostId) + 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] + } } - layout.activeLeafId = args.leafId - if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { - layout.expandedLeafId = null + // Presentation edits do not replace the binding that must be rolled back. + 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 = rollbackFailedPtyBinding( + sessionBeforeBinding, + boundSession, + current, + bindingWorktreeId, + args.tabId, + args.leafId + ) + 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 00000000000..3024543505a --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -0,0 +1,146 @@ +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 + } + // 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() + 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/pty-binding-write-rollback.ts b/src/main/persistence/loading-store/pty-binding-write-rollback.ts new file mode 100644 index 00000000000..af7cb63d7b0 --- /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/pty-reattach-failure-routing.test.ts b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts new file mode 100644 index 00000000000..16f2978e04b --- /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-retirement-async-durability.test.ts b/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts new file mode 100644 index 00000000000..6751cc3a010 --- /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/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 00000000000..dd6754c1d73 --- /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-commit-dependencies-fixture.ts b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts new file mode 100644 index 00000000000..b1d185c38b1 --- /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-exit-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts new file mode 100644 index 00000000000..ef146779b4b --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts @@ -0,0 +1,238 @@ +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' +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', () => ({ + 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 } + ].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 = { + 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' + } + 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) + } 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.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 = + 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)] + : 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) + expect(() => + registerPersistedPtySpawn(runtime, store, binding.ptyId, binding.worktreeId, null, binding) + ).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 cleanup = registerPersistedPtySpawn( + runtime, + store, + binding.ptyId, + binding.worktreeId, + null, + binding + ) + if (!cleanup) { + throw new Error('exited registration did not start durable cleanup') + } + const pending = cleanup.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() +}) + +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() +}) 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 00000000000..9fb0d65a2cb --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts @@ -0,0 +1,77 @@ +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 { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-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: '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) + const deps = createPtySpawnCommitDependencies(runtime, store) + let commit: () => Promise + 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 + ctx.preAllocatedHandle = preAllocatedHandle + commit = () => commitPtyIpcSpawn(ctx) + } else { + 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 00000000000..719a3bbe924 --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts @@ -0,0 +1,146 @@ +import { afterEach, expect, it, vi } from 'vitest' +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' +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, 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', () => ({ + 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(() => { + 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', + 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 00000000000..26737df9dd0 --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts @@ -0,0 +1,79 @@ +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 { PtySpawnResult } from '../../providers/types' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-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: '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' } +] + +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 = createPtySpawnCommitDependencies(runtime, store) + 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 ctx = createRuntimePtySpawnState(deps, { ...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/secret-sentinel-substitution.test.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts index e58d0280bae..eaae559e77f 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 { describe, expect, it, vi } from 'vitest' +import { serializeCompleteProfileStateDomains } from './profile-state-authority-writes' import { applySecretSentinelSubstitutions, type SecretSentinelSubstitution @@ -36,14 +32,101 @@ 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 { 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) + }) + + 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', () => { it('produces bytes and a hash identical to the previous implementation', () => { const subs: SecretSentinelSubstitution[] = [ @@ -117,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(), @@ -172,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 afcdddafa77..30ec43eaa1d 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 -): { payload: Buffer; stateHash: string } { + degradedPrefix: string, + output?: 'buffer' +): { payload: Buffer; stateHash: string } +export function applySecretSentinelSubstitutions( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + 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/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index d358f4c8f62..12efd45a19e 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 37ffd9d366b..9c3cf23be7b 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,7 +18,13 @@ import { scheduleSave } from './write-scheduling' type SessionSnapshotOperationsRuntime = Pick< StoreRuntimeState, - 'pendingSnapshotFileWork' | 'state' | 'terminalScrollbackSnapshotStorage' + | 'durableMutationPhase' + | 'pendingSnapshotFileWork' + | 'profileMaintenancePending' + | 'quitFlushStarted' + | 'state' + | 'terminalScrollbackSnapshotStorage' + | 'writesFrozen' > const sessionSnapshotOperationsContext = Symbol('SessionSnapshotOperations') @@ -43,7 +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(true) setLocalWorkspaceSession(this, session) return } @@ -56,6 +70,7 @@ export class SessionSnapshotOperations { ): void { const resolved = resolveHostId(hostId) if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.assertSnapshotAdmission() setLocalWorkspaceSession(this, session, true) return } @@ -76,15 +91,34 @@ 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) + scheduleSave( + scheduling, + hostId === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId'] + ) + } + + private assertSnapshotAdmission(allowAdmittedMutation = false): void { + const { runtime } = this[sessionSnapshotOperationsContext] + 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/ssh-lease-async-durability.test.ts b/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts new file mode 100644 index 00000000000..6c646285d4b --- /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 00000000000..28f361a12cd --- /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 7da5144898e..7188b4efb66 100644 --- a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -43,10 +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 +type SshLeaseRecoveryOperationsRuntime = SshLeaseDurableMutationRuntime & + Pick const sshLeaseRecoveryOperationsContext = Symbol('SshLeaseRecoveryOperations') type SshLeaseRecoveryOperationsContext = { @@ -81,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[] { @@ -127,6 +138,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 +209,13 @@ export function getSshPtyConsumerRecoveryOperations( return { state: owner[sshLeaseRecoveryOperationsContext].runtime.state, protectedSecrets: owner[sshLeaseRecoveryOperationsContext].runtime.protectedSecrets, - flushDurableStateOrThrowAsync: () => - flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + runDurableMutation: (mutate) => + runSshLeaseDurableMutation( + owner[sshLeaseRecoveryOperationsContext].runtime, + owner[sshLeaseRecoveryOperationsContext].flushBarriers, + 'sshPtyConsumerRecoveries', + mutate + ) } } @@ -238,9 +257,19 @@ 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() + }, + runDurableMutation: (mutate) => + runSshLeaseDurableMutation( + owner[sshLeaseRecoveryOperationsContext].runtime, + owner[sshLeaseRecoveryOperationsContext].flushBarriers, + 'sshRemotePtyLeases', + mutate + ) } } diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts index d21ab5f04da..ce81cea373b 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 026afd12c01..d324c0956cf 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-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index c3f2059efe1..707110cee21 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-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index b14ea8ce3a4..ade6bccdbea 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -19,10 +19,20 @@ import type { AutomationListProjectionCache, AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' +import type { ProfileStatePersistenceAuthority } from './profile-state-authority' +import type { AutomationRun } from '../../../shared/automations-types' + +export type DurableProfileStateMutation = { + value: T + /** 'if-dirty' fences an existing change without rewriting an already durable generation. */ + persist?: boolean | 'if-dirty' + rollback?: () => void +} export type StoreRuntimeOptions = { dataFile?: string storageAuthority?: AutomationStorageAuthority + profileStateAuthority?: ProfileStatePersistenceAuthority } /** Mutable coordination state shared only with this Store's private collaborators. */ @@ -30,6 +40,7 @@ export class StoreRuntimeState { state!: PersistedState readonly dataFile: string readonly storageAuthority: AutomationStorageAuthority + readonly profileStateAuthority: ProfileStatePersistenceAuthority | undefined automationListProjectionCache: AutomationListProjectionCache | null = null activeViewPreference!: ActiveViewPreference readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage @@ -41,11 +52,19 @@ 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 + readonly pendingProfileFlushes = new Set>() quitFlushStarted = false quitFlushPromise: Promise | null = null 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 @@ -54,6 +73,7 @@ export class StoreRuntimeState { readonly protectedSecrets = new ProtectedSecretPersistence() loadNeedsSave = false flushOrThrow!: () => void + runDurableMutation!: (mutate: () => DurableProfileStateMutation) => Promise settingsChangeListeners = new Set< ( updates: Partial, @@ -72,6 +92,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 017583e8f86..2ebf42ff63a 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -1,3 +1,4 @@ +import { mkdirSync } from 'node:fs' import { dirname } from 'node:path' import { setMigrationUnsupportedPty, @@ -12,28 +13,41 @@ 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 type { WriteSchedulingOperations } from './write-scheduling' -import type { PrimaryStateWriteOperations } 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 { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { enqueuePrimaryStateOperation, writeToDiskAsync } from './primary-state-writes' +import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' +import { writeVersionedProfileStateExport } from '../profile-state/profile-state-versioned-export' +import { + beginProfileStateMaintenance, + freezeProfileStateWrites, + freezeProfileStateWritesAsync, + type ProfileStateMaintenanceOptions +} from './profile-state-maintenance' +import type { + AsyncProfileStateAuthority, + ProfileStateAuthorityInitialState, + ProfileStateStartupPaneAlias, + ProfileStatePersistenceAuthority, + ProfileStateMaintenance +} from './profile-state-authority' -export type StoreOptions = StoreRuntimeOptions +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 +} + +export type PreparedProfileStateExport = { + readonly json: string + commit(): void +} export type PtyBindingSourceExpectation = { worktreeId?: string tabId: string @@ -50,12 +64,46 @@ 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?.() + 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() - const loaded = this.domains.loader.load() - const normalized = normalizePersistedPaneIdentityState(loaded) + this.runtime.runDurableMutation = (mutate) => this.runDurableMutation(mutate) + 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, { + registerAliases: !imported, + collectUnboundPaneAlias: options.collectUnboundPaneAlias + }) this.state = normalized.state this.runtime.state = this.state this.runtime.activeViewPreference = new ActiveViewPreference( @@ -67,27 +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) + // 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) + } } } @@ -95,34 +154,163 @@ export class Store { return dirname(this.runtime.dataFile) } - freezeWrites(): void { - this.runtime.writesFrozen = true - if (this.runtime.writeTimer) { - clearTimeout(this.runtime.writeTimer) - this.runtime.writeTimer = null + /** + * 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?.asynchronous) { + throw new Error('Live profile exports require an awaited export') + } + 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?.asynchronous) { + throw new Error('Live profile exports require an awaited export') + } + if (!authority?.writeJsonExport) { + return undefined + } + this.runtime.dirtyProfileStateDomains = null + this.flushOrThrow() + + const writeExport = authority.writeJsonExport.bind(authority) + return writeVersionedProfileStateExport(this.runtime.dataFile, writeExport) + } + + /** Publish recovery and canonical JSON checkpoints for older builds. */ + 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 + } + this.runtime.dirtyProfileStateDomains = null + this.flushOrThrow() + 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, + reason?: string + ): 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') + } + return authority.quarantineDatabase(quarantineRoot, reason) + } + + freezeWrites(): void { + 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') + } + return authority.quarantineDatabase(quarantineRoot, reason) + } } // 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/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index 5b7f90cbb8f..96834ffbcf9 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 21fea4ac15c..9515986f86a 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 c08d4367989..aec8a6d5b5f 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,10 @@ type WriteFlushBarrierOperationsRuntime = Pick< | 'githubCacheGeneration' | 'lastDurableWriteGeneration' | 'pendingGithubCacheWrite' + | 'pendingProfileFlushes' + | 'pendingProfileMaintenance' + | 'profileMaintenancePending' + | 'profileStateAuthority' | 'quitFlushPromise' | 'quitFlushStarted' | 'staleGithubCacheTempCleanup' @@ -30,6 +35,7 @@ const writeFlushBarrierOperationsContext = Symbol('WriteFlushBarrierOperations') type WriteFlushBarrierOperationsContext = { runtime: WriteFlushBarrierOperationsRuntime writes: PrimaryStateWriteOperations + bestEffortFinalFlush?: Promise } export class WriteFlushBarrierOperations { @@ -40,136 +46,183 @@ export class WriteFlushBarrierOperations { } flush(): void { - this[writeFlushBarrierOperationsContext].runtime.automationListProjectionCache = null - if (this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted) { + const { runtime, writes } = this[writeFlushBarrierOperationsContext] + runtime.automationListProjectionCache = null + if (runtime.quitFlushStarted || runtime.profileMaintenancePending) { + return + } + if (runtime.profileStateAuthority?.asynchronous) { + runtime.writeGeneration++ + void flushCurrentStateAsync(this, { drainToStableGeneration: 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) } writeGithubCacheSnapshotSync(this) } - flushAsync(): Promise { - if (this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise) { - return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + 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 } - this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted = true - this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise = flushCurrentStateAsync( - this, - true - ).catch(() => {}) - return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + runtime.quitFlushStarted = 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 (runtime.profileMaintenancePending) { + return + } + await drainProfileStateOperations([ + ...runtime.pendingProfileFlushes, + runtime.profileStateAuthority?.drainBackups?.(true) + ]) + await flushCurrentStateAsync(this, { final: true }) + if (options.exportJsonCompatibility) { + await runtime.profileStateAuthority?.writeJsonCompatibilityExportAsync?.(runtime.dataFile) + } + }) + .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(() => {}) + return flushCurrentStateAsync(this, { drainToStableGeneration: false }).catch(() => {}) } flushPendingOrThrowAsync( options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} ): Promise { - if ( - this[writeFlushBarrierOperationsContext].runtime.writesFrozen || - 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( - this, - false, - options.signal, - options.drainToStableGeneration, - true - ) + return flushCurrentStateAsync(this, { + signal: options.signal, + drainToStableGeneration: options.drainToStableGeneration, + requireInitialGenerationDurable: true + }) } } 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 + 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 + } } - owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null - const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration - await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) - if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { - break - } - } + }) } export async function flushCurrentStateAsync( owner: WriteFlushBarrierOperations, - final: boolean, - signal?: AbortSignal, - drainToStableGeneration = true, - requireInitialGenerationDurable = false + { + final = false, + signal, + drainToStableGeneration = true, + requireInitialGenerationDurable = false, + fullCheckpoint = final + }: { + final?: boolean + signal?: AbortSignal + drainToStableGeneration?: boolean + requireInitialGenerationDurable?: boolean + fullCheckpoint?: boolean + } ): 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) - } catch (error) { - await (final - ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() - : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( - signal - )) - await writeGithubCacheSnapshotAsync(owner, final, signal) - throw error - } - await (final - ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() - : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( + 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 (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.firstPendingSaveAt = null + const generation = runtime.writeGeneration + try { + await enqueueWrite(writes, { + fullCheckpoint, signal - )) - await writeGithubCacheSnapshotAsync(owner, final, signal) - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (!drainToStableGeneration) { - if ( - requiredDurableGeneration === null || - owner[writeFlushBarrierOperationsContext].runtime.lastDurableWriteGeneration >= - requiredDurableGeneration - ) { + }) + } finally { + await (final + ? runtime.activeViewPreference.flushAsync() + : runtime.activeViewPreference.flushPendingAsync(signal)) + await writeGithubCacheSnapshotAsync(owner, final, signal) + if (final || runtime.profileMaintenancePending) { + await runtime.profileStateAuthority?.drainBackups?.(true) + } + } + 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( @@ -177,39 +230,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 { @@ -229,34 +271,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 0301da34a7e..30019c5c456 100644 --- a/src/main/persistence/loading-store/write-scheduling.ts +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -9,11 +9,14 @@ type WriteSchedulingOperationsRuntime = Pick< StoreRuntimeState, | 'activeViewPreference' | 'automationListProjectionCache' + | 'dirtyProfileStateDomains' | 'firstPendingSaveAt' | 'pendingWrite' + | 'profileMaintenancePending' | 'quitFlushStarted' | 'writeGeneration' | 'writeTimer' + | 'writesFrozen' > const writeSchedulingOperationsContext = Symbol('WriteSchedulingOperations') @@ -30,36 +33,44 @@ 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()]) } } -export function scheduleSave(owner: WriteSchedulingOperations): void { - owner[writeSchedulingOperationsContext].runtime.automationListProjectionCache = null - // 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) { +export function scheduleSave( + owner: WriteSchedulingOperations, + dirtyDomains?: readonly string[] +): void { + const { runtime, writes } = owner[writeSchedulingOperationsContext] + runtime.automationListProjectionCache = null + const trackedDomains = runtime.dirtyProfileStateDomains + if (dirtyDomains === undefined) { + runtime.dirtyProfileStateDomains = null + } else if (trackedDomains !== null) { + for (const domain of dirtyDomains) { + trackedDomains.add(domain) + } + } + // A timer admitted after the final snapshot could outlive the awaited shutdown work. + if (runtime.quitFlushStarted || runtime.profileMaintenancePending) { return } - owner[writeSchedulingOperationsContext].runtime.writeGeneration += 1 - const now = Date.now() - owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt ??= now - if (owner[writeSchedulingOperationsContext].runtime.writeTimer) { - clearTimeout(owner[writeSchedulingOperationsContext].runtime.writeTimer) + runtime.writeGeneration += 1 + if (runtime.writesFrozen) { + return } - const untilMaxWait = Math.max( - 0, - owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt + SAVE_MAX_WAIT_MS - now - ) + const now = Date.now() + runtime.firstPendingSaveAt ??= now + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + } + 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) + runtime.writeTimer = setTimeout(() => { + runtime.writeTimer = null + runtime.firstPendingSaveAt = null + void enqueueWrite(writes, { skipIfClean: true }).catch(() => {}) }, delay) } 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 00000000000..1d1c937e8c6 --- /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 00000000000..9d3bdf7ea39 --- /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 00000000000..d31db671d28 --- /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-identity.test.ts b/src/main/persistence/profile-state/profile-state-access-identity.test.ts new file mode 100644 index 00000000000..f0e29776c4d --- /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 new file mode 100644 index 00000000000..2d5d3d1fcff --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-identity.ts @@ -0,0 +1,84 @@ +import { readFileSync } from 'node:fs' +import { runProcessSync } from '../../../shared/child-process/run-process' +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 +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') { + 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', + 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) { + 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 + } + } + 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 new file mode 100644 index 00000000000..9144d22224a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-owner.ts @@ -0,0 +1,314 @@ +import { randomUUID } from 'node:crypto' +import { + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + readdirSync, + realpathSync, + rmdirSync, + unlinkSync, + writeFileSync +} from 'node:fs' +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, + profileStateAccessProcessIdentity +} from './profile-state-access-identity' + +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 + bootIdentity?: string | null + machineIdentity?: string | null + processStartIdentity?: string | null +} + +export function profileStateAccessPidNamespace(): 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, + bootIdentity: + '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' && + /^(?:linux-start-ticks|darwin-utc-start-ms|wall-time-ms):\d+$/.test( + owner.processStartIdentity + ) && + Number.isSafeInteger(Number(owner.processStartIdentity.split(':')[1])) + ? owner.processStartIdentity + : null + } + } + } 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 { + const currentBoot = profileStateAccessBootIdentity() + 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 && !sameHost) || + owner.platform !== process.platform || + (!sameBoot && owner.machineIdentity && currentMachine && !sameMachine) + ) { + return false + } + if ( + sameHost && + 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' && + (owner.pidNamespace === null || owner.pidNamespace !== profileStateAccessPidNamespace()) + ) { + return false + } + try { + process.kill(owner.pid, 0) + } 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.startsWith('darwin-utc-start-ms:') + ? !startTimesWithinTolerance( + Number(actualStart.split(':')[1]), + Number(recordedStart.split(':')[1]), + START_TIME_TOLERANCE_MS + ) + : actualStart !== recordedStart) + ) +} + +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}. 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)) + } + // 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: profileStateAccessPidNamespace(), + bootIdentity: profileStateAccessBootIdentity(), + machineIdentity: profileStateAccessMachineIdentity(), + processStartIdentity: profileStateAccessProcessIdentity(process.pid) + }), + { + flag: 'wx', + mode: 0o600 + } + ) + fsyncFileSync(join(candidate, entry)) + bestEffortFsyncDirectorySync(candidate) + for (let attempt = 0; ; attempt += 1) { + try { + renameFileWithWindowsRetry(candidate, target) + published = true + break + } catch (error) { + if (!exclusive || attempt >= 2) { + throw error + } + 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)) + 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 00000000000..eef48007bf1 --- /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 00000000000..5def614309e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access.test.ts @@ -0,0 +1,527 @@ +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, reclaimExitedOwner } 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 { + 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.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) + 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(), + extra: { + bootIdentity?: string + machineIdentity?: string + startedAtMs?: number + processStartIdentity?: string + } = {} +): 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, + ...extra + }) + ) + return record +} + +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.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') + 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 + // 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 { + 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.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') + 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) + 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 00000000000..724549dc7de --- /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 00000000000..15c38fe3ac1 --- /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 00000000000..48b46b1505a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts @@ -0,0 +1,537 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + 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 { 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(['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) => { + 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.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) + 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 00000000000..35f722b307f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts @@ -0,0 +1,192 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { dirname } from 'node:path' +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 { + PROFILE_STATE_LEGACY_BACKUP_COUNT, + profileStateLegacyBackupPath +} from './profile-state-legacy-backup-path' +import { + assertProfileStateCanInitialize, + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-recovery-required' +export { + ProfileStateAuthorityBootstrapError, + 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: ProfileStateSqliteAuthority + 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 +} + +/** 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' || classification === 'neither') { + assertProfileStateCanInitialize(options) + } + 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 || + (error instanceof ProfileStateDatabaseOpenError && error.code === 'newer-schema') + ) { + 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' + ) + } + assertProfileStateCanInitialize({ dataFile, databaseFile, profileId }) + if (!publishProfileStateDatabase(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') + const { prepared, unboundPaneAliases } = prepareLegacyProfileState(options.dataFile, rawJson) + const migrated = migrateProfileStateToSqlite({ + ...options, + expectedLegacyJson: rawJson, + serializedState: prepared.json + }) + prepared.commit() + return { + classification: 'json-only', + ...migrated, + initialState: { ...migrated.initialState, unboundPaneAliases }, + 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-export-fencing.test.ts b/src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts new file mode 100644 index 00000000000..b53dc25fd69 --- /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 00000000000..39e4027415c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-exports.ts @@ -0,0 +1,89 @@ +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 { writeVersionedProfileStateExport } from './profile-state-versioned-export' +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 + } + 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) + 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 + } + 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) + 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-automation-runs-equality.test.ts b/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts new file mode 100644 index 00000000000..8ac387db9aa --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts @@ -0,0 +1,177 @@ +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.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, { + 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 00000000000..adf99619242 --- /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 00000000000..b02418cf8ea --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-model.ts @@ -0,0 +1,49 @@ +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 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; contentHash: ''; runs?: undefined } + | { presence: typeof AUTOMATION_RUNS_NULL; contentHash: string; runs?: undefined } + | { + presence: typeof AUTOMATION_RUNS_ARRAY + 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 new file mode 100644 index 00000000000..32da30b8784 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts @@ -0,0 +1,72 @@ +import { createHash } from 'node:crypto' +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 parseAutomationRunsValue(undefined) + } + let parsed: unknown + try { + parsed = JSON.parse(payload) + } catch { + return undefined + } + return parseAutomationRunsValue(parsed) +} + +export function parseAutomationRunsValue( + value: unknown +): ParsedAutomationRunsReplacement | undefined { + if (value === undefined) { + return { presence: AUTOMATION_RUNS_ABSENT, contentHash: '' } + } + if (value === null) { + return { presence: AUTOMATION_RUNS_NULL, contentHash: hashProfileStatePayload('null') } + } + if (!Array.isArray(value)) { + return undefined + } + return parseAutomationRunValues(value) +} + +export function parseAutomationRunValues( + values: readonly unknown[] +): 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 + } + const runPayload = JSON.stringify(value) + if (runPayload === undefined) { + return undefined + } + if (ordinal > 0) { + aggregate.update(',') + } + aggregate.update(runPayload, 'utf8') + ids.add(value.id) + runs.push({ + id: value.id, + ordinal, + payload: runPayload, + contentHash: hashProfileStatePayload(runPayload) + }) + } + return { + presence: AUTOMATION_RUNS_ARRAY, + contentHash: aggregate.update(']').digest('hex'), + runs + } +} 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 00000000000..c8e4087a1a8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts @@ -0,0 +1,151 @@ +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, + type ProfileStateValidatedDocument +} 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' + +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, + 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: number, + representation: 'validated' +): ProfileStateValidatedDocument | null | undefined +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision = readProfileStateRevision(db), + representation: Representation = 'serialized' +): ReadDocument | 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( + meta, + representation === 'validated' + ? {} + : representation === 'parsed' + ? { value: null } + : { payload: 'null' } + ) + } + + // 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 = createHash('sha256').update('[') + const ids = new Set() + 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 + ) + } + 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 (index > 0) { + aggregate.update(',') + } + aggregate.update(parsed.payload, 'utf8') + if (representation === 'parsed') { + values.push(parsed.value) + } else if (representation === 'serialized') { + payloads.push(parsed.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( + meta, + representation === 'validated' + ? {} + : representation === 'parsed' + ? { value: values } + : { payload: `[${payloads.join(',')}]` } + ) +} + +function makeAutomationRunsDocument( + meta: AutomationRunsMeta, + content: { payload: string } | { value: unknown } | Record +): ReadDocument { + return { + domain: AUTOMATION_RUNS_DOMAIN, + ...content, + 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 00000000000..389498aa364 --- /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 00000000000..c9ee590d369 --- /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 00000000000..98d0f79cc4b --- /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 00000000000..31f4eb4f1d1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts @@ -0,0 +1,158 @@ +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 AutomationRunIdentity, + type ParsedAutomationRunsReplacement +} from './profile-state-automation-runs-model' +import { + parseAutomationRunValues, + parseAutomationRunsReplacement, + parseAutomationRunsValue +} 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: PreparedProfileStateMutation, + actualRevision: number +): AutomationRunsWritePreparation | undefined { + const current = readCurrentAutomationRunsState(db, actualRevision) + const incoming = parseAutomationRunsValue(replacement.automationRunsValue) + if (incoming === undefined) { + return undefined + } + return { + changed: current.presence !== incoming.presence || current.contentHash !== incoming.contentHash, + 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 incoming = parseAutomationRunValues(after) + if (incoming === undefined) { + return undefined + } + const current = readCurrentAutomationRunsState(db, actualRevision) + return { + changed: current.presence !== incoming.presence || current.contentHash !== incoming.contentHash, + 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 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) { + const existing = existingRows.get(run.id) + existingRows.delete(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()) { + 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, + incoming.contentHash + ) + 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 00000000000..8b81cf07019 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs.ts @@ -0,0 +1,22 @@ +import type Database from '../../sqlite/sync-database' +import { markAutomationRunsDocumentStorage } from './profile-state-automation-runs-storage' +import { 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 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 00000000000..bb28c491f0e --- /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 00000000000..f10ef15368c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-job.ts @@ -0,0 +1,33 @@ +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { validateProfileStateSnapshot } from './profile-state-documents' + +export type ProfileStateBackupJob = { + databasePath: string + profileId: string + targetPath: string + temporaryPath?: 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, { + temporaryPath: job.temporaryPath, + validateStagedSnapshot: (stagingPath) => + validateProfileStateBackup(stagingPath, job.profileId) + }) + } finally { + opened.db.close() + } +} + +function validateProfileStateBackup(path: string, profileId: string): void { + const snapshot = openProfileStateDatabaseReadOnly(path, profileId) + try { + validateProfileStateSnapshot(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 00000000000..593d490ce11 --- /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 00000000000..03a65e37229 --- /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 00000000000..72df43fdbae --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts @@ -0,0 +1,275 @@ +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' +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' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual } +}) + +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() + 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 + 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 00000000000..a9b301f044e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.ts @@ -0,0 +1,130 @@ +import { lstat, rm } from 'node:fs/promises' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath, + 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 +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 readonly cancellation = new AbortController() + private nextAttemptAt = 0 + + constructor( + private readonly databasePath: string, + private readonly profileId: string, + private readonly now: () => number = Date.now, + private readonly runBackup = runProfileStateBackup + ) {} + + 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 && (this.cancellation.signal.aborted || 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 + this.cancellation.abort() + } + + assertIdle(): void { + if (this.pending) { + throw new Error('Flush pending profile state backups before quarantining the database') + } + } + + private async rotate(): Promise { + if (this.stopped) { + return + } + const now = this.now() + await removeAbandonedProfileStateBackupFiles(this.databasePath, 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) + ) + 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 }) + } + } + + 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-temporary-files.test.ts b/src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts new file mode 100644 index 00000000000..a37de695fa5 --- /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 00000000000..eb0424abbc2 --- /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 new file mode 100644 index 00000000000..9213e4e0042 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts @@ -0,0 +1,30 @@ +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' || + (request.temporaryPath !== undefined && typeof request.temporaryPath !== 'string') +) { + throw new Error('Invalid profile state backup request') +} + +void writeProfileStateBackup({ + databasePath: request.databasePath, + profileId: request.profileId, + targetPath: request.targetPath, + temporaryPath: request.temporaryPath +}) + .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 00000000000..310c1471420 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -0,0 +1,236 @@ +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.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) + 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 00000000000..287ca2ce34b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-worker.ts @@ -0,0 +1,87 @@ +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 { + 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, + signal?: AbortSignal +): Promise { + return process.versions.electron + ? runProfileStateBackupWorker(job, { signal }) + : writeProfileStateBackup(job) +} + +export async function runProfileStateBackupWorker( + job: ProfileStateBackupJob, + options: { workerPath?: string; timeoutMs?: number; signal?: AbortSignal } = {} +): Promise { + 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)) + }) + // 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-bootstrap-publication-race.test.ts b/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts new file mode 100644 index 00000000000..967822332fd --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts @@ -0,0 +1,178 @@ +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 { 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: () => {} })) +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'])( + '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) + 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) + 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 00000000000..177c79dd874 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-complete-domain-writes.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 { + 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(() => { + // @ts-expect-error Intentionally malformed input must fail runtime validation. + authority.writeCompleteSerializedDomains(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-complete-replacements.ts b/src/main/persistence/profile-state/profile-state-complete-replacements.ts new file mode 100644 index 00000000000..2f7ea20f537 --- /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-crash-recovery.test.ts b/src/main/persistence/profile-state/profile-state-crash-recovery.test.ts new file mode 100644 index 00000000000..e9d6a54e0e5 --- /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-current-json-command.test.ts b/src/main/persistence/profile-state/profile-state-current-json-command.test.ts new file mode 100644 index 00000000000..9b3a83d788c --- /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-errors.ts b/src/main/persistence/profile-state/profile-state-database-errors.ts new file mode 100644 index 00000000000..65b58470a5e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-errors.ts @@ -0,0 +1,17 @@ +export type ProfileStateDatabaseOpenErrorCode = + | 'unreadable' + | 'identity-mismatch' + | 'invalid-profile-id' + | 'newer-schema' + +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 00000000000..4f82cd61a46 --- /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-publication.ts b/src/main/persistence/profile-state/profile-state-database-publication.ts new file mode 100644 index 00000000000..eb265b43398 --- /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-quarantine.ts b/src/main/persistence/profile-state/profile-state-database-quarantine.ts new file mode 100644 index 00000000000..8e15f9fcf0d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-quarantine.ts @@ -0,0 +1,89 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { randomUUID } from 'node:crypto' +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 { copyProfileStateRecoveryFiles } from './profile-state-recovery-copy' + +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 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) + copies.push({ source: sourcePath, target: targetPath }) + targets.add(targetPath) + } + for (const sourcePath of new Set(recoveryFiles)) { + const targetPath = join(directory, basename(sourcePath)) + if ( + targets.has(targetPath) || + existsSync(targetPath) || + basename(sourcePath) === 'manifest.json' + ) { + throw new Error('Profile recovery artifact name conflicts with the quarantine manifest') + } + copies.push({ source: sourcePath, target: targetPath }) + targets.add(targetPath) + } + 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, + 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: [...targets] } + } 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 00000000000..5a0fb0c868f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-recovery.test.ts @@ -0,0 +1,296 @@ +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; json?: string } = {}) { + 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, options.json ?? 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('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) => { + 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 00000000000..106e771978f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-recovery.ts @@ -0,0 +1,113 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +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 +} 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 { + copyProfileStateRecoveryFile(options.backupPath, stagingPath) + 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 revision = validateProfileStateSnapshot(opened.db) + if (revision === 0) { + throw new Error('Profile state database backup contains no committed profile state') + } + return 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 00000000000..1d8762e5524 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts @@ -0,0 +1,159 @@ +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 originalAuthority = new ProfileStateSqliteAuthority(databasePath, profileId) + const original = new Store({ + dataFile, + 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' }) + 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 00000000000..4f6cd453825 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-schema.ts @@ -0,0 +1,41 @@ +// Schema 3 requires explicit automation storage metadata even when history is empty. + +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 + +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 + ); + 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/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 00000000000..da0ebcf7ac5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts @@ -0,0 +1,324 @@ +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('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) => { + 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 00000000000..9cc89d58b59 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.ts @@ -0,0 +1,123 @@ +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: { + temporaryPath?: string + 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 = 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. + 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 (created && !published) { + await rm(temporaryPath, { force: true }) + } + if (created) { + 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 00000000000..3bd74421965 --- /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 00000000000..c3cb63fb114 --- /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 00000000000..d264d9f27d8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database.ts @@ -0,0 +1,268 @@ +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, mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +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 } + +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 ProfileStateDatabaseOpenError( + 'newer-schema', + 'This profile requires a newer version of Orca' + ) + } + return opened +} + +/** + * 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( + 'newer-schema', + `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 00000000000..075e4f6dc06 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-document-reader.ts @@ -0,0 +1,99 @@ +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, + type ProfileStateValidatedDocument +} 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: '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 === '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` + ) + .iterate() + const documents: ( + | ProfileStateDocument + | ProfileStateParsedDocument + | ProfileStateValidatedDocument + )[] = [] + for (const row of rows) { + 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 === 'validated') { + continue + } + if (options.representation === 'parsed') { + const { payload: _payload, ...parsedDocument } = document + documents.push({ ...parsedDocument, value: document.value }) + } else { + documents.push(document) + } + } + if (options.representation === 'validated') { + return + } + 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 00000000000..52638353921 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-document-validation.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto' + +export type ProfileStateDocument = { + domain: string + payload: string + domainVersion: number + revision: number + updatedAt: number + contentHash: string +} + +export type ProfileStateValidatedDocument = Omit +export type ProfileStateParsedDocument = ProfileStateValidatedDocument & { 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 00000000000..1afdae3d60c --- /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 00000000000..f20b0d0ccdc --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-documents.ts @@ -0,0 +1,232 @@ +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(',')}}` + } + }) +} + +/** 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-equality.test.ts b/src/main/persistence/profile-state/profile-state-domain-equality.test.ts new file mode 100644 index 00000000000..df9dfe3efb9 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-equality.test.ts @@ -0,0 +1,144 @@ +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('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, { + 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 00000000000..c6c9d89c6db --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-reader.test.ts @@ -0,0 +1,152 @@ +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 { importProfileStateJson } from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import { + readProfileStateDomains, + readProfileStateDomainsWithRevisionFromDatabase +} 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) + }) +}) + +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 new file mode 100644 index 00000000000..2bd3a642e8e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-reader.ts @@ -0,0 +1,115 @@ +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 ProfileStateParsedDocument +} 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, '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, document.value) + } + if (normalized !== undefined && normalized !== null) { + values.set(normalized.domain, normalized.value) + } + return { kind: 'values', revision, values } + }) + } catch (error) { + return { kind: 'unreadable', error } + } +} + +function readSelectedDocuments( + db: Parameters[0], + domains: readonly string[] +): readonly ProfileStateParsedDocument[] { + 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` + ) + .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-domain-write-validation.ts b/src/main/persistence/profile-state/profile-state-domain-write-validation.ts new file mode 100644 index 00000000000..bac86a1610a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-write-validation.ts @@ -0,0 +1,78 @@ +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 + // Keep the checked history value only for this write so normalization does not parse it again. + automationRunsValue?: unknown + 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) + let parsed: unknown + if (replacement.payload !== null) { + try { + parsed = JSON.parse(replacement.payload) + } catch { + throw new Error(`Profile state domain payload is invalid JSON: ${replacement.domain}`) + } + } + return { + domain: replacement.domain, + payload: replacement.payload, + now: replacement.now, + domainVersion: replacement.domainVersion ?? PROFILE_STATE_DOCUMENT_VERSION, + payloadHash, + automationRunsValue: replacement.domain === 'automationRuns' ? parsed : undefined + } +} + +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 00000000000..8c54ee2360f --- /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 00000000000..792487ef3b1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-writes.ts @@ -0,0 +1,231 @@ +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 { 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 unknown[] +} + +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 00000000000..34332e86fa0 --- /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 00000000000..ffdfccc7904 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts @@ -0,0 +1,132 @@ +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, + validateProfileStateSnapshot +} 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/) + expect(() => validateProfileStateSnapshot(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', 'validated', '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 === 'validated') { + validateProfileStateSnapshot(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 00000000000..015a35b2899 --- /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 00000000000..1d1502009a8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts @@ -0,0 +1,239 @@ +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') { + 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( + `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 + }) + } + vi.restoreAllMocks() + 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').mockImplementation((...args) => { + write(...args) + if (args[1] === state.paths.dataFile) { + 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() + 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) + + 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-large-recovery-crash.test.ts b/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts new file mode 100644 index 00000000000..a1b8216c49f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts @@ -0,0 +1,149 @@ +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:before', + 'clone:2:after', + 'clone:3:after', + 'clone:4: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-legacy-backup-path.ts b/src/main/persistence/profile-state/profile-state-legacy-backup-path.ts new file mode 100644 index 00000000000..ba222b61fd7 --- /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 new file mode 100644 index 00000000000..56c2cd2b072 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts @@ -0,0 +1,216 @@ +import { build } from 'esbuild' +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' +import { createLiveProfileStateStore } from './profile-state-live-store-factory' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './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: () => [] +})) + +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.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( + 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(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) + 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 00000000000..82bf99bf063 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.ts @@ -0,0 +1,58 @@ +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 + onFailure?: (error: Error) => void + } = {} +): 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: { + ...initialAuthorityState, + unboundPaneAliases: initial.unboundPaneAliases + } + }) + } + } 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 new file mode 100644 index 00000000000..35b4a892364 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-migration.ts @@ -0,0 +1,86 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +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' +import { assertProfileStateCanInitialize } 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) + 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) + try { + 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 (!publishProfileStateDatabase(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 { + writeVersionedProfileStateExport(options.dataFile, (path) => authority.writeJsonExport(path)) + 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 { + assertProfileStateCanInitialize(options) + 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 00000000000..45f81ccdb9f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-offline-settings.test.ts @@ -0,0 +1,135 @@ +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.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(() => { + 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 00000000000..f6eecefd522 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-offline-settings.ts @@ -0,0 +1,198 @@ +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 { isRecord, parseProfileStateRoot } from './profile-state-document-validation' +import { + isProfileStateSqliteAvailable, + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { + readProfileStateDomains, + readProfileStateDomainsWithRevisionFromDatabase +} from './profile-state-domain-reader' +import { writeProfileStateDomain } from './profile-state-domain-writes' +import { assertProfileStateCanInitialize } 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') { + assertProfileStateCanInitialize(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) + } + + assertProfileStateCanInitialize(location) + const state = existsSync(location.dataFile) + ? parseProfileStateRoot(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 { + const settings = existsSync(dataFile) + ? parseProfileStateRoot(readFileSync(dataFile, 'utf8')).settings + : getDefaultPersistedState(homedir()).settings + return readAgentHookSettingsFromSettingsValue(settings) +} + +function readAgentHookSettingsFromSettingsValue(value: unknown): AgentHookSettings { + const settings = isRecord(value) ? value : {} + return { + agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false, + disabledTuiAgents: normalizeDisabledTuiAgents(settings.disabledTuiAgents) + } +} + +function writeJsonProfileState(dataFile: string, state: Record): void { + mkdirSync(dirname(dataFile), { recursive: true }) + writeFileDurableSync( + durableWriteTempPath(dataFile), + dataFile, + `${JSON.stringify(state, null, 2)}\n` + ) +} 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 00000000000..428622f8c6d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts @@ -0,0 +1,158 @@ +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, + validateProfileStateSnapshot +} 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) + expect(() => validateProfileStateSnapshot(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') + expect(() => validateProfileStateSnapshot(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 00000000000..0f6307e47d3 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts @@ -0,0 +1,174 @@ +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, + validateProfileStateSnapshot +} 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, validateProfileStateSnapshot])( + '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) + 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) + const next = read(reader) + expect(typeof next === 'number' ? next : next.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 00000000000..415c1721071 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-read-snapshot.ts @@ -0,0 +1,37 @@ +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 + if (ownsTransaction) { + db.exec('BEGIN') + } + try { + const result = read() + if (ownsTransaction) { + db.exec('COMMIT') + } + return result + } catch (error) { + if (ownsTransaction && db.isTransaction) { + try { + db.exec('ROLLBACK') + } catch (rollbackError) { + throw new ProfileStateReadRollbackError(error, rollbackError) + } + } + throw error + } +} 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 00000000000..82e4ccf708e --- /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-command.ts b/src/main/persistence/profile-state/profile-state-recovery-command.ts new file mode 100644 index 00000000000..0715f9f3561 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-command.ts @@ -0,0 +1,142 @@ +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 { isRecord } from './profile-state-document-validation' +import { profileHasPendingProjectMove } from '../../orca-profiles/profile-project-move-record' +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 (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) + } + 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.` + ) + } + const recovered = restoreProfileStateJsonExport({ + maintenance, + databasePath: result.databaseFile, + dataFile: result.dataFile, + exportPath, + profileId: result.profileId, + ...(revision === null ? { reason: 'profile-state-adopt-current-json' } : {}), + 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) +} 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 00000000000..d7bc8796be2 --- /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 00000000000..cb7a1f9315b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-copy.ts @@ -0,0 +1,81 @@ +import { constants, copyFileSync, linkSync, lstatSync, mkdtempSync, rmSync } from 'node:fs' +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 { + 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() + 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 + } + 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', + ...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) { + 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) + } + } + cloned = true + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } + if (!cloned) { + for (const { source, target } of clones) { + copyFileSync(source, target, constants.COPYFILE_EXCL) + } + } +} 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 00000000000..2554324968d --- /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 00000000000..d51ae465dae --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts @@ -0,0 +1,178 @@ +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) +} +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) + 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 00000000000..9c376528562 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-required.ts @@ -0,0 +1,81 @@ +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' + +type ProfileStateRecoveryLocation = { + dataFile: string + databaseFile: string + 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 + 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 00000000000..2b3a061ed5a --- /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-readmission.ts b/src/main/persistence/profile-state/profile-state-revision-readmission.ts new file mode 100644 index 00000000000..8a3c3df338d --- /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-revision.ts b/src/main/persistence/profile-state/profile-state-revision.ts new file mode 100644 index 00000000000..6c64714606f --- /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 00000000000..158357924d2 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-sqlite-authority.ts @@ -0,0 +1,339 @@ +import { existsSync } from 'node:fs' +import type { + ProfileStateAuthority, + ProfileStateAuthorityInitialState, + ProfileStateDomainReplacement, + ProfileStateMaintenance +} from '../loading-store/profile-state-authority' +import { + importProfileStateJson, + readAcceptedProfileStateParsedSnapshot, + readProfileStateParsedSnapshot, + readProfileStateRevision, + readProfileStateSnapshot +} from './profile-state-documents' +import { + openWritableProfileStateDatabase, + 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 { 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. + * + * 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 retired = false + private observedRevision: number | undefined + private writableDatabase: ReturnType | undefined + private backupRotation: ProfileStateBackupRotation | undefined + + constructor( + private readonly databasePath: string, + 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 { + this.assertActive() + 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 { + this.assertActive() + 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 { + 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. + 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[], + 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() + this.observedRevision = writeProfileStateDomains(opened.db, { + expectedRevision: this.observedRevision ?? 0, + replacements, + automationRunsAfter + }).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 unknown[] + ): void { + 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') + } + 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 { + this.assertActive() + 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 + } + this.observedRevision = writeProfileStateDomains(opened.db, { + expectedRevision: this.observedRevision ?? currentRevision, + replacements: complete + }).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() + } + + writeJsonExport(targetPath: string): number { + return writeProfileStateAuthorityJsonExport( + this.openWritableDatabase().db, + targetPath, + this.observedRevision + ) + } + + writeJsonCompatibilityExport(targetPath: string): number | undefined { + return writeProfileStateAuthorityCompatibilityExport( + this.openWritableDatabase().db, + targetPath, + this.observedRevision + ) + } + + 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 { + let pending: { revision: number; value: Record | undefined } | undefined = { + revision, + value + } + this.observedRevision = revision + return { + authority: this, + takeParsedState: () => { + this.assertActive() + 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 assertActive(): void { + if (this.retired) { + throw new Error('Profile state authority was retired for worker ownership') + } + } + + private openWritableDatabase(): NonNullable { + this.assertActive() + this.writableDatabase ??= openWritableProfileStateDatabase(this.databasePath, this.profileId) + return this.writableDatabase + } +} 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 00000000000..2eb64a50d4d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts @@ -0,0 +1,329 @@ +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, resolve } 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' + +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(), + 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', async () => { + 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 = await createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: 'legacy' + }) + expect(legacy.backend).toBe('json') + expect(existsSync(databaseFile)).toBe(false) + await legacy.store.freezeWritesAsync() + + const candidate = await createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode() + }) + expect(candidate.backend).toBe('sqlite') + expect(candidate.migrated).toBe(true) + candidate.store.updateSettings({ theme: 'dark' }) + await candidate.store.flushPendingOrThrowAsync() + await candidate.store.freezeWritesAsync() + rmSync(dataFile) + + const restarted = await createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode() + }) + expect(restarted.backend).toBe('sqlite') + expect(restarted.classification).toBe('sqlite-only') + expect(restarted.store.getSettings().theme).toBe('dark') + await restarted.store.freezeWritesAsync() + + const packaged = await createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode() + }) + expect(packaged.backend).toBe('sqlite') + expect(packaged.classification).toBe('sqlite-only') + expect(packaged.store.getSettings().theme).toBe('dark') + await packaged.store.freezeWritesAsync() + + await expect( + createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: 'legacy' + }) + ).rejects.toThrowError(expect.objectContaining({ code: 'profile-state-authority-required' })) + + const orcad = await createProfileStateStoreForStartup({ + ...base, + runtime: 'orcad', + authorityMode: 'sqlite-candidate', + storageAuthority: 'runtime' + }) + expect(orcad.backend).toBe('sqlite') + await orcad.store.freezeWritesAsync() + + await expect( + createProfileStateStoreForStartup({ + ...base, + runtime: 'orcad', + authorityMode: 'legacy', + storageAuthority: 'runtime' + }) + ).rejects.toThrowError(expect.objectContaining({ code: 'profile-state-authority-required' })) + }) + + 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') { + return undefined + } + return original(id) + }) + + await 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' + }) + ).rejects.toThrowError(ProfileStateStartupAuthorityError) + }) + + 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 = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'default-empty', + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode(), + storageAuthority: 'desktop' + } + const first = await createProfileStateStoreForStartup(options) + try { + expect(first.backend).toBe('sqlite') + expect(first.classification).toBe('neither') + first.store.updateSettings({ terminalFontSize: 19 }) + await first.store.flushPendingOrThrowAsync() + expect(existsSync(options.databaseFile)).toBe(true) + expect(existsSync(options.dataFile)).toBe(false) + } finally { + await first.store.freezeWritesAsync() + } + const reopened = await createProfileStateStoreForStartup(options) + try { + expect(reopened.backend).toBe('sqlite') + expect(reopened.migrated).toBe(false) + expect(reopened.store.getSettings().terminalFontSize).toBe(19) + } finally { + await reopened.store.freezeWritesAsync() + } + }) + + it.each(['corrupt', 'future-schema', 'ambiguous'] as const)( + 'refuses %s storage under the desktop default without replacing the authority', + async (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) + 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"}}') + } + } + ) + + 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 = await 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') + await result.store.freezeWritesAsync() + }) + + 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') + await 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' + }) + ).rejects.toThrowError(ProfileStateStartupAuthorityError) + }) + + 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 = await 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') + 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 new file mode 100644 index 00000000000..ef8d597d8de --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-authority.ts @@ -0,0 +1,68 @@ +import type { AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' +import { isProfileStateSqliteAvailable } from './profile-state-database' +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' + +export type ProfileStateStartupAuthorityOptions = Omit< + ProfileStateStoreFactoryOptions, + 'authorityMode' | 'storageAuthority' +> & { + runtime: ProfileStateStartupRuntime + authorityMode: ProfileStateStoreAuthorityMode + storageAuthority: AutomationStorageAuthority + onPersistenceFailure?: (error: Error) => void +} + +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 async function createProfileStateStoreForStartup( + options: ProfileStateStartupAuthorityOptions +): Promise { + if ( + options.runtime === 'orcad' && + options.authorityMode === 'sqlite-candidate' && + !isProfileStateSqliteAvailable() + ) { + throw new ProfileStateStartupAuthorityError() + } + 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 new file mode 100644 index 00000000000..e82ee6ba56f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-authority-bootstrap' +import { + formatProfileStateStartupFailure, + profileStateStartupFailureClass +} 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( + { + 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('explains ambiguity and offers explicit inspection before choosing a recovery point', () => { + const message = formatProfileStateStartupFailure( + new ProfileStateAuthorityBootstrapError('both profile stores are present') + ) + + 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 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( + 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() + }) + + 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 new file mode 100644 index 00000000000..6b67b42c145 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-failure.ts @@ -0,0 +1,127 @@ +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' + | 'revision-conflict' + | 'writer-unavailable' + | 'newer-schema' + | 'publication-unavailable' + +/** 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' + } + 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' + } + if (error.code.startsWith('profile-state-writer-')) { + return 'writer-unavailable' + } + } + 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}`, + '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') + } + + 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.' + } + if (failureClass === 'writer-unavailable') { + return 'Orca could not start profile persistence. Restart Orca; if the problem continues, repair or reinstall this build.' + } + + 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 00000000000..44fb9ccf4bf --- /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 00000000000..d89fbbb7729 --- /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 00000000000..e9fbd395bd0 --- /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 00000000000..af5043a7d23 --- /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 00000000000..987f2a7bbe7 --- /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 00000000000..20af26f1c1c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-store-factory.test.ts @@ -0,0 +1,460 @@ +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(expect.objectContaining({ code: 'newer-schema' })) + 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.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 } }) + 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 00000000000..1b833acb5b8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-store-factory.ts @@ -0,0 +1,121 @@ +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 { + 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 { initialState, ...prepared } = prepareProfileStateStore(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 + } +} + +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') { + 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 { + backend: 'json', + classification, + migrated: false + } + } + + if ( + authorityMode === 'sqlite-established' && + (classification === 'neither' || classification === 'json-only') + ) { + return { + backend: 'json', + classification, + migrated: false + } + } + + const bootstrap = bootstrapProfileStateAuthority({ + ...options, + allowEmptyProfileState: authorityMode === 'sqlite-candidate' + }) + const authority = bootstrap.authority + if (authority === undefined) { + return { + backend: 'json', + classification: bootstrap.classification, + migrated: bootstrap.migrated + } + } + + return { + initialState: bootstrap.initialState, + backend: 'sqlite', + classification: bootstrap.classification, + migrated: bootstrap.migrated + } +} 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 00000000000..537023ac45a --- /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) + }) +}) 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 00000000000..c74781ed96f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-versioned-export.test.ts @@ -0,0 +1,113 @@ +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('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() + 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 new file mode 100644 index 00000000000..70cbee1d72e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-versioned-export.ts @@ -0,0 +1,52 @@ +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, + profileStateJsonExportPaths +} 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 = durableWriteTempPath(`${dataFile}.sqlite-export.pending`) + try { + const revision = writeExport(stagingPath) + if (revision === 0) { + return undefined + } + const targetPath = profileStateJsonExportPath(dataFile, revision) + mkdirSync(dirname(targetPath), { recursive: true }) + if (!publishFileDurableSync(stagingPath, 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` + ) + } + 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 new file mode 100644 index 00000000000..cc42f350581 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-worker-authority.test.ts @@ -0,0 +1,76 @@ +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('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() + 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).toHaveBeenCalledOnce() + 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 new file mode 100644 index 00000000000..efd93652c17 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-worker-authority.ts @@ -0,0 +1,168 @@ +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 + onFailure?: (error: Error) => void + } = {} + ) { + 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(cancel = false): Promise { + if (cancel) { + this.backups.stop() + } + return this.backups.drain() + } + + close(): Promise { + this.writer.stopAdmission() + 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, reportInitializationFailure: true } + ) + 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 { + 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 + } + } + } + + private createBackups(): ProfileStateBackupRotation { + return new ProfileStateBackupRotation( + this.initialization.databasePath, + this.initialization.profileId, + Date.now, + (job, signal) => + runProfileStateBackupWorker(job, { workerPath: this.options.backupWorkerPath, signal }) + ) + } +} 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 00000000000..56e21da43e5 --- /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-write-transaction.test.ts b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts new file mode 100644 index 00000000000..2f4dade139c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import Database from '../../sqlite/sync-database' +import { + ProfileStateIndeterminateWriteError, + withProfileStateWriteTransaction +} 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 { + 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() + } + }) + 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 new file mode 100644 index 00000000000..1dbf10d5bed --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-write-transaction.ts @@ -0,0 +1,42 @@ +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) { + 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) { + 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 00000000000..bd956f48592 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-connection.ts @@ -0,0 +1,302 @@ +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 exit = Promise.withResolvers() + private didExit = false + private closeAcknowledged = false + private readonly timeoutMs: number + private readonly initialRevision: number + private latestRevision: number | undefined + + constructor( + initialization: ProfileStateWriterInitialization, + private readonly options: { + workerPath?: string + timeoutMs?: number + onFailure?: (error: Error) => void + reportInitializationFailure?: boolean + } = {} + ) { + this.initialRevision = initialization.revision + this.timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS + 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.exit.resolve() + 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.exit.resolve() + 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.exit.promise + } + + stopAdmission(): void { + this.draining = true + } + + close(): Promise { + this.stopAdmission() + 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.exit.promise + } finally { + clearTimeout(timer) + } + } + if (this.failure) { + throw this.failure + } + } else { + await this.exit.promise + } + } + + 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(): void { + const error = new ProfileStateWriterError( + 'profile-state-writer-protocol', + 'Invalid profile state writer response', + this.dispatchedOutcome() + ) + this.fault(error) + } + + private fault(error: Error): void { + 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 new file mode 100644 index 00000000000..28237862ce2 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-errors.ts @@ -0,0 +1,94 @@ +import { + ProfileStateDocumentCorruptionError, + ProfileStateRevisionConflictError +} 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 +} 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 || + error instanceof ProfileStateReadRollbackError + ) { + 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 || + error instanceof ProfileStateReadRollbackError + ) { + 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 00000000000..80a92f00118 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts @@ -0,0 +1,148 @@ +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 { 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 }', + onFailure?: (error: Error) => void, + startup = '' +) { + 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} }) + ${startup} + ` + ) + const client = new ProfileStateWriteWorkerClient( + { databasePath: join(root, 'unused.db'), profileId: 'protocol-test', revision: 1 }, + { workerPath, timeoutMs: 1000, onFailure } + ) + 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 notify = vi.fn() + const client = clientFor('', undefined, notify) + 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' + }) + 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-protocol.ts b/src/main/persistence/profile-state/profile-state-writer-protocol.ts new file mode 100644 index 00000000000..1a7dc665abf --- /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 00000000000..8db4c4f6580 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-request.ts @@ -0,0 +1,59 @@ +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 { + return { + id, + command, + ...Promise.withResolvers(), + 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 00000000000..215af1f3499 --- /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 00000000000..a63eb83e71d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts @@ -0,0 +1,148 @@ +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) { + // 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 { + 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 00000000000..d1068eb26b0 --- /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 00000000000..6fa1efe9a6e --- /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/pane-identity-migration.ts b/src/main/persistence/restoring-sessions/pane-identity-migration.ts index 98d97a649a3..6cd2bbf129f 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 cfc26c7bb8a..38ec014c7c9 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/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 00000000000..bdca21f7bbe --- /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/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index c25315e4986..1192b7aaf39 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 0dc9e61731a..7415fd12cf1 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-concurrency.test.ts b/src/main/protected-secret-persistence-concurrency.test.ts new file mode 100644 index 00000000000..1b429cdaa8b --- /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.test.ts b/src/main/protected-secret-persistence.test.ts index 28a9ca6cf43..cbd1561f344 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 90bb2ee334d..c72af096619 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 @@ -34,10 +35,18 @@ type ProtectedSecretEncryption = { 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) } isSealed(slot: string, value: string): boolean { @@ -46,6 +55,12 @@ 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) } else { @@ -56,11 +71,21 @@ 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 } + return { + blob: '', + degraded: false, + ...(this.pendingEncryption.has(slot) + ? { retentionUpdate: this.prepareRetentionUpdate(slot, null) } + : {}) + } } if (!this.encryptionAvailable()) { + if (!this.isSealed(slot, plaintext) && (plaintext || !this.sealedSlots.has(slot))) { + this.pendingEncryption.add(slot) + } return { blob: retained, degraded: true, @@ -74,7 +99,7 @@ export class ProtectedSecretPersistence { return { blob: '', degraded: false, - retentionUpdate: { slot, blob: null } + retentionUpdate: this.prepareRetentionUpdate(slot, null) } } try { @@ -82,9 +107,10 @@ export class ProtectedSecretPersistence { return { blob, degraded: false, - retentionUpdate: { slot, blob } + retentionUpdate: this.prepareRetentionUpdate(slot, blob) } } catch (err) { + this.pendingEncryption.add(slot) console.error('[persistence] Encryption failed; retaining the prior protected value:', err) return { blob: retained, degraded: true } } @@ -99,6 +125,7 @@ export class ProtectedSecretPersistence { ciphertext: string, isLegacyPlaintext?: LegacyPlaintextValidator ): ProtectedSecretDecryption { + this.retentionEpochs.delete(slot) if (!ciphertext) { this.removeRetainedBlob(slot) return { plaintext: '', status: 'decrypted' } @@ -129,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 94528481622..4e2ebdf6026 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 2173d080dcb..51cd8b4cd8b 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 91c3cb1b1d6..179dba29bd1 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 () => { @@ -78,7 +79,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 +104,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, @@ -133,13 +134,27 @@ 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 () => { 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/automation-change-publication.test.ts b/src/main/runtime/automation-change-publication.test.ts index 5cde275d5ea..f9fc8508cab 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/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 16a0844f621..186f4e0f30d 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 db14f74bad8..43eb15f66b3 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 e91f18e8c6d..a824191bb90 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 b33579b783c..1a0344f393b 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 66a34f16ea6..642edd6cf17 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/mobile-session-terminal-retirement.ts b/src/main/runtime/mobile-session-terminal-retirement.ts index e8caba4ddf3..0f027e8c68d 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-automation-operations.ts b/src/main/runtime/orca-runtime-automation-operations.ts index fe65979ed1e..662ea04c3da 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 9c2ea907c91..26cbdec3d13 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/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 5aa6e46cc58..dda3caa7cff 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,78 @@ 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) { - throw new Error('tab_not_found') + const acknowledgeRetirement = this.captureTerminalTabRetirement(worktreeId, parentTabId) + const committed = await this.store.runDurableMutation(() => { + if (!acknowledgeRetirement().matches) { + return { value: new Error('terminal_pane_owner_changed'), persist: false } } - } - 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 hostId = this.getWorkspaceSessionHostIdForWorktree(worktreeId) + const currentSession = this.store.getWorkspaceSession(hostId) + if (!currentSession) { + 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) { + return { value: new Error('terminal_tab_pinned'), persist: false } + } + if (!result.closed && !options.allowMissing) { + return { value: new Error('tab_not_found'), persist: false } + } + 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 + }) + if (committed instanceof Error) { + throw committed } - return result.ptyIdsToKill + return committed } protected persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { 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 159a709fd3c..d7a169d00e1 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/orca-runtime-close-headless-mobile-terminal-tab.ts b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts index 1f5498043c6..a981a09dc2d 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 2ef5b77069d..fa624d6f3ce 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-fit-override-listeners.ts b/src/main/runtime/orca-runtime-fit-override-listeners.ts index 5ef17ce4c2e..6d4242d7758 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-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index 4d2219e3e56..39b8dbf0dcf 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/runtime/orca-runtime-invalidate-all-handles-for-pty.ts b/src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts index 9d6e5d56ca4..a348309a568 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-mobile-close-preserved-resurrection.test.ts b/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts index 5780b353944..4260c8213ef 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 588a834d6e6..99fd88373fa 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 @@ -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) @@ -216,13 +216,24 @@ 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) + 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 }[] = [] @@ -253,6 +264,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 6b9887d2cf8..bafc280d957 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]) - } + ): 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] } } - 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 }) - } - 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 { + // 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() @@ -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.pendingPtySurfaceRetirementsByPtyId.get(ptyId) !== pendingRetirement || + (currentIncarnation && currentIncarnation !== incarnationId) + ) { return } for (const surface of persisted.unpersisted) { diff --git a/src/main/runtime/orca-runtime-register-pty.ts b/src/main/runtime/orca-runtime-register-pty.ts index 37a5434e5c5..31c1bc38627 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,12 @@ 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.handleByPtyId.get(ptyId) !== replacementHandle + ) { + this.registerPreAllocatedHandleForPty(ptyId, replacementHandle) + } this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, binding.tabId) } } 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 768448e2ac9..747384d2103 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,60 +110,65 @@ 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 + const refusal = 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) { + return { value: new Error('terminal_close_incomplete'), persist: false } } - 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) + } + } + } + }) + if (refusal) { + throw refusal } } 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 3079ce1f4da..063866c83c5 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 e1a2c68242c..4422dc2a1e6 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 c72bde039b9..78a6089e3ed 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 1f87d901c81..63dc6d8c94e 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 6f5410e82a8..8b6806aec2b 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 eb2af5c0a1b..d2861c21400 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 518d6805c5b..4ac0faf4d58 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 81d78d4e308..88c988929f7 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 591f4e7ca97..2aba44299f2 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 e85aeb461c6..65757797f70 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 ecf2ab53678..1ebf15cbc24 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 00a08310877..51edcad7373 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 ea70b730388..6c439089396 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 389c70d4f42..756f87611f6 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 1907b2bb450..2100c708043 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 79bdf13909e..8980dc53f85 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/orchestration/coordinator-task-dispatch.ts b/src/main/runtime/orchestration/coordinator-task-dispatch.ts index bb64f6dd98b..a305afedf40 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 00000000000..49b34814144 --- /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 252c9f89ea9..915dc9e16a4 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/paired-close-retirement-proof-publication-order.test.ts b/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts index 640c54a753c..35fb16d55ea 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 22dc70e42c0..cf5618ab2ff 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-automation-controller.ts b/src/main/runtime/runtime-automation-controller.ts index d3ac55df438..20d3725c34e 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 } } 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 00000000000..a5f2dc82793 --- /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 df794567737..345e2c08377 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 6c1436395f1..86af6d3e15f 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 59fbf76b857..24df1cc1cbf 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-list-pending-pty-registration.test.ts b/src/main/runtime/terminal-list-pending-pty-registration.test.ts new file mode 100644 index 00000000000..62a5daf78e3 --- /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/terminal-retirement-async-durability.test.ts b/src/main/runtime/terminal-retirement-async-durability.test.ts new file mode 100644 index 00000000000..038832782d7 --- /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/tui-idle-name-only-real-pty.integration.test.ts b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts index 0d265a0a5bb..562ab23ee3e 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) 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 7234446cb9d..00000000000 --- 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/runtime/worktree-terminal-mutation-lock.test.ts b/src/main/runtime/worktree-terminal-mutation-lock.test.ts index 822238034a8..b972bd02dec 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 b0af98b2d3b..f76d8449826 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 { diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 0bfa79e43f5..08f69b9cf99 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-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index d7179703360..889f792abd4 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/orcad-remote-deploy-stop.ts b/src/main/ssh/orcad-remote-deploy-stop.ts new file mode 100644 index 00000000000..8a00a1dfa03 --- /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 1d9f87b009d..8fc387184f5 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 88a4d923c84..be9837ff31e 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, @@ -88,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 }) } @@ -156,6 +143,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, @@ -176,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 { @@ -191,16 +179,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 +202,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 +273,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 dfcb0f4b6e4..7ef8548ecdf 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 068ae588f65..0be9ddc76c1 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 a76010ccbce..3abd43ed1d9 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 6a9038ea3d6..c7f78331bd5 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 03df9479e4a..985bc05a7b3 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 0c50dc55fdb..f660e95e5a4 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 78ad8b47d2a..7d062275442 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/ssh/ssh-orphan-relay-pty-sweep.test.ts b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts index afb365c075c..7a503f51867 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 db49a45c399..727f293b572 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 e5683c0949a..da528f7f95f 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 6760a27821c..3c405cd5dff 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-recovery-races.test.ts b/src/main/ssh/ssh-relay-session-recovery-races.test.ts index 26dd798ac5a..f4600bb23e6 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-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index ba0782b3a20..c186e922e93 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 19e102b08ed..b739fbd3d8c 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 @@ -2575,11 +2577,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 +2748,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/browser-process-user-agent-ordering.test.ts b/src/main/startup/browser-process-user-agent-ordering.test.ts index 5913890b5cf..ee14f1a8d3e 100644 --- a/src/main/startup/browser-process-user-agent-ordering.test.ts +++ b/src/main/startup/browser-process-user-agent-ordering.test.ts @@ -26,19 +26,36 @@ 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}`) }) } - return { app, events, userAgent: () => userAgent } + return { + app, + events, + userAgent: () => userAgent, + showErrorBox: vi.fn(), + backgroundLaunch: vi.fn(() => true), + admission: vi.fn(), + lock: vi.fn(() => true), + afterIdentity: vi.fn((): void => { + throw new Error('preflight-test-stop') + }), + recoverMoves: vi.fn() + } }) 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 }) @@ -83,10 +100,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 +114,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 +126,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') @@ -127,9 +149,24 @@ 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') +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('../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') @@ -163,23 +200,65 @@ 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') + }) + 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() + platform.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 + 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') 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 +270,193 @@ 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', + 'set-name:Orca Development', + 'single-instance-lock', + 'admission:/canonical-user-data' + ]) + expect(focusExistingWindow).not.toHaveBeenCalled() + expect(requestDesktopActivation).not.toHaveBeenCalled() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + } finally { + error.mockRestore() + } + }) +}) + +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', 'active-profile') + 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/cli-command-names.ts b/src/main/startup/cli-command-names.ts index 0c001b74f44..22d90947ced 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 00000000000..ed9a6176af5 --- /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 7d7e2b0cc1a..fd4379354c4 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 13dbd70c2c3..95e1df91337 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 5e4d4cfe428..40d20d73b03 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -104,6 +104,30 @@ 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('formatProfileStateStartupFailure(error) ??') + expect(entrySource).toContain('presentProfileStateStartupRecoveryDialog') + expect(entrySource).toContain('!state.isServeMode && !isBackgroundLaunch()') + 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 3b661dede99..c761ccf0409 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 9e85a83be66..88de78ab808 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 00000000000..27fe1c30a6a --- /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 00000000000..f41569a73c8 --- /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 b0ee60f88c2..a4000449250 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-failure.ts b/src/main/startup/main-process-preflight-failure.ts new file mode 100644 index 00000000000..0fcd47935cb --- /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 98d41093c5f..2aa02ece192 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,9 @@ 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' +import { getActiveProfileStateLocation } from '../persistence/profile-state/profile-state-active-location' +import { handleMainProcessPreflightFailure } from './main-process-preflight-failure' export type MainProcessPreflightOptions = { focusExistingWindow: () => void @@ -97,6 +103,19 @@ 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) + handleMainProcessPreflightFailure(error) + return false + } +} + +function initializeMainProcessPreflight(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. @@ -186,10 +205,6 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b 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', { @@ -229,6 +244,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 @@ -282,6 +302,13 @@ 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. + 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() diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 073791f6d3c..ad4548db7bd 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -195,12 +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() ?? 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-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 3c0e01fe09e..b62bdec19f7 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' @@ -49,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') @@ -134,10 +139,24 @@ 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 = await createProfileStateStoreForStartup({ dataFile: profile.dataFile, - storageAuthority: state.isServeMode ? 'runtime' : 'desktop' + databaseFile: profile.stateDatabaseFile, + profileId: profile.profile.id, + runtime: 'desktop', + authorityMode: profileStateAuthorityMode, + storageAuthority: state.isServeMode ? 'runtime' : 'desktop', + onPersistenceFailure: reportProfileStateWriteFailure }) + 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 +216,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 +225,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 13011ccfb6b..eb6c29c6555 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-ready-persistence-cleanup.test.ts b/src/main/startup/main-process-ready-persistence-cleanup.test.ts new file mode 100644 index 00000000000..988c78f1c12 --- /dev/null +++ b/src/main/startup/main-process-ready-persistence-cleanup.test.ts @@ -0,0 +1,94 @@ +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 () => {}), + runtime: vi.fn(async () => {}), + i18n: vi.fn(async () => {}), + 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 })) +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() + 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( + () => + new Promise((resolve) => { + release = resolve + }) + ) + 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() + }) + + 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() + 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 835c1f1dd13..a6dcf83a430 100644 --- a/src/main/startup/main-process-ready.ts +++ b/src/main/startup/main-process-ready.ts @@ -11,12 +11,33 @@ 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() + state.profileStateAdmission?.release() + state.profileStateAdmission = undefined + } catch (closeError) { + console.error( + '[persistence] Failed to close profile persistence after startup failure:', + closeError + ) + } + throw error + } } diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 49f86136e89..81919505fd6 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -45,6 +45,25 @@ 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' +import type { ProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' + +export type ProfileStateStartupMetadata = { + backend: 'json' | 'sqlite' + classification: ProfileStateStorageClassification + authorityMode: ProfileStateStoreAuthorityMode + runtime: 'desktop' | 'orcad' + migrated: boolean +} + +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 = { @@ -52,6 +71,8 @@ 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(), + 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 new file mode 100644 index 00000000000..50d0bd49db5 --- /dev/null +++ b/src/main/startup/main-window-core-services.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + attachMainWindowServicesMock, + initTccPromptNoticeMock, + preserveAgentAuthBeforeRestartMock, + registerCoreHandlersMock, + state, + store +} = vi.hoisted(() => { + const store = { + writeLatestProfileStateJsonCompatibilityExportAsync: vi.fn(async () => {}), + writeLatestProfileStateJsonExportAsync: vi.fn(async () => {}), + 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 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. + 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.writeLatestProfileStateJsonExportAsync).not.toHaveBeenCalled() + expect(store.writeLatestProfileStateJsonCompatibilityExportAsync).toHaveBeenCalledOnce() + expect(options).toHaveProperty('onBeforeUpdateQuitFailure', 'abort') + }) +}) diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 8f9ef2118df..c292a4455c7 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -126,8 +126,11 @@ export function attachMainWindowCoreServices( isRecoveryReloadInFlight, onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit, - onBeforeUpdateQuit: () => - preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), + onBeforeUpdateQuit: async () => { + await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) + await store.writeLatestProfileStateJsonCompatibilityExportAsync() + }, + onBeforeUpdateQuitFailure: 'abort', updateInstallMode: resolveUpdateInstallMode(state.isServeMode), onWorktreeLifecycle: emitPluginWorktreeLifecycle } 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 2a8e008c0b3..292645f874f 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/profile-state-recovery-preflight.test.ts b/src/main/startup/profile-state-recovery-preflight.test.ts new file mode 100644 index 00000000000..4d120a4170e --- /dev/null +++ b/src/main/startup/profile-state-recovery-preflight.test.ts @@ -0,0 +1,280 @@ +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.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() + 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 00000000000..68ccbe68a13 --- /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/startup/profile-state-write-failure.test.ts b/src/main/startup/profile-state-write-failure.test.ts new file mode 100644 index 00000000000..b434b3ca573 --- /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 00000000000..81668513ba6 --- /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/main/startup/startup-diagnostics.test.ts b/src/main/startup/startup-diagnostics.test.ts index dcf7a4237b3..4ba75269d6c 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 d5cd7718d88..dff09612711 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/src/main/updater.quit-and-install.test.ts b/src/main/updater.quit-and-install.test.ts index 0080db58991..d5ba376e02e 100644 --- a/src/main/updater.quit-and-install.test.ts +++ b/src/main/updater.quit-and-install.test.ts @@ -183,6 +183,98 @@ 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('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() + + 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(90_000) + + 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 cc9f0fc2c98..95c457b82c3 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 175362dad05..2da273e152e 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?.()) @@ -146,29 +150,42 @@ 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) + timeout = setTimeout(() => resolve('timeout'), timeoutMs) }) - 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 }, + { + level: 'warn', + message: + this.onBeforeQuitFailure === 'abort' + ? `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 ${timeoutMs}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 8ebbc49b169..2b43964d5c0 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 d15ce42520c..32177e3183c 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 @@ -24,6 +26,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 +45,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 ae83f5b7712..9e5c5493f09 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 78496abc1fc..5a7aab6b145 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 b3f0960eab9..3c01d0f2ea2 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 6f82b96fc37..07a115b0ad8 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 f0298ed115d..7cdcdd8cd73 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 9c9de40eb80..265c3048b3a 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/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts index da58b2d9def..201b6be9799 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 b68f55989f5..2c44b9a5b7f 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 bd896109b57..ba97195a5f5 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -1,38 +1,140 @@ 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_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' import { + prepareAndInvokeAppRestart, prepareAndInvokeUpdaterInstall, 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 () => {}) + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, started) + await prepareAndInvokeAppRestart( + eventTarget, + async () => { + ipcRenderer.emit('app:restart-committed') + 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 } = restartIpc(eventTarget) + const checkpoint = vi.fn(async () => {}) + await prepareAndInvokeAppRestart(eventTarget, async () => true, checkpoint, Boolean) + ipcRenderer.emit('app:restart-committed') + await prepareAndInvokeAppRestart(eventTarget, async () => false, checkpoint, Boolean) + expect(checkpoint).toHaveBeenCalledOnce() + ipcRenderer.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 () => {} + ) + 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 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) - - 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')?.({}) + ipcRenderer.emit('updater:quitAndInstallAborted') + ipcRenderer.emit('window:unload-prevented') - expect(ipcRenderer.on).toHaveBeenCalledTimes(3) + expect(ipcRenderer.eventNames()).toHaveLength(4) expect(handleStatus).toHaveBeenCalledWith({ state: 'error', message: 'install failed' }) expect(abort).toHaveBeenCalledTimes(1) expect(unloadPrevented).toHaveBeenCalledTimes(1) diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index 2dd0ad316aa..38ea1882b9f 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -12,6 +12,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, @@ -24,7 +36,14 @@ export function registerRendererRestartIpcRelays( ipcRenderer.on('updater:quitAndInstallAborted', () => { relay.abort() }) + ipcRenderer.on('app:restart-committed', () => { + appRestartState(eventTarget).committed = true + }) 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)) }) @@ -50,20 +69,39 @@ export async function prepareAndInvokeUpdaterInstall( } } -export async function prepareAndInvokeAppRestart( +export async function prepareAndInvokeAppRestart( eventTarget: EventTarget, - invoke: () => Promise, - awaitCheckpoint: () => Promise -): Promise { - await prepareRendererForAppRestart(eventTarget, { - startedEventName: ORCA_APP_RESTART_STARTED_EVENT, - abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, - awaitCheckpoint - }) + invoke: () => Promise, + awaitCheckpoint: () => Promise, + willRestart: (result: T) => boolean = () => true +): Promise { + const state = appRestartState(eventTarget) + if (state.pending) { + throw new Error('App restart preparation is already in progress') + } + state.pending = true try { - await invoke() - } catch (error) { - eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) - throw error + if (!state.committed) { + await prepareRendererForAppRestart(eventTarget, { + startedEventName: ORCA_APP_RESTART_STARTED_EVENT, + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, + awaitCheckpoint + }) + } + 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 + } + } finally { + state.pending = false } } 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 7936300fb06..2c521261b73 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/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 00000000000..85023d75f4f --- /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 11ceff832fc..2efacc7765d 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/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index 49d13f87f01..84b498f18f2 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 3675b7575da..81ac509ffb7 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 033ff1da034..66db1463496 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 d42c8e5ad4f..34aa91438f8 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 } }) 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 ab85698660b..cde7022eefd 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() }) diff --git a/src/shared/cli-argument-boundary.ts b/src/shared/cli-argument-boundary.ts index f4252c03e4a..a3ad092c3c8 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/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 9a2598caefb..aa737ea4aed 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/profile-state-recovery-command.test.ts b/src/shared/profile-state-recovery-command.test.ts new file mode 100644 index 00000000000..8036ecac085 --- /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 new file mode 100644 index 00000000000..726e034ef93 --- /dev/null +++ b/src/shared/profile-state-recovery-command.ts @@ -0,0 +1,72 @@ +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('current-json') }).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({ + // 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']), + 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 00000000000..0dea3dbb0a5 --- /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 00000000000..63c9dd88ab0 --- /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 26762e5a52e..9632e75bd05 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 810ba8b35d1..cba4ad9a208 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 c2cf9036dc0..d1bec760ecd 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 00000000000..a12396accca --- /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 00000000000..2bda84ec25e --- /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 18e98409094..b6609c29df7 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 17c68f01ca7..b4a8573904f 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 5d135161f08..6022edbf201 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 1d4b4a0f82c..673f548d18c 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 d3ef3fb8195..5ac0d8c1948 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 6868f48b083..e749945eddb 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 c538d6f9a85..301548a9f4e 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-main-evaluate-retry.ts b/tests/e2e/helpers/electron-main-evaluate-retry.ts index 502e93152a0..c7794c40728 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 7909a4d6eab..c6735e2fd1f 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) }) }) diff --git a/tests/e2e/helpers/electron-process-shutdown.ts b/tests/e2e/helpers/electron-process-shutdown.ts index f9b642a676e..5d78b761b96 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 2560fde154d..8be4adb79f8 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. */ @@ -190,23 +192,45 @@ 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 => { 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') { @@ -218,7 +242,7 @@ export function createRestartSession( } } - return { userDataDir, seedCodexResumeRollout, launch, close, dispose } + return { userDataDir, seedCodexResumeRollout, launch, launchUntilExit, close, dispose } } /** diff --git a/tests/e2e/helpers/persisted-profile-state.ts b/tests/e2e/helpers/persisted-profile-state.ts new file mode 100644 index 00000000000..02e00a89873 --- /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 00000000000..50588174d27 --- /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/ssh-reconnect-failure-observation.ts b/tests/e2e/helpers/ssh-reconnect-failure-observation.ts new file mode 100644 index 00000000000..4a1dd4f9d58 --- /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/helpers/terminal-restart-persistence.ts b/tests/e2e/helpers/terminal-restart-persistence.ts new file mode 100644 index 00000000000..46ba4efda9f --- /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 73527afa50d..28d8f5565df 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 68c508eca62..a7090c0fd92 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 b9cd2b35dc7..bc6514a3904 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-parked-scrollback-restart.spec.ts b/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts index 5d83bef49f4..608c31111af 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/paired-remote-terminal-serve-restart-binding.spec.ts b/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts index b9d2242de22..7047f8a6dcb 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 3f63366b25f..dbfba16936e 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,288 @@ 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 + const candidateExecutable = await candidateApp.evaluate(() => process.execPath) + 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) + + 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 { + 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 + const candidateExecutable = await app.evaluate(() => process.execPath) + 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') + 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) => + /^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/plugin-startup-budget.spec.ts b/tests/e2e/plugin-startup-budget.spec.ts index 8f03d03d3e7..369a0c6e24e 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 + ) }) 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 00000000000..d3eb3a815e9 --- /dev/null +++ b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts @@ -0,0 +1,222 @@ +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' +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' } + : {}), + // Raw development Electron needs the same sandbox opt-out as Playwright. + ...(process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: '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 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: recoveryMarker + }) + ) + 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: 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 + 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).equals(backupBytes)).toBe(true) + 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).equals(backupBytes)).toBe(true) + await session.close(firstApp) + firstApp = null + await cleanupE2EDaemons(session.userDataDir) + + for (const file of [dataFile, rootJson, `${databasePath}-wal`, `${databasePath}-shm`]) { + rmSync(file, { force: true }) + } + // 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, + backup.id, + recoveryExecutable + ) + expect(restored.code, restored.stderr || restored.stdout).toBe(0) + const recovered = JSON.parse(restored.stdout) + expect(recovered).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) + 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() + restoredApp = relaunched.app + await waitForSessionReady(relaunched.page) + await expect + .poll(() => + relaunched.page.evaluate(() => window.__store?.getState().settings?.terminalFontSize) + ) + .toBe(19) + 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()) + ) + ).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 00000000000..656692e9f68 --- /dev/null +++ b/tests/e2e/profile-state-terminal-restart-persistence.spec.ts @@ -0,0 +1,591 @@ +/** 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) + + 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) + + 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 switching back. + await attachRepoAndOpenTerminal(secondLaunch.page, repoPath) + await waitForSessionReady(secondLaunch.page) + + await expect( + secondLaunch.page.evaluate( + (profileId) => window.api.orcaProfiles.switchProfile({ profileId }), + defaultProfileId + ) + ).resolves.toEqual({ status: 'relaunching' }) + 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) + 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 + + // 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) + 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) + 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(launchOptions) + 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 + 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(launchOptions) + 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() + await testInfo.attach('restart-stderr', { + body: stderr.join(''), + contentType: 'text/plain' + }) + } + }) +}) diff --git a/tests/e2e/remote-terminal-tab-retirement.unit.test.ts b/tests/e2e/remote-terminal-tab-retirement.unit.test.ts index 45ac2f8b0a0..4d5ee50a1d7 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) { diff --git a/tests/e2e/settled-worker-tab-survives-restart.spec.ts b/tests/e2e/settled-worker-tab-survives-restart.spec.ts index db3b03409dd..695abfa0325 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' @@ -71,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( @@ -87,21 +90,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 +481,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 +516,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-reconnect-pane-restore.spec.ts b/tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts index a7a2261de68..27be0b9ad1b 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) diff --git a/tests/e2e/ssh-docker-resource-accumulation.spec.ts b/tests/e2e/ssh-docker-resource-accumulation.spec.ts index f028ef0d2ab..bb4a3520183 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 { diff --git a/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts b/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts index 83a7ae66f65..7939320d666 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-binding-crash-hydration.unit.test.ts b/tests/e2e/terminal-binding-crash-hydration.unit.test.ts new file mode 100644 index 00000000000..ec83a396e38 --- /dev/null +++ b/tests/e2e/terminal-binding-crash-hydration.unit.test.ts @@ -0,0 +1,101 @@ +import '../../src/renderer/src/store/slices/terminal-hydration-store-test-bootstrap' +import { describe, expect, it, vi } from 'vitest' +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() })) +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]) + } + ) +}) diff --git a/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts b/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts index 2eec7356f65..48a0f7a118c 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') - } - - 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 + 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') } - } - writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`) + + 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 + } + } + }) } 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 b9cbd6a00b6..95bc5a4dd5d 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' +import { + seededRepoPathOrSkip, + bootstrapFirstLaunch, + bootstrapRestoredLaunch, + waitForTerminalActiveLine, + readTerminalActiveLine, + setPaneTitleFromTerminalMenu, + getTabCustomTitle, + expectSavedLayoutToContainTitle +} from './helpers/terminal-restart-persistence' -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. +// 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 50c3d153101..26ce06e0da8 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 {