Files

20 KiB

APP-3792 codebase indexing persistence PR tech spec

Problem statement

This PR makes remote codebase indexing survive daemon restarts and reconnects by restoring daemon-owned codebase index metadata and snapshots at startup, keeping the client synchronized with daemon status snapshots and updates, and exposing remote indexed codebases to the agent context in the same broad shape as local indexed codebases. The changes are intentionally scoped to the persistence/bootstrap and protocol plumbing needed for APP-3792. They do not redesign the local indexing product flow, move retrieval fully into the daemon, or remove the current remote ResyncCodebase protocol path.

Current state

Local codebase indexing is owned in-process by CodebaseIndexManager in crates/ai/src/index/full_source_code_embedding/manager.rs. The normal app path constructs it with app-scoped persisted metadata, app-default snapshot storage, a BulkFilesystemWatcher, and a client-side StoreClient. Local settings can trigger a manual resync through CodeSettingsPageAction::ManualResync, which calls CodebaseIndexManager::try_manual_resync_codebase directly because the settings UI and index manager live in the same process. Remote codebase indexing splits those responsibilities across the Warp client and the remote-server daemon. The daemon owns remote filesystem walking, snapshot files, indexing work, and backend sync. The client owns session context, settings/speedbump decisions, agent tool advertisement, and retrieval orchestration. The client and daemon communicate through crates/remote_server/proto/remote_server.proto, so operations that are direct method calls locally become client-to-daemon messages remotely. Before this PR, remote daemon indexing state was too transient: a reconnect or daemon restart did not have a narrow restore path for known remote codebase index metadata and daemon-scoped snapshots. The client also needed a bootstrap status snapshot from the daemon so the active remote repo and agent context could reflect already-indexed remote codebases without waiting for a new indexing run.

Goals

Restore remote codebase index metadata for the remote-server daemon while keeping a clear startup boundary between full persistence reads and the subset of restored data the daemon is allowed to consume. Give the daemon an identity-scoped persistence root and snapshot directory so long-lived remote indexing data does not mix with normal app warp.sqlite state. Reuse the existing CodebaseIndexManager implementation for both app and daemon paths by injecting snapshot storage rather than forking indexing logic. Push a full remote codebase index status snapshot after daemon initialize/reconnect, then keep the client current with incremental status updates. Avoid automatic reindex requests when navigating to a repo that is already ready, stale with a last ready index, queued, or indexing. Expose ready remote codebases to agent context as stable (name, path) entries. Keep the current ResyncCodebase protocol in this PR while documenting why it exists and how it compares with local resync.

Non-goals

Do not remove or fold ResyncCodebase into IndexCodebase in this PR. Do not change local codebase indexing behavior, local persistence schema semantics beyond the shared metadata reuse, or local settings UI behavior. Do not build a daemon-only indexing implementation separate from CodebaseIndexManager. Do not make the daemon consume or initialize app-only state such as panes, cloud objects, command history, user profiles, MCP servers, or projects. Do not introduce a client-initiated per-repo status fetch path; daemon-pushed snapshots and deltas remain the synchronization mechanism.

Proposed design

Daemon-scoped persistence restore

persistence::initialize accepts a PersistenceScope so startup can choose between the normal app scope and the remote-server daemon scope. PersistenceScope::App uses the normal app database path. PersistenceScope::RemoteServerDaemon { identity_key } uses a daemon-specific database path derived from the remote-server identity. Both scopes read the same PersistedData shape through the existing SQLite restore helper and both scopes receive writer handles for subsequent updates. The boundary between app and daemon restore lives at startup initialization rather than in the SQLite reader. initialize_app maps the full restored PersistedData payload directly into the startup variables used by singleton registration, matching normal app startup. Immediately after that mapping, initialize_app applies the launch-mode boundary. Normal app startup consumes the full app restore payload. LaunchMode::RemoteServerDaemon preserves persisted_workspaces from codebase_indices for indexing restore and defaults the app-only startup fields. This keeps a single persistence read path and makes daemon consumption explicit and auditable at initialization. This split is intentional. The daemon needs the same Diesel/SQLite open, migrate, writer, and full-schema read path as the app so the persistence layer does not fork into app-shaped and daemon-shaped restore contracts. But the daemon should not retain unrelated app-scoped state after initialization. Keeping the direct PersistedData startup mapping and filtering in initialize_app makes the daemon boundary explicit where startup models are registered while still reusing the existing persistence infrastructure. The full app restore payload includes state that is meaningful only inside the interactive app process: window/session restoration, cloud object caches, command history, user profiles, workspace language-server settings, MCP server installations, project rules, ignored suggestions, and other UI or app-lifecycle state. Synchronizing all of that into the remote daemon would create two problems. First, the daemon would spend startup time reading and allocating data it will never use. Second, the daemon would become another consumer of app-owned invariants and migrations, so future app persistence changes could accidentally affect a headless remote process. The daemon only consumes enough persisted data to reconstruct codebase-indexing state:

  • repo metadata used to seed CodebaseIndexManager,
  • the daemon snapshot root used to validate or discard serialized snapshots,
  • identity-scoped status/enablement decisions represented by restored codebase metadata,
  • writer handles for subsequent codebase-index metadata updates. That narrow startup consumption is still “syncing app data” in the sense that it reuses the same codebase_indices model and full persisted data shape that local app startup uses, but it is not letting the daemon initialize the entire app object graph. This is the intended boundary: share the data model and persistence infrastructure for codebase indexing, then select launch-mode-appropriate fields before registering startup models. The daemon database and snapshot directories should remain owner-only, matching the remote-server socket/cache privacy model. The database is identity-scoped because enablement/status/backend authorization decisions are Warp-user-specific. Snapshot files are injected separately so the daemon can use its remote-server data root instead of the app default.

Shared index manager with injected snapshot storage

CodebaseIndexManager::new remains the default app constructor. In local_fs builds it migrates old app snapshots if needed and passes SnapshotStorage::app_default() into new_with_snapshot_storage. CodebaseIndexManager::new_with_snapshot_storage is the daemon-compatible seam. It accepts the same persisted WorkspaceMetadata and indexing configuration as the app constructor, but lets startup choose the snapshot root. The remote-server daemon passes daemon-scoped storage; the app passes app-default storage. This keeps indexing behavior shared:

  • snapshot validity checks use the same code,
  • invalid metadata emits the same RemoveExpiredIndexMetadata event,
  • valid metadata feeds the same persisted build queue,
  • rebuild/resync/drop paths continue to use CodebaseIndexManager and CodebaseIndex. The only difference is where metadata and serialized snapshots are restored from and written to.

Startup and status synchronization

On app startup, initialize_app selects a persistence scope from LaunchMode. The normal app, CLI, proxy, and tests use PersistenceScope::App; LaunchMode::RemoteServerDaemon { identity_key } uses PersistenceScope::RemoteServerDaemon. initialize_app normalizes the full restored PersistedData payload into the startup fields consumed later by singleton registration. It then clears app-only restored fields for daemon launch while preserving persisted_workspaces from codebase_indices. That lets the existing CodebaseIndexManager constructor receive daemon-restored index metadata without pretending the daemon has a full app session restore. When a client initializes with the daemon, RemoteServerModel pushes CodebaseIndexStatusesSnapshot. The client-side RemoteCodebaseIndexModel applies that snapshot by replacing statuses for the connected host, then applies subsequent CodebaseIndexStatusUpdated deltas. This makes reconnect a full-resync boundary and keeps steady-state updates lightweight.

Navigation and automatic indexing

RemoteCodebaseIndexModel records the active repo for a host when it receives NavigatedToDirectory. If the navigated directory is a git repo and remote auto-indexing is enabled, it calls should_request_auto_index_for_navigated_git_repo before sending an indexing request. That guard mirrors the local product expectation: navigating into an already-known repo should not immediately trigger another indexing run. It returns false when the current status is ready, stale with a usable last root, queued, or indexing. It returns true when the repo is missing from the status map or has an unusable state such as failed/unavailable/missing root hash. This preserves automatic indexing for first discovery and recovery while avoiding repeated reindex requests on every cd into a repo.

Agent context and search availability

RemoteCodebaseIndexModel::codebases_for_agent_context projects ready searchable remote repos into stable entries with a display name and path. The model only includes statuses that resolve to RemoteCodebaseSearchAvailability::Ready, so unindexed, indexing, failed, or otherwise unavailable repos do not appear as searchable codebase context. For active-session search, active_repo_availability resolves an explicit repo path first when it matches known status, otherwise falls back to the active repo for the host or current working directory. Ready availability carries the remote path, root hash, and embedding config needed by downstream search plumbing.

Protocol shape and local resync contrast

Local resync has two distinct product shapes but no wire protocol. First-time indexing goes through the same manager that owns the local file watcher, persisted metadata, and snapshot storage. Manual resync is an in-process settings action: the settings page dispatches ManualResync(PathBuf) and directly calls CodebaseIndexManager::try_manual_resync_codebase. Drop/delete similarly calls the manager directly through settings UI actions. Local code can distinguish “index this repo for the first time,” “retry or manually resync this already-indexed repo,” and “drop this repo” by calling different Rust methods because the UI and index manager share memory. The local pattern also keeps resync conservative. A manual resync only applies when a codebase is already known to the manager. Navigation or repo discovery does not imply a full resync if a ready or stale index already exists; local indexing keeps search available against the last ready root while watcher-driven or manual sync work catches up. Remote resync crosses the client/daemon boundary. This PR currently models that distinction explicitly with two proto messages:

  • IndexCodebase { repo_path, auth_token } requests indexing for a repo that may not have an index yet.
  • ResyncCodebase { repo_path, auth_token } requests a manual full resync of a repo that is already indexed. That explicit remote protocol mirrors the local product distinction between initial indexing and retry/resync affordances, while making the daemon-side behavior readable in logs, telemetry operation names, and request dispatch. It also lets the daemon return a clear unavailable status when asked to resync a repo it does not know about. There is a reasonable simplification to consider later: make remote IndexCodebase mean “ensure indexed, and if already indexed, perform the manual full resync requested by the client.” That would remove ResyncCodebase from the proto and make remote indexing idempotent through one request type. This PR does not implement that simplification so the persistence/status bootstrap work remains isolated from protocol churn. If a follow-up removes ResyncCodebase, it should update all of these seams together:
  • proto oneof and message definition,
  • RemoteServerClient::resync_codebase,
  • RemoteServerOperation::ResyncCodebase,
  • RemoteCodebaseIndexMutation::Resync,
  • RemoteServerModel::handle_resync_codebase,
  • client round-trip tests. The daemon handle_index_codebase behavior would then need to explicitly call try_manual_resync_codebase for already-indexed repos.

Mirroring local patterns in remote indexing

Remote indexing should feel like the local feature even though the implementation is split across processes. The strongest local patterns to preserve are:

  • A single indexing manager owns per-repo index lifecycle, watcher integration, snapshot validation, sync state, retrieval state, and drop/resync behavior.
  • Startup restores persisted codebase metadata first, then queues valid persisted indices through the same manager path as fresh indices.
  • Snapshot corruption or incompatibility invalidates the snapshot and falls back to rebuild rather than leaving a repo permanently broken.
  • Ready and stale states keep search available through the last ready root; queued and indexing states suppress duplicate indexing requests.
  • Settings and speedbump UI decide when to index, retry, resync, or drop; the index manager executes those decisions.
  • Manual retry/resync is separate from passive navigation. Navigating into a known repo should update active context, not force a rebuild. This PR mirrors those patterns by reusing CodebaseIndexManager, injecting daemon snapshot storage instead of adding a remote-only manager, feeding daemon-restored metadata into the same persisted build queue, and using RemoteCodebaseIndexModel as the client-side analog of local availability state. The remote model records active repo context, applies daemon status snapshots, filters agent context to ready searchable repos, and avoids duplicate auto-index requests when a repo is already ready, stale, queued, or indexing. The places where remote intentionally differs from local are the process boundary and persistence scope:
  • Local can call manager methods directly; remote must encode user actions as proto messages.
  • Local can consume full app state because it is the app; the daemon reads the same persisted shape but consumes only codebase-index data because it is a headless worker.
  • Local snapshot storage uses the app default; the daemon injects a remote-server data root.
  • Local UI state and daemon indexing state synchronize through explicit status snapshots and deltas rather than shared memory. Future remote improvements should continue to ask “what is the closest local pattern?” before adding remote-specific behavior. Examples:
  • If local treats retry as a manual resync of a known repo, remote should either keep a clearly named ResyncCodebase message or make IndexCodebase explicitly perform that same known-repo resync behavior.
  • If local keeps stale search available, remote status pushes should include the last ready root hash for stale states so agent search can continue.
  • If local snapshot validation removes invalid persisted metadata, daemon startup should remove invalid daemon metadata and push disabled/failed status deltas rather than silently dropping client state.
  • If local settings are the source of truth for user intent, remote daemon handlers should execute explicit client decisions and avoid inventing new daemon-only enablement policy.

Error handling and security

The daemon should report persistence startup failures through the existing SQLite error reporting and telemetry path, then degrade by starting without restored metadata rather than crashing the app path. Remote indexing requests that can cause daemon-to-Warp-service calls carry auth_token in the protocol payload. The token must never be logged or persisted. The daemon uses it for request-scoped outbound auth and should reject missing or invalid credentials rather than treating the daemon's cached token as ambient authority for proxy-socket writers. Status values sent to the client should avoid exposing implementation details beyond what the client needs: repo path, lifecycle state, progress, failure message, root hash when search is ready/stale, and embedding config when root hash is present.

Testing strategy

Unit coverage should focus on the client model and protocol seams:

  • snapshot application replaces host-scoped statuses and leaves other hosts untouched,
  • incremental status updates update one repo,
  • ready/stale statuses with usable root hashes are searchable,
  • queued/indexing statuses suppress duplicate automatic indexing,
  • failed/unavailable/missing-root statuses allow recovery indexing,
  • agent context includes only ready searchable remote repos,
  • remote client round-trip tests cover the current indexing/resync/drop request messages. Persistence coverage should verify that the SQLite reader restores both app state and codebase index metadata through the full PersistedData payload. Startup-level coverage should verify that daemon launch consumes only codebase index metadata from that payload and passes daemon-scoped snapshot storage into CodebaseIndexManager::new_with_snapshot_storage. Targeted validation for this PR should include the remote-server client tests, RemoteCodebaseIndexModel tests, and a compile check for the app/remote-server crates touched by the persistence and proto changes. Full PR validation should still use the repository presubmit expectations before pushing.

Risks and mitigations

Daemon accidentally consumes app state

Consuming full app data in the daemon would couple daemon startup to UI/session state and app-only singleton assumptions. Mitigate by reading the shared PersistedData shape, then making the LaunchMode::RemoteServerDaemon branch in initialize_app preserve only persisted_workspaces and default app-only restored fields before model initialization.

Duplicate indexing on navigation

Remote navigation events can fire often. Without the status guard, entering a known repo could repeatedly enqueue indexing. Mitigate with should_request_auto_index_for_navigated_git_repo and tests for ready, stale, queued, indexing, failed, and missing states.

Client and daemon status drift

If the client misses daemon state during reconnect, tool availability and settings rows can be stale. Mitigate by pushing CodebaseIndexStatusesSnapshot after initialize/reconnect and using incremental updates only after that bootstrap.

Protocol complexity

Keeping both IndexCodebase and ResyncCodebase makes the remote protocol larger than the minimal idempotent shape. Mitigate by documenting the local-resync parity rationale now and treating removal/folding as a follow-up protocol cleanup rather than mixing it into this persistence PR.

Definition of done

Remote daemon startup reads the shared persisted data shape but restores only known codebase index metadata into daemon startup state. The daemon uses daemon-scoped snapshot storage through CodebaseIndexManager::new_with_snapshot_storage. The client receives a bootstrap status snapshot and applies subsequent status deltas. Remote auto-indexing does not re-request indexing for ready/stale/queued/indexing repos on navigation. Ready remote repos can appear in agent codebase context. The PR tech spec documents the persistence architecture and the IndexCodebase versus ResyncCodebase protocol tradeoff without removing ResyncCodebase.