3856 lines
162 KiB
Rust
3856 lines
162 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::future::Future;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use ::ai::index::full_source_code_embedding::manager::{
|
|
CodebaseIndexManager, CodebaseIndexManagerEvent,
|
|
FragmentMetadataLookupError as LocalFragmentMetadataLookupError,
|
|
};
|
|
use ::ai::index::full_source_code_embedding::{
|
|
ContentHash, FragmentMetadata as LocalFragmentMetadata, NodeHash,
|
|
};
|
|
use ::ai::project_context::model::{ProjectContextModel, ProjectContextModelEvent};
|
|
use galaxy_core::channel::ChannelState;
|
|
use galaxy_core::{safe_error, SessionId};
|
|
use galaxy_files::{FileModel, FileModelEvent};
|
|
use galaxy_util::content_version::ContentVersion;
|
|
use galaxy_util::file::FileId;
|
|
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
|
use galaxy_util::standardized_path::StandardizedPath;
|
|
use galaxyui::platform::TerminationMode;
|
|
use galaxyui::r#async::{Spawnable, SpawnableOutput, SpawnedFutureHandle};
|
|
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
|
use remote_server::proto::OpenBufferSuccess;
|
|
use repo_metadata::repositories::{DetectedRepositories, RepoDetectionSource};
|
|
use repo_metadata::{RepoMetadataEvent, RepoMetadataModel, RepositoryIdentifier};
|
|
|
|
use super::codebase_index_status::{
|
|
codebase_index_status_to_proto, disabled_codebase_index_status,
|
|
not_enabled_codebase_index_status, queued_codebase_index_status,
|
|
unavailable_codebase_index_status,
|
|
};
|
|
use super::diff_state_tracker::{
|
|
DiffModelKey, DiffStateUpdate, RemoteDiffStateManager, SubscribeOutcome,
|
|
};
|
|
use super::proto::{
|
|
client_message, delete_file_response, discard_files_response, get_diff_state_response,
|
|
get_fragment_metadata_from_hash_response, git_commit_chain_response, git_create_pr_response,
|
|
git_generate_commit_message_response, git_get_committed_branch_files_response,
|
|
git_push_response, host_scoped_request, notification, remote_skill_proto,
|
|
resolve_conflict_response, run_command_response, save_buffer_response, server_message,
|
|
session_scoped_request, write_file_response, Abort, Authenticate, BranchInfo, BufferEdit,
|
|
BufferUpdatedPush, ClientMessage, CloseBuffer, CodebaseIndexLimits, CodebaseIndexStatus,
|
|
CodebaseIndexStatusUpdated, CodebaseIndexStatusesSnapshot, CodebaseResyncMode, DeleteFile,
|
|
DeleteFileResponse, DeleteFileSuccess, DiscardFilesError, DiscardFilesResponse,
|
|
DiscardFilesSuccess, DropCodebaseIndex, ErrorCode, ErrorResponse, FailedFileRead,
|
|
FileContextProto, FileOperationError, FragmentMetadata as ProtoFragmentMetadata,
|
|
FragmentMetadataLookupError as ProtoFragmentMetadataLookupError,
|
|
FragmentMetadataLookupErrorCode, GetBranchesError, GetBranchesResponse, GetBranchesSuccess,
|
|
GetDiffStateResponse, GetFragmentMetadataFromHash, GetFragmentMetadataFromHashResponse,
|
|
GetFragmentMetadataFromHashSuccess, GitCommitChainMode, GitCommitChainRequest,
|
|
GitCommitChainResponse, GitCommitChainSuccess, GitCreatePrRequest, GitCreatePrResponse,
|
|
GitGenerateCommitMessageRequest, GitGenerateCommitMessageResponse,
|
|
GitGetCommittedBranchFilesRequest, GitGetCommittedBranchFilesResponse,
|
|
GitGetCommittedBranchFilesSuccess, GitHubPrInfoPush, GitHubRepositoryInfoPush, GitOpDelta,
|
|
GitOpError, GitPushRequest, GitPushResponse, GitStatusPush, HomeSkillMetadata, IndexCodebase,
|
|
Initialize, InitializeResponse, MissingFragmentMetadata, NavigatedToDirectory,
|
|
NavigatedToDirectoryResponse, OpenBuffer, OpenBufferResponse, ReadFileContextResponse,
|
|
RemoteAgentContextSnapshot, RemoteContextFileProto, RemoteSkillProto, ResolveConflict,
|
|
ResolveConflictResponse, ResolveConflictSuccess, ResyncCodebase, RipgrepSearchRequest,
|
|
RunCommandError, RunCommandErrorCode, RunCommandRequest, RunCommandResponse, RunCommandSuccess,
|
|
SaveBuffer, SaveBufferResponse, SaveBufferSuccess, ServerMessage, SessionBootstrapped,
|
|
TextEdit, UpdateGitHubPrInfo, UpdateGitHubRepoInfo, UpdateGitStatus, UploadHandoffSnapshot,
|
|
WriteFile, WriteFileResponse, WriteFileSuccess,
|
|
};
|
|
use super::server_buffer_tracker::{PendingBufferRequestKind, ServerBufferTracker};
|
|
use super::{diff_state_proto, ripgrep_search};
|
|
use crate::code::global_buffer_model::{GlobalBufferModel, GlobalBufferModelEvent};
|
|
use crate::code_review::diff_state::{CommitChainMode, DiffMode, FileStatusInfo};
|
|
use crate::code_review::git_repo_model::{GitRepoModels, GitRepoStatusModel};
|
|
use crate::code_review::github_repo_model::{GitHubRepoEvent, GitHubRepoModel};
|
|
#[cfg(feature = "local_tty")]
|
|
use crate::terminal::local_shell::LocalShellState;
|
|
use crate::terminal::shell::ShellType;
|
|
|
|
/// How long the daemon waits with no connections before exiting.
|
|
pub const GRACE_PERIOD: std::time::Duration = std::time::Duration::from_secs(10 * 60);
|
|
|
|
/// Server-side cap on the number of branches returned by `GetBranches`.
|
|
/// Prevents a client from forcing the daemon to enumerate an arbitrarily
|
|
/// large ref list.
|
|
const MAX_BRANCH_COUNT_CAP: usize = 500;
|
|
|
|
/// Unique identifier for a connected proxy session in daemon mode.
|
|
pub type ConnectionId = uuid::Uuid;
|
|
use super::protocol::RequestId;
|
|
use crate::ai::agent::FileLocations;
|
|
use crate::ai::blocklist::handoff::snapshot::upload_result_to_proto;
|
|
use crate::ai::blocklist::{read_local_file_context, ReadFileContextResult};
|
|
use crate::ai::skills::{
|
|
bundled_skill_snapshot_protos, BundledSkill, SkillManager, SkillManagerEvent,
|
|
};
|
|
use crate::auth::auth_state::{AuthState, AuthStateProvider};
|
|
use crate::code_review::git_actions;
|
|
use crate::features::FeatureFlag;
|
|
use crate::server::server_api::ServerApiProvider;
|
|
use crate::terminal::model::session::command_executor::{
|
|
ExecuteCommandOptions, LocalCommandExecutor,
|
|
};
|
|
use crate::util::git;
|
|
|
|
/// Resolves the global bundled resources directory populated by the install
|
|
/// script (see [`remote_server::setup::remote_server_bundled_resources_dir`]),
|
|
/// expanding the shell-form `~/` prefix against this process's home directory.
|
|
///
|
|
/// This deliberately does not use `galaxy_core::paths::bundled_resources_dir`,
|
|
/// whose macOS behavior resolves resources inside an app bundle. The global
|
|
/// location is version-independent: the last install wins, and slight skew
|
|
/// against this daemon's version is accepted.
|
|
fn daemon_bundled_resources_dir() -> Option<PathBuf> {
|
|
let dir = remote_server::setup::remote_server_bundled_resources_dir();
|
|
let suffix = dir.strip_prefix("~/")?;
|
|
let dir = dirs::home_dir()?.join(suffix);
|
|
dir.is_dir().then_some(dir)
|
|
}
|
|
fn remote_agent_context_snapshot(
|
|
revision: u64,
|
|
bundled_skills: &[RemoteSkillProto],
|
|
ctx: &warpui::AppContext,
|
|
) -> RemoteAgentContextSnapshot {
|
|
let home_dir = dirs::home_dir()
|
|
.map(|path| path.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
let mut skills = bundled_skills.to_vec();
|
|
skills.extend(
|
|
SkillManager::as_ref(ctx)
|
|
.home_skills()
|
|
.map(|skill| RemoteSkillProto {
|
|
path: skill.path.display_path(),
|
|
content: skill.content.clone(),
|
|
source: Some(remote_skill_proto::Source::Home(HomeSkillMetadata {})),
|
|
}),
|
|
);
|
|
skills.sort_by(|a, b| a.path.cmp(&b.path));
|
|
let mut global_rules = ProjectContextModel::as_ref(ctx)
|
|
.global_rules()
|
|
.map(|rule| RemoteContextFileProto {
|
|
path: rule.path.display_path(),
|
|
content: rule.content,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
global_rules.sort_by(|a, b| a.path.cmp(&b.path));
|
|
RemoteAgentContextSnapshot {
|
|
revision,
|
|
home_dir,
|
|
skills,
|
|
global_rules,
|
|
}
|
|
}
|
|
|
|
/// Outcome of dispatching a request-style `ClientMessage`.
|
|
///
|
|
/// Notifications (fire-and-forget messages like `SessionBootstrapped` and
|
|
/// `Abort`) do not produce a `HandlerOutcome`; they are dispatched inline in
|
|
/// `handle_message` and return early.
|
|
#[allow(clippy::large_enum_variant)]
|
|
enum HandlerOutcome {
|
|
/// The response is ready synchronously — the caller sends it immediately.
|
|
Sync(server_message::Message),
|
|
/// The handler initiated async work whose response will be sent later.
|
|
///
|
|
/// When the handle is `Some`, the caller inserts it into `in_progress`
|
|
/// so the request can be cancelled via `Abort`. Removal on
|
|
/// completion/abort is arranged by [`ServerModel::spawn_request_handler`].
|
|
///
|
|
/// `None` is used for async work whose completion is delivered through
|
|
/// a separate event subscription and is not currently cancellable via
|
|
/// `Abort` (e.g. `FileModel` events for file writes and deletes, which
|
|
/// are tracked by `FileId` in `pending_file_ops` rather than by
|
|
/// `RequestId` in `in_progress`).
|
|
Async(Option<SpawnedFutureHandle>),
|
|
}
|
|
|
|
struct CodebaseIndexRequest {
|
|
repo_path: PathBuf,
|
|
}
|
|
struct CodebaseIndexRequestParams<'a> {
|
|
operation_name: &'a str,
|
|
repo_path: String,
|
|
auth_token: String,
|
|
auth_operation: &'a str,
|
|
path_kind: CodebaseIndexRequestPathKind,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum CodebaseIndexRequestPathKind {
|
|
Canonicalized,
|
|
Requested,
|
|
}
|
|
|
|
/// Tracks an in-flight file write or delete so the async completion
|
|
/// event can be correlated back to the originating client request.
|
|
enum FileOpKind {
|
|
Write,
|
|
Delete,
|
|
}
|
|
|
|
struct PendingFileOp {
|
|
request_id: RequestId,
|
|
conn_id: ConnectionId,
|
|
kind: FileOpKind,
|
|
}
|
|
|
|
/// Manages pending file operations and ensures that the corresponding
|
|
/// `FileModel` entry is always cleaned up when an operation completes
|
|
/// or fails, preventing `FileState` leaks.
|
|
struct PendingFileOps {
|
|
ops: HashMap<FileId, PendingFileOp>,
|
|
}
|
|
|
|
impl PendingFileOps {
|
|
fn new() -> Self {
|
|
Self {
|
|
ops: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Registers a file path with `FileModel`, sets the initial version,
|
|
/// and tracks the pending operation. Returns the `FileId` and
|
|
/// `ContentVersion` for the caller to initiate the actual I/O.
|
|
fn insert(
|
|
&mut self,
|
|
path: &Path,
|
|
request_id: RequestId,
|
|
conn_id: ConnectionId,
|
|
kind: FileOpKind,
|
|
ctx: &mut ModelContext<ServerModel>,
|
|
) -> (FileId, ContentVersion) {
|
|
let file_model = FileModel::handle(ctx);
|
|
let file_id = file_model.update(ctx, |m, ctx| m.register_file_path(path, false, ctx));
|
|
let version = ContentVersion::new();
|
|
file_model.update(ctx, |m, _| m.set_version(file_id, version));
|
|
self.ops.insert(
|
|
file_id,
|
|
PendingFileOp {
|
|
request_id,
|
|
conn_id,
|
|
kind,
|
|
},
|
|
);
|
|
(file_id, version)
|
|
}
|
|
|
|
fn get(&self, file_id: &FileId) -> Option<&PendingFileOp> {
|
|
self.ops.get(file_id)
|
|
}
|
|
|
|
/// Removes a pending operation and unsubscribes the file from `FileModel`,
|
|
/// preventing the `FileState` entry from leaking.
|
|
fn remove(
|
|
&mut self,
|
|
file_id: FileId,
|
|
ctx: &mut ModelContext<ServerModel>,
|
|
) -> Option<PendingFileOp> {
|
|
let op = self.ops.remove(&file_id)?;
|
|
FileModel::handle(ctx).update(ctx, |m, ctx| m.unsubscribe(file_id, ctx));
|
|
Some(op)
|
|
}
|
|
}
|
|
|
|
/// The top-level server-side orchestrator model.
|
|
///
|
|
/// Receives `ClientMessage`s from connected proxy sessions and routes
|
|
/// `ServerMessage` responses and push notifications back through each
|
|
/// connection's dedicated sender channel.
|
|
pub struct ServerModel {
|
|
/// Per-connection outbound channels, keyed by `ConnectionId`.
|
|
///
|
|
/// The daemon can serve multiple proxy connections simultaneously — one
|
|
/// per SSH session / Warp tab connecting to this host. Each entry maps
|
|
/// a connection's `Uuid` to the channel the connection task drains to
|
|
/// write `ServerMessage`s back to its proxy.
|
|
connection_senders: HashMap<ConnectionId, async_channel::Sender<ServerMessage>>,
|
|
/// Per-connection set of repo roots for which we've already sent a
|
|
/// snapshot in this connection's lifetime.
|
|
///
|
|
/// Used to avoid sending duplicate snapshots on repeated
|
|
/// `NavigatedToDirectory` calls while the user `cd`s within the same repo.
|
|
snapshot_sent_roots_by_connection: HashMap<ConnectionId, HashSet<StandardizedPath>>,
|
|
/// Abort handle for the active grace timer, if any.
|
|
/// Calling `.abort()` cancels the timer before it fires.
|
|
grace_timer_cancel: Option<SpawnedFutureHandle>,
|
|
/// Tracks in-progress requests that can be cancelled via `Abort`.
|
|
/// Calling `.abort()` on the handle cancels the background future and
|
|
/// triggers its `on_abort` callback.
|
|
in_progress: HashMap<RequestId, SpawnedFutureHandle>,
|
|
/// Stable host identifier generated once at process startup.
|
|
/// Returned in every `InitializeResponse` so clients can deduplicate
|
|
/// host-scoped models.
|
|
host_id: String,
|
|
/// Bundled skill source entries detected and rendered on the daemon.
|
|
bundled_skills: Vec<RemoteSkillProto>,
|
|
/// Latest revisioned full replacement of all daemon-host Agent Mode context.
|
|
remote_agent_context_snapshot: RemoteAgentContextSnapshot,
|
|
/// Connections that have already received the current snapshot revision.
|
|
remote_agent_context_snapshot_sent: HashSet<ConnectionId>,
|
|
/// Per-session command executors created from `SessionBootstrapped` notifications.
|
|
executors: HashMap<SessionId, Arc<LocalCommandExecutor>>,
|
|
/// Tracks in-flight file write/delete operations and handles cleanup.
|
|
pending_file_ops: PendingFileOps,
|
|
/// Daemon-wide auth credentials and user identity.
|
|
auth_state: Arc<AuthState>,
|
|
/// Tracks open buffers, per-buffer connection sets, and pending async
|
|
/// buffer requests (OpenBuffer, SaveBuffer).
|
|
buffers: ServerBufferTracker,
|
|
/// Manages per-(repo, mode) diff state models and per-connection subscriptions.
|
|
diff_states: ModelHandle<RemoteDiffStateManager>,
|
|
/// In-flight host-scoped requests whose response may be delivered on
|
|
/// a different connection if the originating connection disconnects.
|
|
host_scoped_requests: HashMap<RequestId, ConnectionId>,
|
|
/// Per-repo local git status models tracked on the daemon, keyed by repo
|
|
/// path. Created when `NavigatedToDirectory` resolves a git root or a
|
|
/// client requests a snapshot; each is subscribed so watcher ticks
|
|
/// broadcast `GitStatusPush` to every connection.
|
|
git_status_models: HashMap<StandardizedPath, ModelHandle<GitRepoStatusModel>>,
|
|
/// Per-repo local GitHub-info models tracked on the daemon, keyed by repo
|
|
/// path. Created lazily on the first GitHub-info notification; each is
|
|
/// subscribed so `gh`-driven changes broadcast PR-info and repository-info
|
|
/// pushes to every connection.
|
|
github_repo_models: HashMap<StandardizedPath, ModelHandle<GitHubRepoModel>>,
|
|
/// Connections subscribed (via navigation) to each repo's git status,
|
|
/// keyed by repo path. A repo's git-status *and* GitHub-info models live
|
|
/// while this set is non-empty and are evicted once the last connection
|
|
/// unsubscribes (navigates away or disconnects). Mirrors
|
|
/// `RemoteDiffStateManager`'s per-key connection sets, keyed by repo only.
|
|
git_status_subscribers: HashMap<StandardizedPath, HashSet<ConnectionId>>,
|
|
/// Each connection's current git repo (a connection is in at most one repo
|
|
/// at a time), so a navigation can move its subscription and a disconnect
|
|
/// can drop it.
|
|
git_status_repo_by_conn: HashMap<ConnectionId, StandardizedPath>,
|
|
}
|
|
|
|
impl Entity for ServerModel {
|
|
type Event = ();
|
|
}
|
|
|
|
impl SingletonEntity for ServerModel {}
|
|
|
|
impl ServerModel {
|
|
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
|
let host_id = uuid::Uuid::new_v4().to_string();
|
|
log::info!(
|
|
"Daemon started: PID={}, host_id={}",
|
|
std::process::id(),
|
|
host_id
|
|
);
|
|
let bundled_skills = Vec::new();
|
|
let remote_agent_context_snapshot = remote_agent_context_snapshot(1, &bundled_skills, ctx);
|
|
let mut model = Self {
|
|
connection_senders: HashMap::new(),
|
|
snapshot_sent_roots_by_connection: HashMap::new(),
|
|
grace_timer_cancel: None,
|
|
in_progress: HashMap::new(),
|
|
host_id,
|
|
bundled_skills,
|
|
remote_agent_context_snapshot,
|
|
remote_agent_context_snapshot_sent: HashSet::new(),
|
|
executors: HashMap::new(),
|
|
pending_file_ops: PendingFileOps::new(),
|
|
auth_state: AuthStateProvider::as_ref(ctx).get().clone(),
|
|
buffers: ServerBufferTracker::new(),
|
|
diff_states: ctx.add_model(|_| RemoteDiffStateManager::new()),
|
|
host_scoped_requests: HashMap::new(),
|
|
git_status_models: HashMap::new(),
|
|
github_repo_models: HashMap::new(),
|
|
git_status_subscribers: HashMap::new(),
|
|
git_status_repo_by_conn: HashMap::new(),
|
|
};
|
|
// Subscribe to FileModel and RepoMetadataModel events
|
|
// file operation results and repo metadata pushes are forwarded to all
|
|
// connected proxy sessions.
|
|
{
|
|
let file_model = FileModel::handle(ctx);
|
|
ctx.subscribe_to_model(&file_model, |me, _, event, ctx| {
|
|
let file_id = event.file_id();
|
|
let Some(pending_kind) = me.pending_file_ops.get(&file_id).map(|op| &op.kind)
|
|
else {
|
|
return; // Not a file op we're tracking.
|
|
};
|
|
let response_message = match (event, pending_kind) {
|
|
(FileModelEvent::FileSaved { .. }, FileOpKind::Write) => {
|
|
server_message::Message::WriteFileResponse(WriteFileResponse {
|
|
result: Some(write_file_response::Result::Success(WriteFileSuccess {})),
|
|
})
|
|
}
|
|
(FileModelEvent::FileSaved { .. }, FileOpKind::Delete) => {
|
|
server_message::Message::DeleteFileResponse(DeleteFileResponse {
|
|
result: Some(delete_file_response::Result::Success(
|
|
DeleteFileSuccess {},
|
|
)),
|
|
})
|
|
}
|
|
(FileModelEvent::FailedToSave { error, .. }, FileOpKind::Write) => {
|
|
server_message::Message::WriteFileResponse(WriteFileResponse {
|
|
result: Some(write_file_response::Result::Error(FileOperationError {
|
|
message: format!("{error}"),
|
|
})),
|
|
})
|
|
}
|
|
(FileModelEvent::FailedToSave { error, .. }, FileOpKind::Delete) => {
|
|
server_message::Message::DeleteFileResponse(DeleteFileResponse {
|
|
result: Some(delete_file_response::Result::Error(FileOperationError {
|
|
message: format!("{error}"),
|
|
})),
|
|
})
|
|
}
|
|
(FileModelEvent::FileLoaded { .. }, _)
|
|
| (FileModelEvent::FailedToLoad { .. }, _)
|
|
| (FileModelEvent::FileUpdated { .. }, _) => return,
|
|
};
|
|
// Remove the pending op and unsubscribe from FileModel.
|
|
let pending = me
|
|
.pending_file_ops
|
|
.remove(file_id, ctx)
|
|
.expect("pending op was confirmed present");
|
|
me.send_server_message(
|
|
Some(pending.conn_id),
|
|
Some(&pending.request_id),
|
|
response_message,
|
|
);
|
|
});
|
|
}
|
|
{
|
|
let repo_model = RepoMetadataModel::handle(ctx);
|
|
ctx.subscribe_to_model(&repo_model, |me, _, event, ctx| match event {
|
|
RepoMetadataEvent::IncrementalUpdateReady { update } => {
|
|
me.send_server_message(
|
|
None,
|
|
None,
|
|
server_message::Message::RepoMetadataUpdate(update.into()),
|
|
);
|
|
}
|
|
RepoMetadataEvent::RepositoryUpdated {
|
|
id: RepositoryIdentifier::Local(path),
|
|
} => {
|
|
// A repo finished indexing — push the full tree as a snapshot.
|
|
let id = RepositoryIdentifier::local(path.clone());
|
|
let repo_model = RepoMetadataModel::handle(ctx);
|
|
if let Some(state) = repo_model.as_ref(ctx).get_repository(&id, ctx) {
|
|
let entries = super::repo_metadata_proto::file_tree_entry_to_snapshot_proto(
|
|
&state.entry,
|
|
);
|
|
let standing_results = repo_model
|
|
.as_ref(ctx)
|
|
.standing_query_results(&id, ctx)
|
|
.map(|results| (&results.as_snapshot_delta()).into());
|
|
me.send_server_message(
|
|
None,
|
|
None,
|
|
server_message::Message::RepoMetadataSnapshot(
|
|
super::proto::RepoMetadataSnapshot {
|
|
repo_path: path.to_string(),
|
|
entries,
|
|
sync_complete: true,
|
|
standing_results,
|
|
},
|
|
),
|
|
);
|
|
// Mark this root as snapshot-sent for all active connections
|
|
// so subsequent NavigatedToDirectory calls skip re-sending.
|
|
for sent_roots in me.snapshot_sent_roots_by_connection.values_mut() {
|
|
sent_roots.insert(path.clone());
|
|
}
|
|
}
|
|
}
|
|
RepoMetadataEvent::RepositoryRemoved { .. }
|
|
| RepoMetadataEvent::FileTreeUpdated { .. }
|
|
| RepoMetadataEvent::FileTreeEntryUpdated { .. }
|
|
| RepoMetadataEvent::StandingQueryResultsUpdated { .. }
|
|
| RepoMetadataEvent::UpdatingRepositoryFailed { .. }
|
|
| RepoMetadataEvent::RepositoryUpdated {
|
|
id: RepositoryIdentifier::Remote(_),
|
|
} => {}
|
|
});
|
|
}
|
|
let index_manager = CodebaseIndexManager::handle(ctx);
|
|
ctx.subscribe_to_model(&index_manager, |me, _, event, ctx| {
|
|
me.handle_codebase_index_manager_event(event, ctx);
|
|
});
|
|
// Subscribe to GlobalBufferModel events for server-local buffers.
|
|
{
|
|
let gbm = GlobalBufferModel::handle(ctx);
|
|
ctx.subscribe_to_model(&gbm, |me, _, event, ctx| match event {
|
|
GlobalBufferModelEvent::BufferLoaded { file_id, .. } => {
|
|
// Complete all pending OpenBuffer requests for this file.
|
|
let pending = me.buffers.take_pending_by_kind(
|
|
file_id,
|
|
PendingBufferRequestKind::OpenBuffer,
|
|
);
|
|
if !pending.is_empty() {
|
|
let gbm = GlobalBufferModel::handle(ctx);
|
|
let content = gbm.as_ref(ctx).content_for_file(*file_id, ctx);
|
|
let server_version = gbm
|
|
.as_ref(ctx)
|
|
.sync_clock_for_server_local(*file_id)
|
|
.map(|c| c.server_version.as_u64());
|
|
|
|
for req in pending {
|
|
let message = match (&content, server_version) {
|
|
(Some(content), Some(sv)) => {
|
|
server_message::Message::OpenBufferResponse(OpenBufferResponse{
|
|
result: Some(remote_server::proto::open_buffer_response::Result::Success(OpenBufferSuccess {
|
|
content: content.clone(),
|
|
server_version: sv,
|
|
}))
|
|
})
|
|
}
|
|
_ => server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::Internal.into(),
|
|
message: format!(
|
|
"Buffer loaded but content or sync clock unavailable for file {file_id:?}"
|
|
),
|
|
}),
|
|
};
|
|
me.send_server_message(
|
|
Some(req.connection_id),
|
|
Some(&req.request_id),
|
|
message,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
GlobalBufferModelEvent::ServerLocalBufferUpdated {
|
|
file_id,
|
|
edits,
|
|
new_server_version,
|
|
expected_client_version,
|
|
} => {
|
|
// Push incremental edits to all connections that have this buffer open,
|
|
// except connections with a pending OpenBuffer request (they will
|
|
// receive the content via OpenBufferResponse instead).
|
|
let Some(conns) = me.buffers.connections_for_buffer(file_id) else {
|
|
return;
|
|
};
|
|
let excluded =
|
|
me.buffers.pending_connections_for_open_buffer(file_id);
|
|
// Find the path for this file_id.
|
|
let path = me.buffers.path_for_file_id(*file_id).unwrap_or_default();
|
|
|
|
let proto_edits: Vec<TextEdit> = edits
|
|
.iter()
|
|
.map(|edit| TextEdit {
|
|
start_offset: edit.start.as_usize() as u64,
|
|
end_offset: edit.end.as_usize() as u64,
|
|
text: edit.text.clone(),
|
|
})
|
|
.collect();
|
|
|
|
// Collect to break the immutable borrow on `me.buffers`
|
|
// before calling `me.send_server_message(&mut self)`.
|
|
let conns: Vec<_> = conns.iter().copied().collect();
|
|
for conn_id in conns {
|
|
if excluded.contains(&conn_id) {
|
|
continue;
|
|
}
|
|
me.send_server_message(
|
|
Some(conn_id),
|
|
None,
|
|
server_message::Message::BufferUpdated(BufferUpdatedPush {
|
|
path: path.clone(),
|
|
new_server_version: new_server_version.as_u64(),
|
|
expected_client_version: expected_client_version.as_u64(),
|
|
edits: proto_edits.clone(),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
GlobalBufferModelEvent::FileSaved { file_id, .. } => {
|
|
for req in me.buffers.take_pending_by_kind(
|
|
file_id,
|
|
PendingBufferRequestKind::SaveBuffer,
|
|
) {
|
|
me.send_server_message(
|
|
Some(req.connection_id),
|
|
Some(&req.request_id),
|
|
server_message::Message::SaveBufferResponse(SaveBufferResponse {
|
|
result: Some(save_buffer_response::Result::Success(
|
|
SaveBufferSuccess {},
|
|
)),
|
|
}),
|
|
);
|
|
}
|
|
for req in me.buffers.take_pending_by_kind(
|
|
file_id,
|
|
PendingBufferRequestKind::ResolveConflict,
|
|
) {
|
|
me.send_server_message(
|
|
Some(req.connection_id),
|
|
Some(&req.request_id),
|
|
server_message::Message::ResolveConflictResponse(
|
|
ResolveConflictResponse {
|
|
result: Some(
|
|
resolve_conflict_response::Result::Success(
|
|
ResolveConflictSuccess {},
|
|
),
|
|
),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
GlobalBufferModelEvent::FailedToSave { file_id, error } => {
|
|
for req in me.buffers.take_pending_by_kind(
|
|
file_id,
|
|
PendingBufferRequestKind::SaveBuffer,
|
|
) {
|
|
me.send_server_message(
|
|
Some(req.connection_id),
|
|
Some(&req.request_id),
|
|
server_message::Message::SaveBufferResponse(SaveBufferResponse {
|
|
result: Some(save_buffer_response::Result::Error(
|
|
FileOperationError {
|
|
message: format!("{error}"),
|
|
},
|
|
)),
|
|
}),
|
|
);
|
|
}
|
|
for req in me.buffers.take_pending_by_kind(
|
|
file_id,
|
|
PendingBufferRequestKind::ResolveConflict,
|
|
) {
|
|
me.send_server_message(
|
|
Some(req.connection_id),
|
|
Some(&req.request_id),
|
|
server_message::Message::ResolveConflictResponse(
|
|
ResolveConflictResponse {
|
|
result: Some(resolve_conflict_response::Result::Error(
|
|
FileOperationError {
|
|
message: format!("{error}"),
|
|
},
|
|
)),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
GlobalBufferModelEvent::FailedToLoad { file_id, error } => {
|
|
for req in me.buffers.take_pending_by_kind(
|
|
file_id,
|
|
PendingBufferRequestKind::OpenBuffer,
|
|
) {
|
|
me.send_server_message(
|
|
Some(req.connection_id),
|
|
Some(&req.request_id),
|
|
server_message::Message::OpenBufferResponse(OpenBufferResponse{
|
|
result: Some(remote_server::proto::open_buffer_response::Result::Error(FileOperationError {
|
|
message: format!("Failed to load buffer: {error}"),
|
|
}))
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
GlobalBufferModelEvent::BufferUpdatedFromFileEvent {
|
|
file_id,
|
|
success,
|
|
..
|
|
} => {
|
|
// When a file-watcher update couldn't be applied because
|
|
// the buffer has unsaved client edits, forward the conflict
|
|
// to connected clients so they can show a resolution banner.
|
|
if !success {
|
|
if let Some(conns) = me.buffers.connections_for_buffer(file_id) {
|
|
// Collect to break the immutable borrow on `me.buffers`
|
|
// before calling `me.send_server_message(&mut self)`.
|
|
let conns: Vec<_> = conns.iter().copied().collect();
|
|
let path = me.buffers.path_for_file_id(*file_id).unwrap_or_default();
|
|
for conn_id in conns {
|
|
me.send_server_message(
|
|
Some(conn_id),
|
|
None,
|
|
server_message::Message::BufferConflictDetected(
|
|
super::proto::BufferConflictDetected {
|
|
path: path.clone(),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
GlobalBufferModelEvent::RemoteBufferConflict { .. } => {
|
|
// Not relevant for server-local buffers.
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let skill_manager = SkillManager::handle(ctx);
|
|
ctx.subscribe_to_model(&skill_manager, |me, _, event, ctx| match event {
|
|
SkillManagerEvent::HomeSkillsChanged => {
|
|
me.refresh_remote_agent_context_snapshot(ctx);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let project_context = ProjectContextModel::handle(ctx);
|
|
ctx.subscribe_to_model(&project_context, |me, _, event, ctx| match event {
|
|
ProjectContextModelEvent::GlobalRulesChanged(_) => {
|
|
me.refresh_remote_agent_context_snapshot(ctx);
|
|
}
|
|
ProjectContextModelEvent::PathIndexed
|
|
| ProjectContextModelEvent::KnownRulesChanged(_) => {}
|
|
});
|
|
}
|
|
// Subscribe to diff state manager events — convert domain dispatches
|
|
// to proto messages and send them to connected clients.
|
|
{
|
|
let diff_states = model.diff_states.clone();
|
|
ctx.subscribe_to_model(&diff_states, |me, _, dispatch, _ctx| {
|
|
me.handle_diff_state_update(dispatch);
|
|
});
|
|
}
|
|
// Parse the bundled skill catalog from the global install location.
|
|
// Parsing never blocks the initialize handshake: connections that
|
|
// initialize before parsing completes receive the catalog via the
|
|
// completion broadcast instead. Deliberately not feature-flag gated:
|
|
// the flag controls exposure on the client (catalog storage and
|
|
// skill selection), where the connecting user's flag state actually
|
|
// lives — a headless daemon only sees its own channel defaults.
|
|
if let Some(resources_dir) = daemon_bundled_resources_dir() {
|
|
ctx.spawn(
|
|
BundledSkill::detect_in_resources_dir(resources_dir),
|
|
|me, catalog, ctx| {
|
|
let skills = bundled_skill_snapshot_protos(&catalog);
|
|
log::info!("Daemon parsed {} bundled skills", skills.len());
|
|
me.bundled_skills = skills;
|
|
me.refresh_remote_agent_context_snapshot(ctx);
|
|
},
|
|
);
|
|
} else {
|
|
log::info!(
|
|
"Daemon found no global bundled resources directory; \
|
|
bundled skills unavailable on this host"
|
|
);
|
|
}
|
|
// Start the grace timer immediately so the daemon exits if no proxy
|
|
// connects within GRACE_PERIOD. In practice the spawning proxy connects
|
|
// within milliseconds, so the risk of premature shutdown is negligible;
|
|
// register_connection will cancel the timer the moment the first proxy
|
|
// arrives.
|
|
model.start_grace_timer(ctx);
|
|
model
|
|
}
|
|
|
|
fn refresh_remote_agent_context_snapshot(&mut self, ctx: &warpui::AppContext) {
|
|
let revision = self
|
|
.remote_agent_context_snapshot
|
|
.revision
|
|
.saturating_add(1);
|
|
self.remote_agent_context_snapshot =
|
|
remote_agent_context_snapshot(revision, &self.bundled_skills, ctx);
|
|
self.broadcast_remote_agent_context_snapshot();
|
|
}
|
|
|
|
fn broadcast_remote_agent_context_snapshot(&mut self) {
|
|
self.send_server_message(
|
|
None,
|
|
None,
|
|
server_message::Message::RemoteAgentContextSnapshot(
|
|
self.remote_agent_context_snapshot.clone(),
|
|
),
|
|
);
|
|
self.remote_agent_context_snapshot_sent
|
|
.extend(self.connection_senders.keys().copied());
|
|
}
|
|
|
|
fn send_remote_agent_context_snapshot_to_connection(&mut self, conn_id: ConnectionId) {
|
|
if self.remote_agent_context_snapshot_sent.contains(&conn_id) {
|
|
return;
|
|
}
|
|
self.send_server_message(
|
|
Some(conn_id),
|
|
None,
|
|
server_message::Message::RemoteAgentContextSnapshot(
|
|
self.remote_agent_context_snapshot.clone(),
|
|
),
|
|
);
|
|
self.remote_agent_context_snapshot_sent.insert(conn_id);
|
|
}
|
|
|
|
/// Called when a proxy connects. Inserts `conn_tx` into the connection
|
|
/// map so `send_server_message` can route responses to this proxy, and
|
|
/// cancels the grace timer if it was running.
|
|
pub fn register_connection(
|
|
&mut self,
|
|
conn_id: ConnectionId,
|
|
conn_tx: async_channel::Sender<ServerMessage>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
log::info!(
|
|
"Daemon: connection {conn_id} registered — {} active, host_id={}",
|
|
self.connection_senders.len() + 1,
|
|
self.host_id
|
|
);
|
|
if let Some(handle) = self.grace_timer_cancel.take() {
|
|
handle.abort();
|
|
}
|
|
self.connection_senders.insert(conn_id, conn_tx);
|
|
self.snapshot_sent_roots_by_connection
|
|
.insert(conn_id, HashSet::new());
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Called when a proxy disconnects. Removes it from the connection map
|
|
/// and starts the grace timer if no connections remain.
|
|
pub fn deregister_connection(&mut self, conn_id: ConnectionId, ctx: &mut ModelContext<Self>) {
|
|
self.snapshot_sent_roots_by_connection.remove(&conn_id);
|
|
self.remote_agent_context_snapshot_sent.remove(&conn_id);
|
|
// Guard against double-deregister (reader and writer tasks both call
|
|
// this on connection close; the second call must be a safe no-op).
|
|
if self.connection_senders.remove(&conn_id).is_none() {
|
|
return;
|
|
}
|
|
|
|
// Host-scoped in-flight requests that were sent through the dead
|
|
// connection are NOT eagerly reassigned here. Instead,
|
|
// `send_server_message` handles failover at delivery time: when it
|
|
// finds the target connection is gone, it picks any other open
|
|
// connection. If no connections remain at delivery time, the
|
|
// response is dropped (logged). If no connections remain NOW and
|
|
// there are in-progress handlers, abort them so they don't run
|
|
// to completion pointlessly.
|
|
if self.connection_senders.is_empty() {
|
|
let orphaned: Vec<RequestId> = self.host_scoped_requests.keys().cloned().collect();
|
|
for rid in orphaned {
|
|
self.host_scoped_requests.remove(&rid);
|
|
if let Some(handle) = self.in_progress.remove(&rid) {
|
|
log::warn!("Daemon: no connections remain, aborting host-scoped request {rid}");
|
|
handle.abort();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove this connection from all buffer connection sets.
|
|
// Orphaned buffers (no connections left) are deallocated automatically.
|
|
self.buffers.remove_connection(conn_id, ctx);
|
|
|
|
// Remove this connection from diff state subscriptions.
|
|
// Orphaned models (no subscribers) are dropped automatically.
|
|
self.diff_states
|
|
.update(ctx, |mgr, _| mgr.remove_connection(conn_id));
|
|
|
|
// Drop this connection's git-status / GitHub-info subscription. The
|
|
// per-repo models are evicted once no connection remains in the repo.
|
|
self.unsubscribe_git_status(conn_id);
|
|
|
|
let remaining = self.connection_senders.len();
|
|
log::info!("Daemon: connection {conn_id} deregistered — {remaining} active remaining");
|
|
if remaining == 0 {
|
|
log::info!("Daemon: grace timer started ({GRACE_PERIOD:?})");
|
|
self.start_grace_timer(ctx);
|
|
}
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Starts (or restarts) a timer that shuts the daemon down after
|
|
/// [`GRACE_PERIOD`] with no connected proxies. If a timer is already
|
|
/// running its abort handle is cancelled before the new one is stored.
|
|
/// When a proxy connects, `register_connection` aborts the handle,
|
|
/// preventing the shutdown.
|
|
fn start_grace_timer(&mut self, ctx: &mut ModelContext<Self>) {
|
|
if let Some(handle) = self.grace_timer_cancel.take() {
|
|
handle.abort();
|
|
}
|
|
let handle = ctx.spawn_abortable(
|
|
async_io::Timer::after(GRACE_PERIOD),
|
|
|_, _, ctx| {
|
|
log::info!("Daemon: grace period expired, shutting down");
|
|
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
|
},
|
|
|_, _| {
|
|
log::debug!("Daemon: grace timer cancelled");
|
|
},
|
|
);
|
|
self.grace_timer_cancel = Some(handle);
|
|
}
|
|
|
|
/// Called by the background stdin reader task via `ModelSpawner`.
|
|
///
|
|
/// Dispatches on the `oneof message` variant. Notifications are handled
|
|
/// inline; request-style messages return a `HandlerOutcome` that is
|
|
/// centrally acted on here: `Sync` responses are sent immediately and
|
|
/// `Async` handles are tracked in `in_progress` so they can be aborted.
|
|
pub fn handle_message(
|
|
&mut self,
|
|
conn_id: ConnectionId,
|
|
msg: ClientMessage,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let request_id = RequestId::from(msg.request_id);
|
|
|
|
let (outcome, is_host_scoped) = match msg.message {
|
|
// ── Host-scoped requests (daemon owns failover delivery) ───
|
|
Some(client_message::Message::HostScoped(wrapper)) => {
|
|
let outcome = match wrapper.message {
|
|
Some(host_scoped_request::Message::WriteFile(m)) => {
|
|
self.handle_write_file(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::DeleteFile(m)) => {
|
|
self.handle_delete_file(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::ReadFileContext(m)) => {
|
|
self.handle_read_file_context(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::SaveBuffer(m)) => {
|
|
self.handle_save_buffer(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::ResolveConflict(m)) => {
|
|
self.handle_resolve_conflict(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::DiscardFiles(m)) => {
|
|
self.handle_discard_files(m, &request_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::IndexCodebase(m)) => {
|
|
self.handle_index_codebase(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::DropCodebaseIndex(m)) => {
|
|
self.handle_drop_codebase_index(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::GetFragmentMetadataFromHash(m)) => {
|
|
self.handle_get_fragment_metadata_from_hash(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::GetBranches(m)) => {
|
|
self.handle_get_branches(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::ResyncCodebase(m)) => {
|
|
self.handle_resync_codebase(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::UploadHandoffSnapshot(m)) => {
|
|
self.handle_upload_handoff_snapshot(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::GitCommitChain(m)) => {
|
|
self.handle_git_commit_chain(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::GitPush(m)) => {
|
|
self.handle_git_push(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::GitCreatePr(m)) => {
|
|
self.handle_create_pr(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::GitGenerateCommitMessage(m)) => {
|
|
self.handle_generate_git_commit_message(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::GitGetCommittedBranchFiles(m)) => {
|
|
self.handle_get_committed_branch_files(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(host_scoped_request::Message::RipgrepSearch(m)) => {
|
|
self.handle_ripgrep_search(m, &request_id, conn_id, ctx)
|
|
}
|
|
None => {
|
|
log::warn!(
|
|
"HostScopedRequest with no inner message (request_id={request_id})"
|
|
);
|
|
HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::InvalidRequest.into(),
|
|
message: "HostScopedRequest had no message variant set".to_string(),
|
|
}))
|
|
}
|
|
};
|
|
(outcome, true)
|
|
}
|
|
// ── Session-scoped requests (response tied to originating connection) ───
|
|
Some(client_message::Message::SessionScoped(wrapper)) => {
|
|
let outcome = match wrapper.message {
|
|
Some(session_scoped_request::Message::Initialize(m)) => {
|
|
self.handle_initialize(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(session_scoped_request::Message::NavigatedToDirectory(m)) => {
|
|
self.handle_navigated_to_directory(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(session_scoped_request::Message::LoadRepoMetadataDirectory(m)) => {
|
|
self.handle_load_repo_metadata_directory(m, &request_id, ctx)
|
|
}
|
|
Some(session_scoped_request::Message::RunCommand(m)) => {
|
|
self.handle_run_command(m, &request_id, conn_id, ctx)
|
|
}
|
|
// Subscription-establishing ops: their per-connection
|
|
// subscription state is bound to this connection, so the
|
|
// response (and later pushes) must stay on it — never
|
|
// failed over to a sibling.
|
|
Some(session_scoped_request::Message::OpenBuffer(m)) => {
|
|
self.handle_open_buffer(m, &request_id, conn_id, ctx)
|
|
}
|
|
Some(session_scoped_request::Message::GetDiffState(m)) => {
|
|
self.handle_get_diff_state(m, &request_id, conn_id, ctx)
|
|
}
|
|
None => {
|
|
log::warn!(
|
|
"SessionScopedRequest with no inner message (request_id={request_id})"
|
|
);
|
|
HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::InvalidRequest.into(),
|
|
message: "SessionScopedRequest had no message variant set".to_string(),
|
|
}))
|
|
}
|
|
};
|
|
(outcome, false)
|
|
}
|
|
// ── Notifications (fire-and-forget) ───
|
|
Some(client_message::Message::Notification(wrapper)) => {
|
|
match wrapper.message {
|
|
Some(notification::Message::Abort(m)) => {
|
|
self.handle_abort(m, &request_id, ctx);
|
|
}
|
|
Some(notification::Message::Authenticate(m)) => {
|
|
self.handle_authenticate(m);
|
|
}
|
|
Some(notification::Message::UpdatePreferences(m)) => {
|
|
self.handle_update_preferences(m, ctx);
|
|
}
|
|
Some(notification::Message::SessionBootstrapped(m)) => {
|
|
self.handle_session_bootstrapped(m);
|
|
}
|
|
Some(notification::Message::BufferEdit(m)) => {
|
|
self.handle_buffer_edit(m, ctx);
|
|
}
|
|
Some(notification::Message::CloseBuffer(m)) => {
|
|
self.handle_close_buffer(m, conn_id, ctx);
|
|
}
|
|
Some(notification::Message::UnsubscribeDiffState(m)) => {
|
|
self.handle_unsubscribe_diff_state(m, conn_id, ctx);
|
|
}
|
|
Some(notification::Message::UpdateGitStatus(m)) => {
|
|
self.handle_update_git_status(m, conn_id, ctx);
|
|
}
|
|
Some(notification::Message::UpdateGithubPrInfo(m)) => {
|
|
self.handle_update_github_pr_info(m, ctx);
|
|
}
|
|
Some(notification::Message::UpdateGithubRepoInfo(m)) => {
|
|
self.handle_update_github_repo_info(m, ctx);
|
|
}
|
|
None => {
|
|
log::warn!("Notification with no inner message (request_id={request_id})");
|
|
}
|
|
}
|
|
return; // Notifications never produce a response.
|
|
}
|
|
None => {
|
|
log::warn!(
|
|
"Received ClientMessage with no message variant (request_id={request_id})"
|
|
);
|
|
(
|
|
HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::InvalidRequest.into(),
|
|
message: "ClientMessage had no message variant set".to_string(),
|
|
})),
|
|
false,
|
|
)
|
|
}
|
|
};
|
|
|
|
// Track host-scoped requests for failover delivery.
|
|
if is_host_scoped && !request_id.is_empty() {
|
|
self.host_scoped_requests
|
|
.insert(request_id.clone(), conn_id);
|
|
}
|
|
|
|
match outcome {
|
|
HandlerOutcome::Sync(server_message::Message::InitializeResponse(response)) => {
|
|
self.send_server_message(
|
|
Some(conn_id),
|
|
Some(&request_id),
|
|
server_message::Message::InitializeResponse(response),
|
|
);
|
|
self.push_codebase_index_statuses_snapshot(conn_id, ctx);
|
|
}
|
|
HandlerOutcome::Sync(message) => {
|
|
self.send_server_message(Some(conn_id), Some(&request_id), message);
|
|
}
|
|
HandlerOutcome::Async(Some(handle)) => {
|
|
self.in_progress.insert(request_id, handle);
|
|
}
|
|
HandlerOutcome::Async(None) => {
|
|
// Async work tracked elsewhere (e.g. `pending_file_ops`);
|
|
// the response will be sent via an event subscription.
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_codebase_index_manager_event(
|
|
&mut self,
|
|
event: &CodebaseIndexManagerEvent,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
if !FeatureFlag::RemoteCodebaseIndexing.is_enabled() {
|
|
return;
|
|
}
|
|
|
|
match event {
|
|
CodebaseIndexManagerEvent::SyncStateUpdated { root_path }
|
|
| CodebaseIndexManagerEvent::NewIndexCreated { root_path } => {
|
|
self.push_codebase_index_status(root_path, ctx);
|
|
}
|
|
CodebaseIndexManagerEvent::RemoveExpiredIndexMetadata { expired_metadata } => {
|
|
for repo_path in expired_metadata.iter() {
|
|
self.push_codebase_index_status_update(disabled_codebase_index_status(
|
|
repo_path.to_string_lossy().to_string(),
|
|
));
|
|
}
|
|
}
|
|
CodebaseIndexManagerEvent::RetrievalRequestCompleted { .. }
|
|
| CodebaseIndexManagerEvent::RetrievalRequestFailed { .. }
|
|
| CodebaseIndexManagerEvent::IndexMetadataUpdated { .. } => {}
|
|
}
|
|
}
|
|
fn push_codebase_index_status(&mut self, repo_path: &Path, ctx: &mut ModelContext<Self>) {
|
|
let Some(status) = self.codebase_index_status(repo_path, ctx) else {
|
|
return;
|
|
};
|
|
self.push_codebase_index_status_update(status);
|
|
}
|
|
|
|
fn push_codebase_index_status_update(&mut self, status: CodebaseIndexStatus) {
|
|
self.send_server_message(
|
|
None,
|
|
None,
|
|
server_message::Message::CodebaseIndexStatusUpdated(CodebaseIndexStatusUpdated {
|
|
status: Some(status),
|
|
}),
|
|
);
|
|
}
|
|
|
|
fn push_codebase_index_statuses_snapshot(
|
|
&mut self,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
if !FeatureFlag::RemoteCodebaseIndexing.is_enabled() {
|
|
log::info!(
|
|
"[Remote codebase indexing] Daemon skipping bootstrap codebase index statuses snapshot because remote indexing is disabled: conn_id={conn_id}"
|
|
);
|
|
return;
|
|
}
|
|
let snapshot = self.codebase_index_statuses_snapshot(ctx);
|
|
let status_count = snapshot.statuses.len();
|
|
log::debug!(
|
|
"[Remote codebase indexing] Daemon pushing bootstrap codebase index statuses snapshot: conn_id={conn_id} bootstrap_status_count={status_count}"
|
|
);
|
|
self.send_server_message(
|
|
Some(conn_id),
|
|
None,
|
|
server_message::Message::CodebaseIndexStatusesSnapshot(snapshot),
|
|
);
|
|
}
|
|
fn codebase_index_statuses_snapshot(
|
|
&self,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> CodebaseIndexStatusesSnapshot {
|
|
let index_manager = CodebaseIndexManager::handle(ctx);
|
|
let statuses = index_manager
|
|
.as_ref(ctx)
|
|
.get_codebase_index_statuses(ctx)
|
|
.map(|(repo_path, status)| codebase_index_status_to_proto(repo_path.as_path(), &status))
|
|
.collect();
|
|
CodebaseIndexStatusesSnapshot { statuses }
|
|
}
|
|
|
|
fn codebase_index_status(
|
|
&self,
|
|
repo_path: &Path,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Option<CodebaseIndexStatus> {
|
|
let index_manager = CodebaseIndexManager::handle(ctx);
|
|
index_manager
|
|
.as_ref(ctx)
|
|
.get_codebase_index_status_for_path(repo_path, ctx)
|
|
.map(|status| codebase_index_status_to_proto(repo_path, &status))
|
|
}
|
|
|
|
fn handle_index_codebase(
|
|
&mut self,
|
|
msg: IndexCodebase,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
let IndexCodebase {
|
|
repo_path,
|
|
auth_token,
|
|
} = msg;
|
|
let request = match self.prepare_codebase_index_request(
|
|
CodebaseIndexRequestParams {
|
|
operation_name: "IndexCodebase",
|
|
repo_path,
|
|
auth_token,
|
|
auth_operation: "remote codebase indexing",
|
|
path_kind: CodebaseIndexRequestPathKind::Canonicalized,
|
|
},
|
|
request_id,
|
|
conn_id,
|
|
) {
|
|
Ok(request) => request,
|
|
Err(outcome) => return *outcome,
|
|
};
|
|
let repo_path = request.repo_path;
|
|
let status = CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| {
|
|
manager.with_indexed_codebase(
|
|
&repo_path,
|
|
|manager, indexed_repo_path, ctx| {
|
|
Self::current_codebase_index_status_or_queued(manager, indexed_repo_path, ctx)
|
|
},
|
|
|manager, repo_path, ctx| {
|
|
if !manager.is_indexing_enabled() {
|
|
log::info!(
|
|
"[Remote codebase indexing] Daemon cannot start IndexCodebase because indexing is disabled: repo_path={}",
|
|
repo_path.display()
|
|
);
|
|
not_enabled_codebase_index_status(repo_path.to_string_lossy().to_string())
|
|
} else if !manager.can_create_new_indices() {
|
|
let failure_message = "Cannot index remote codebase because the maximum number of codebase indexes has been reached.".to_string();
|
|
log::warn!(
|
|
"[Remote codebase indexing] Daemon cannot start IndexCodebase: repo_path={} reason={failure_message}",
|
|
repo_path.display()
|
|
);
|
|
unavailable_codebase_index_status(
|
|
repo_path.to_string_lossy().to_string(),
|
|
failure_message,
|
|
)
|
|
} else if manager.index_directory(repo_path.to_path_buf(), ctx) {
|
|
Self::current_codebase_index_status_or_queued(manager, repo_path, ctx)
|
|
} else {
|
|
let failure_message =
|
|
"Cannot index remote codebase because indexing did not start."
|
|
.to_string();
|
|
log::warn!(
|
|
"[Remote codebase indexing] Daemon cannot start IndexCodebase: repo_path={} reason={failure_message}",
|
|
repo_path.display()
|
|
);
|
|
unavailable_codebase_index_status(
|
|
repo_path.to_string_lossy().to_string(),
|
|
failure_message,
|
|
)
|
|
}
|
|
},
|
|
ctx,
|
|
)
|
|
});
|
|
|
|
HandlerOutcome::Sync(server_message::Message::CodebaseIndexStatusUpdated(
|
|
CodebaseIndexStatusUpdated {
|
|
status: Some(status),
|
|
},
|
|
))
|
|
}
|
|
|
|
fn handle_resync_codebase(
|
|
&mut self,
|
|
msg: ResyncCodebase,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
let ResyncCodebase {
|
|
repo_path,
|
|
auth_token,
|
|
mode,
|
|
} = msg;
|
|
let mode = match CodebaseResyncMode::try_from(mode) {
|
|
Ok(mode) => mode,
|
|
Err(_) => {
|
|
return invalid_request_response(format!("Invalid ResyncCodebase mode: {mode}"));
|
|
}
|
|
};
|
|
let request = match self.prepare_codebase_index_request(
|
|
CodebaseIndexRequestParams {
|
|
operation_name: "ResyncCodebase",
|
|
repo_path,
|
|
auth_token,
|
|
auth_operation: "remote codebase resync",
|
|
path_kind: CodebaseIndexRequestPathKind::Canonicalized,
|
|
},
|
|
request_id,
|
|
conn_id,
|
|
) {
|
|
Ok(request) => request,
|
|
Err(outcome) => return *outcome,
|
|
};
|
|
let repo_path = request.repo_path;
|
|
let status = CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| {
|
|
manager.with_indexed_codebase(
|
|
&repo_path,
|
|
|manager, indexed_repo_path, ctx| {
|
|
match mode {
|
|
CodebaseResyncMode::Full => {
|
|
manager.try_manual_resync_codebase(indexed_repo_path, ctx);
|
|
}
|
|
CodebaseResyncMode::Incremental => {
|
|
if let Err(error) =
|
|
manager.trigger_incremental_sync_for_path(indexed_repo_path, ctx)
|
|
{
|
|
log::warn!(
|
|
"Failed to trigger remote codebase incremental sync: repo_path={} error={error}",
|
|
indexed_repo_path.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Self::current_codebase_index_status_or_queued(manager, indexed_repo_path, ctx)
|
|
},
|
|
|_, repo_path, _| {
|
|
unavailable_codebase_index_status(
|
|
repo_path.to_string_lossy().to_string(),
|
|
"Cannot resync remote codebase because it has not been indexed."
|
|
.to_string(),
|
|
)
|
|
},
|
|
ctx,
|
|
)
|
|
});
|
|
|
|
HandlerOutcome::Sync(server_message::Message::CodebaseIndexStatusUpdated(
|
|
CodebaseIndexStatusUpdated {
|
|
status: Some(status),
|
|
},
|
|
))
|
|
}
|
|
|
|
fn current_codebase_index_status_or_queued(
|
|
manager: &CodebaseIndexManager,
|
|
indexed_repo_path: &Path,
|
|
ctx: &mut ModelContext<CodebaseIndexManager>,
|
|
) -> CodebaseIndexStatus {
|
|
manager
|
|
.get_codebase_index_status_for_path(indexed_repo_path, ctx)
|
|
.map(|status| codebase_index_status_to_proto(indexed_repo_path, &status))
|
|
.unwrap_or_else(|| {
|
|
queued_codebase_index_status(indexed_repo_path.to_string_lossy().to_string())
|
|
})
|
|
}
|
|
|
|
fn handle_drop_codebase_index(
|
|
&mut self,
|
|
msg: DropCodebaseIndex,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
let DropCodebaseIndex {
|
|
repo_path,
|
|
auth_token,
|
|
} = msg;
|
|
let request = match self.prepare_codebase_index_request(
|
|
CodebaseIndexRequestParams {
|
|
operation_name: "DropCodebaseIndex",
|
|
repo_path,
|
|
auth_token,
|
|
auth_operation: "remote codebase index removal",
|
|
path_kind: CodebaseIndexRequestPathKind::Requested,
|
|
},
|
|
request_id,
|
|
conn_id,
|
|
) {
|
|
Ok(request) => request,
|
|
Err(outcome) => return *outcome,
|
|
};
|
|
let CodebaseIndexRequest { repo_path } = request;
|
|
CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| {
|
|
manager.drop_index(repo_path.clone(), ctx);
|
|
});
|
|
|
|
codebase_index_status_response(disabled_codebase_index_status(
|
|
repo_path.to_string_lossy().to_string(),
|
|
))
|
|
}
|
|
|
|
fn handle_get_fragment_metadata_from_hash(
|
|
&self,
|
|
msg: GetFragmentMetadataFromHash,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!(
|
|
"[Remote codebase indexing] Daemon handling GetFragmentMetadataFromHash: \
|
|
request_id={request_id} conn_id={conn_id} repo_path={} root_hash={} hash_count={}",
|
|
msg.repo_path,
|
|
msg.root_hash,
|
|
msg.content_hashes.len()
|
|
);
|
|
|
|
if !FeatureFlag::RemoteCodebaseIndexing.is_enabled() {
|
|
return fragment_metadata_lookup_error_response(
|
|
FragmentMetadataLookupErrorCode::RemoteCodebaseIndexingNotEnabled,
|
|
"Remote codebase indexing is not enabled".to_string(),
|
|
None,
|
|
);
|
|
}
|
|
|
|
let repo_path = match canonicalize_index_repo_path(&msg.repo_path) {
|
|
Ok(repo_path) => repo_path,
|
|
Err(error) => {
|
|
return fragment_metadata_lookup_error_response(
|
|
FragmentMetadataLookupErrorCode::InvalidRepoPath,
|
|
error,
|
|
None,
|
|
);
|
|
}
|
|
};
|
|
let root_hash = match msg.root_hash.parse::<NodeHash>() {
|
|
Ok(root_hash) => root_hash,
|
|
Err(error) => {
|
|
return fragment_metadata_lookup_error_response(
|
|
FragmentMetadataLookupErrorCode::InvalidRootHash,
|
|
format!("Invalid root_hash: {error}"),
|
|
None,
|
|
);
|
|
}
|
|
};
|
|
if let Err(error) = self.validate_fragment_metadata_lookup(&repo_path, &root_hash, ctx) {
|
|
return fragment_metadata_lookup_error_response_from_error(error);
|
|
}
|
|
|
|
let mut valid_hashes = Vec::new();
|
|
let mut missing_hashes = Vec::new();
|
|
for content_hash in msg.content_hashes {
|
|
match content_hash.parse::<ContentHash>() {
|
|
Ok(parsed_hash) => valid_hashes.push((content_hash, parsed_hash)),
|
|
Err(error) => missing_hashes.push(missing_fragment_metadata(
|
|
content_hash,
|
|
format!("Invalid content hash: {error}"),
|
|
)),
|
|
}
|
|
}
|
|
|
|
let content_hashes = valid_hashes
|
|
.iter()
|
|
.map(|(_, hash)| hash.clone())
|
|
.collect::<Vec<_>>();
|
|
let metadata_by_hash = match CodebaseIndexManager::handle(ctx)
|
|
.as_ref(ctx)
|
|
.fragment_metadatas_from_hashes(&repo_path, &root_hash, &content_hashes, ctx)
|
|
{
|
|
Ok(metadata_by_hash) => metadata_by_hash,
|
|
Err(error) => {
|
|
return fragment_metadata_lookup_error_response_from_error(error);
|
|
}
|
|
};
|
|
|
|
let mut fragments = Vec::new();
|
|
for (content_hash_string, content_hash) in valid_hashes {
|
|
match metadata_by_hash.get(&content_hash) {
|
|
Some(metadata) => {
|
|
fragments.extend(
|
|
metadata
|
|
.iter()
|
|
.map(|metadata| fragment_metadata_to_proto(&content_hash, metadata)),
|
|
);
|
|
}
|
|
None => missing_hashes.push(missing_fragment_metadata(
|
|
content_hash_string,
|
|
"No fragment metadata found for content hash".to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
HandlerOutcome::Sync(
|
|
server_message::Message::GetFragmentMetadataFromHashResponse(
|
|
GetFragmentMetadataFromHashResponse {
|
|
result: Some(get_fragment_metadata_from_hash_response::Result::Success(
|
|
GetFragmentMetadataFromHashSuccess {
|
|
fragments,
|
|
missing_hashes,
|
|
},
|
|
)),
|
|
},
|
|
),
|
|
)
|
|
}
|
|
|
|
fn validate_fragment_metadata_lookup(
|
|
&self,
|
|
repo_path: &Path,
|
|
root_hash: &NodeHash,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Result<(), LocalFragmentMetadataLookupError> {
|
|
let Some(status) = CodebaseIndexManager::handle(ctx)
|
|
.as_ref(ctx)
|
|
.get_codebase_index_status_for_path(repo_path, ctx)
|
|
else {
|
|
return Err(LocalFragmentMetadataLookupError::IndexNotFound);
|
|
};
|
|
if !status.has_synced_version() {
|
|
return Err(LocalFragmentMetadataLookupError::IndexNotSynced);
|
|
}
|
|
let Some(current_root_hash) = status.root_hash() else {
|
|
return Err(LocalFragmentMetadataLookupError::IndexNotSynced);
|
|
};
|
|
if current_root_hash != root_hash {
|
|
return Err(LocalFragmentMetadataLookupError::RootHashMismatch {
|
|
requested: root_hash.clone(),
|
|
current: current_root_hash.clone(),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Routes a server message to its destination.
|
|
///
|
|
/// - `conn_id = Some(id)` — sends only to the connection that originated
|
|
/// the request (used for all request/response pairs).
|
|
/// - `conn_id = None` — broadcasts to every connected proxy (used for
|
|
/// server-initiated push notifications such as repo metadata updates).
|
|
///
|
|
/// For host-scoped requests: if the target connection is gone, the
|
|
/// response is delivered through any other open connection. This
|
|
/// handles the case where a session disconnects while a host-scoped
|
|
/// request is still in flight.
|
|
fn send_server_message(
|
|
&mut self,
|
|
conn_id: Option<ConnectionId>,
|
|
request_id: Option<&RequestId>,
|
|
message: server_message::Message,
|
|
) {
|
|
// Sending a response is the terminal step of a host-scoped request,
|
|
// so we drop its failover-tracking entry here. We snapshot whether
|
|
// the request was tracked *before* removing it, because that decides
|
|
// whether the message is eligible for failover delivery below (and
|
|
// the removal would otherwise erase that signal). Push notifications
|
|
// (empty/absent request_id) are never tracked, so this is a no-op for
|
|
// them.
|
|
let is_host_scoped_response = request_id
|
|
.is_some_and(|rid| !rid.is_empty() && self.host_scoped_requests.contains_key(rid));
|
|
if let Some(rid) = request_id {
|
|
self.host_scoped_requests.remove(rid);
|
|
}
|
|
|
|
let msg = ServerMessage {
|
|
request_id: request_id.map(|id| id.clone().into()).unwrap_or_default(),
|
|
message: Some(message),
|
|
};
|
|
if let Some(target) = conn_id {
|
|
if let Some(conn_tx) = self.connection_senders.get(&target) {
|
|
if let Err(e) = conn_tx.try_send(msg.clone()) {
|
|
log::warn!("Daemon: failed to send to conn {target}: {e}");
|
|
if is_host_scoped_response {
|
|
self.send_host_scoped_response_via_alternate_connection(target, msg);
|
|
}
|
|
}
|
|
} else if is_host_scoped_response {
|
|
// Target connection is gone. Deliver the host-scoped
|
|
// response through any other open connection.
|
|
self.send_host_scoped_response_via_alternate_connection(target, msg);
|
|
} else {
|
|
log::debug!("Daemon: no sender for conn {target} (already disconnected)");
|
|
}
|
|
} else {
|
|
// Push notification — broadcast to all connections.
|
|
for (id, conn_tx) in &self.connection_senders {
|
|
if let Err(e) = conn_tx.try_send(msg.clone()) {
|
|
log::warn!("Daemon: failed to send to conn {id}: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Delivers a host-scoped response through a connected proxy other than
|
|
/// `target`. Used when the original connection has disappeared or its
|
|
/// outbound channel rejects the response.
|
|
fn send_host_scoped_response_via_alternate_connection(
|
|
&self,
|
|
target: ConnectionId,
|
|
msg: ServerMessage,
|
|
) {
|
|
for (&alt_id, alt_tx) in &self.connection_senders {
|
|
if alt_id == target {
|
|
continue;
|
|
}
|
|
log::info!(
|
|
"Daemon: failover delivery for request_id={} from conn {target} to conn {alt_id}",
|
|
msg.request_id
|
|
);
|
|
match alt_tx.try_send(msg.clone()) {
|
|
Ok(()) => return,
|
|
Err(e) => {
|
|
log::warn!("Daemon: failover delivery failed to conn {alt_id}: {e}");
|
|
}
|
|
}
|
|
}
|
|
log::warn!(
|
|
"Daemon: cannot deliver host-scoped response for request_id={}, \
|
|
no alternate connections available",
|
|
msg.request_id
|
|
);
|
|
}
|
|
|
|
/// Spawns an abortable future tied to `request_id` and wires up automatic
|
|
/// removal from `in_progress` on completion or abort.
|
|
///
|
|
/// The returned handle is intended to be returned from a handler as
|
|
/// `HandlerOutcome::Async(Some(handle))`; the caller (`handle_message`)
|
|
/// inserts it into `in_progress`.
|
|
fn spawn_request_handler<S, F>(
|
|
&mut self,
|
|
request_id: RequestId,
|
|
future: S,
|
|
on_resolve: F,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> SpawnedFutureHandle
|
|
where
|
|
S: Spawnable,
|
|
<S as Future>::Output: SpawnableOutput,
|
|
F: 'static + FnOnce(&mut Self, <S as Future>::Output, &mut ModelContext<Self>),
|
|
{
|
|
let resolve_id = request_id.clone();
|
|
let abort_id = request_id;
|
|
ctx.spawn_abortable(
|
|
future,
|
|
move |me, output, ctx| {
|
|
me.in_progress.remove(&resolve_id);
|
|
on_resolve(me, output, ctx);
|
|
},
|
|
move |me, _ctx| {
|
|
log::info!("Request cancelled (request_id={abort_id})");
|
|
me.in_progress.remove(&abort_id);
|
|
},
|
|
)
|
|
}
|
|
|
|
/// Handles `Initialize` by returning the server version and host id.
|
|
///
|
|
/// Also configures Sentry crash reporting based on the user's identity and
|
|
/// preferences supplied by the connecting client, and sends the latest
|
|
/// remote Agent Mode context snapshot to the initializing connection.
|
|
#[cfg_attr(not(feature = "crash_reporting"), allow(unused_variables))]
|
|
fn handle_initialize(
|
|
&mut self,
|
|
msg: Initialize,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!("Handling Initialize (request_id={request_id})");
|
|
self.apply_initialize_auth(&msg);
|
|
Self::apply_codebase_index_limits(msg.codebase_index_limits.as_ref(), ctx);
|
|
CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| {
|
|
manager.start_persisted_index_restore(ctx);
|
|
});
|
|
|
|
// Update crash reporting based on client-supplied preferences.
|
|
#[cfg(feature = "crash_reporting")]
|
|
{
|
|
if msg.crash_reporting_enabled {
|
|
self.apply_sentry_user_id(ctx);
|
|
} else {
|
|
crate::crash_reporting::uninit_sentry();
|
|
}
|
|
}
|
|
|
|
// Enqueued on the same channel as the response below, so the client
|
|
// buffers it as a push event during the handshake.
|
|
self.send_remote_agent_context_snapshot_to_connection(conn_id);
|
|
|
|
let server_version = ChannelState::app_version().unwrap_or("").to_string();
|
|
HandlerOutcome::Sync(server_message::Message::InitializeResponse(
|
|
InitializeResponse {
|
|
server_version,
|
|
host_id: self.host_id.clone(),
|
|
},
|
|
))
|
|
}
|
|
|
|
/// Applies the auth token from an `Initialize` message.
|
|
/// Extracted so unit tests can call it without a `ModelContext`.
|
|
fn apply_initialize_auth(&mut self, msg: &Initialize) {
|
|
self.auth_state.apply_remote_server_auth_context(
|
|
msg.auth_token.clone(),
|
|
msg.user_id.clone(),
|
|
msg.user_email.clone(),
|
|
);
|
|
}
|
|
|
|
fn apply_codebase_index_limits(
|
|
limits: Option<&CodebaseIndexLimits>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let Some(limits) = limits else {
|
|
return;
|
|
};
|
|
let max_indices_allowed = limits.max_indices_allowed.map(|limit| limit as usize);
|
|
let max_files_per_repo = usize::try_from(limits.max_files_per_repo).unwrap_or(usize::MAX);
|
|
let embedding_generation_batch_size =
|
|
usize::try_from(limits.embedding_generation_batch_size).unwrap_or(usize::MAX);
|
|
|
|
log::info!(
|
|
"[Remote codebase indexing] Daemon applying codebase index limits: max_indices_allowed={max_indices_allowed:?} max_files_per_repo={max_files_per_repo} embedding_generation_batch_size={embedding_generation_batch_size}"
|
|
);
|
|
CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| {
|
|
manager.update_max_limits(
|
|
max_indices_allowed,
|
|
max_files_per_repo,
|
|
embedding_generation_batch_size,
|
|
ctx,
|
|
);
|
|
});
|
|
}
|
|
|
|
/// Sets the Sentry user identity from the stored `AuthState`.
|
|
/// Called both during `Initialize` and when re-enabling crash reporting
|
|
/// via `UpdatePreferences`.
|
|
#[cfg(feature = "crash_reporting")]
|
|
fn apply_sentry_user_id(&self, ctx: &mut warpui::AppContext) {
|
|
if let Some(user_id) = self.auth_state.user_id() {
|
|
crate::crash_reporting::set_user_id(user_id, self.auth_state.user_email(), ctx);
|
|
}
|
|
}
|
|
|
|
/// Handles `UpdatePreferences` by dynamically enabling or disabling
|
|
/// Sentry crash reporting. This is a notification — no response is sent.
|
|
fn handle_update_preferences(
|
|
&mut self,
|
|
msg: super::proto::UpdatePreferences,
|
|
#[allow(unused_variables)] ctx: &mut ModelContext<Self>,
|
|
) {
|
|
log::info!(
|
|
"Handling UpdatePreferences: crash_reporting_enabled={}",
|
|
msg.crash_reporting_enabled
|
|
);
|
|
Self::apply_codebase_index_limits(msg.codebase_index_limits.as_ref(), ctx);
|
|
#[cfg(feature = "crash_reporting")]
|
|
{
|
|
if msg.crash_reporting_enabled {
|
|
if !crate::crash_reporting::is_initialized() {
|
|
crate::crash_reporting::init(ctx);
|
|
self.apply_sentry_user_id(ctx);
|
|
}
|
|
} else {
|
|
crate::crash_reporting::uninit_sentry();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handles `Authenticate` by replacing the daemon-wide credential.
|
|
/// This is a notification — no response is sent.
|
|
fn handle_authenticate(&mut self, msg: Authenticate) {
|
|
self.auth_state
|
|
.set_remote_server_bearer_token(msg.auth_token);
|
|
}
|
|
|
|
pub fn auth_token(&self) -> Option<String> {
|
|
self.auth_state.get_access_token_ignoring_validity()
|
|
}
|
|
|
|
fn validate_remote_codebase_index_auth(
|
|
&self,
|
|
auth_token: &str,
|
|
operation: &str,
|
|
) -> Result<(), String> {
|
|
if auth_token.is_empty() {
|
|
return Err(format!(
|
|
"Missing authentication credentials for {operation}"
|
|
));
|
|
}
|
|
|
|
match self.auth_token() {
|
|
Some(cached_auth_token) if cached_auth_token == auth_token => Ok(()),
|
|
Some(_) => Err(format!(
|
|
"Authentication credentials for {operation} do not match daemon credentials"
|
|
)),
|
|
None => Err(format!(
|
|
"Missing cached authentication credentials for {operation}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn prepare_codebase_index_request(
|
|
&self,
|
|
params: CodebaseIndexRequestParams<'_>,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
) -> Result<CodebaseIndexRequest, Box<HandlerOutcome>> {
|
|
let CodebaseIndexRequestParams {
|
|
operation_name,
|
|
repo_path,
|
|
auth_token,
|
|
auth_operation,
|
|
path_kind,
|
|
} = params;
|
|
let repo_path_for_log = repo_path.clone();
|
|
if !FeatureFlag::RemoteCodebaseIndexing.is_enabled() {
|
|
log::info!(
|
|
"[Remote codebase indexing] Daemon rejecting {operation_name} because remote indexing is disabled: request_id={request_id} conn_id={conn_id} repo_path={repo_path_for_log}"
|
|
);
|
|
return Err(Box::new(codebase_index_status_response(
|
|
not_enabled_codebase_index_status(repo_path),
|
|
)));
|
|
}
|
|
|
|
let repo_path = match path_kind {
|
|
CodebaseIndexRequestPathKind::Canonicalized => canonicalize_index_repo_path(&repo_path),
|
|
CodebaseIndexRequestPathKind::Requested => requested_repo_path(&repo_path),
|
|
}
|
|
.map_err(|error| Box::new(invalid_request_response(error)))?;
|
|
|
|
if let Err(error) = self.validate_remote_codebase_index_auth(&auth_token, auth_operation) {
|
|
return Err(Box::new(invalid_request_response(error)));
|
|
}
|
|
|
|
log::info!(
|
|
"[Remote codebase indexing] Daemon handling {operation_name}: request_id={request_id} conn_id={conn_id} repo_path={repo_path_for_log}"
|
|
);
|
|
Ok(CodebaseIndexRequest { repo_path })
|
|
}
|
|
|
|
/// Handles `Abort` by cancelling the in-progress request it targets.
|
|
/// Checks `ServerModel`'s own in-progress map first, then delegates to
|
|
/// the diff state manager for content reload requests, and finally checks
|
|
/// queued pending responses.
|
|
/// This is a notification — no response is sent.
|
|
fn handle_abort(&mut self, abort: Abort, request_id: &RequestId, ctx: &mut ModelContext<Self>) {
|
|
let target_id = RequestId::from(abort.request_id_to_abort);
|
|
// Drop any failover-tracking entry for the aborted request so it
|
|
// doesn't leak in `host_scoped_requests` until all connections drop.
|
|
// (A manager-side timeout sends `Abort` while sibling connections may
|
|
// still be alive, so `deregister_connection` won't clean it up.)
|
|
self.host_scoped_requests.remove(&target_id);
|
|
if let Some(handle) = self.in_progress.remove(&target_id) {
|
|
log::info!(
|
|
"Aborting in-progress request (request_id={target_id}, \
|
|
abort_request_id={request_id})"
|
|
);
|
|
handle.abort();
|
|
} else {
|
|
let found = self
|
|
.diff_states
|
|
.update(ctx, |mgr, _| mgr.abort_request(&target_id));
|
|
if !found {
|
|
// Check if the target is a queued pending response
|
|
// (not an in-flight reload).
|
|
let found_pending = self
|
|
.diff_states
|
|
.update(ctx, |mgr, _| mgr.abort_pending_response(&target_id));
|
|
if !found_pending {
|
|
log::info!(
|
|
"Abort for unknown/completed request (request_id={target_id}, \
|
|
abort_request_id={request_id})"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handles `SessionBootstrapped` by creating a `LocalCommandExecutor` for
|
|
/// the session. This is a notification — no response is sent.
|
|
fn handle_session_bootstrapped(&mut self, msg: SessionBootstrapped) {
|
|
let session_id = SessionId::from(msg.session_id);
|
|
log::info!(
|
|
"Handling SessionBootstrapped: session_id={session_id:?}, \
|
|
shell_type={:?}, shell_path={:?}",
|
|
msg.shell_type,
|
|
msg.shell_path,
|
|
);
|
|
|
|
let Some(shell_type) = ShellType::from_name(&msg.shell_type) else {
|
|
safe_error!(
|
|
safe: ("Received unknown shell_type in SessionBootstrapped: shell_type={:?}", msg.shell_type),
|
|
full: ("Received unknown shell_type in SessionBootstrapped: shell_type={:?} session={session_id:?}", msg.shell_type)
|
|
);
|
|
return;
|
|
};
|
|
|
|
let shell_path = msg.shell_path.map(PathBuf::from);
|
|
if shell_path.is_none() {
|
|
log::warn!(
|
|
"SessionBootstrapped for session {session_id:?} had no shell_path; \
|
|
LocalCommandExecutor will fall back to bare shell name",
|
|
);
|
|
}
|
|
let executor = Arc::new(LocalCommandExecutor::new(shell_path, shell_type));
|
|
if self.executors.insert(session_id, executor).is_some() {
|
|
log::warn!(
|
|
"Overwriting existing executor for session {session_id:?} \
|
|
(re-SessionBootstrapped with shell_type={:?})",
|
|
msg.shell_type,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Handles `RunCommand` by delegating to the session's `LocalCommandExecutor`.
|
|
///
|
|
/// On success, returns a `HandlerOutcome::Async` whose task resolves the
|
|
/// request with a `RunCommandResponse`. On validation failure (missing
|
|
/// executor), returns a `HandlerOutcome::Sync` error response.
|
|
fn handle_run_command(
|
|
&mut self,
|
|
req: RunCommandRequest,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
let session_id = SessionId::from(req.session_id);
|
|
log::info!(
|
|
"Handling RunCommand (request_id={request_id}, session_id={session_id:?}): \
|
|
command={:?}, cwd={:?}",
|
|
req.command,
|
|
req.working_directory,
|
|
);
|
|
|
|
let command = req.command;
|
|
let cwd = req.working_directory;
|
|
let env_vars = if req.environment_variables.is_empty() {
|
|
None
|
|
} else {
|
|
Some(req.environment_variables)
|
|
};
|
|
|
|
let Some(executor) = self.executors.get(&session_id).cloned() else {
|
|
safe_error!(
|
|
safe: ("No executor for RunCommand, session was never initialized"),
|
|
full: ("No executor for RunCommand, session was never initialized: session={session_id:?}")
|
|
);
|
|
return HandlerOutcome::Sync(server_message::Message::RunCommandResponse(
|
|
RunCommandResponse {
|
|
result: Some(run_command_response::Result::Error(RunCommandError {
|
|
code: RunCommandErrorCode::SessionNotFound.into(),
|
|
message: format!("No executor for session {session_id:?}"),
|
|
})),
|
|
},
|
|
));
|
|
};
|
|
|
|
// Call `execute_local_command` directly because the
|
|
// `CommandExecutor::execute_command` trait method requires
|
|
// a `&Shell` (version, options, plugins from bootstrap).
|
|
let request_id_for_response = request_id.clone();
|
|
let conn_id_for_response = conn_id;
|
|
let handle = self.spawn_request_handler(
|
|
request_id.clone(),
|
|
async move {
|
|
executor
|
|
.execute_local_command(
|
|
&command,
|
|
cwd.as_deref(),
|
|
env_vars,
|
|
ExecuteCommandOptions::default(),
|
|
)
|
|
.await
|
|
},
|
|
move |me, result, _ctx| {
|
|
let result_oneof = match result {
|
|
Ok(output) => {
|
|
let mut stdout = output.stdout.clone();
|
|
let mut stderr = output.stderr.clone();
|
|
|
|
// Truncate to stay under the wire-level message size
|
|
// limit. Leave headroom for protobuf framing overhead.
|
|
const MAX_OUTPUT_BYTES: usize =
|
|
remote_server::protocol::MAX_MESSAGE_SIZE - 1024;
|
|
let total = stdout.len() + stderr.len();
|
|
if total > MAX_OUTPUT_BYTES {
|
|
log::warn!(
|
|
"RunCommand output too large \
|
|
(request_id={request_id_for_response}): \
|
|
{total} bytes, truncating to {MAX_OUTPUT_BYTES}"
|
|
);
|
|
let ratio = MAX_OUTPUT_BYTES as f64 / total as f64;
|
|
stdout.truncate((stdout.len() as f64 * ratio) as usize);
|
|
stderr.truncate((stderr.len() as f64 * ratio) as usize);
|
|
}
|
|
|
|
log::info!(
|
|
"RunCommand completed (request_id={request_id_for_response}): \
|
|
exit_code={:?}, stdout_len={}, stderr_len={}",
|
|
output.exit_code,
|
|
stdout.len(),
|
|
stderr.len(),
|
|
);
|
|
run_command_response::Result::Success(RunCommandSuccess {
|
|
stdout,
|
|
stderr,
|
|
exit_code: output.exit_code.map(|c| c.value()),
|
|
})
|
|
}
|
|
Err(e) => {
|
|
log::warn!("RunCommand failed (request_id={request_id_for_response}): {e}");
|
|
run_command_response::Result::Error(RunCommandError {
|
|
code: RunCommandErrorCode::ExecutionFailed.into(),
|
|
message: format!("Failed to execute command: {e}"),
|
|
})
|
|
}
|
|
};
|
|
me.send_server_message(
|
|
Some(conn_id_for_response),
|
|
Some(&request_id_for_response),
|
|
server_message::Message::RunCommandResponse(RunCommandResponse {
|
|
result: Some(result_oneof),
|
|
}),
|
|
);
|
|
},
|
|
ctx,
|
|
);
|
|
HandlerOutcome::Async(Some(handle))
|
|
}
|
|
|
|
/// Handles `NavigatedToDirectory` by running git detection first, then
|
|
/// responding. On validation failure returns a `HandlerOutcome::Sync` error;
|
|
/// otherwise spawns a task and returns a `HandlerOutcome::Async(Some(_))`
|
|
/// handle.
|
|
fn handle_navigated_to_directory(
|
|
&mut self,
|
|
msg: NavigatedToDirectory,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!(
|
|
"Handling NavigatedToDirectory path={} (request_id={request_id})",
|
|
msg.path
|
|
);
|
|
|
|
let std_path = match StandardizedPath::from_local_canonicalized(Path::new(&msg.path)) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
log::warn!("Invalid path for NavigatedToDirectory: {e}");
|
|
return HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::InvalidRequest.into(),
|
|
message: format!("Invalid path: {e}"),
|
|
}));
|
|
}
|
|
};
|
|
|
|
// Kick off git detection. The returned future resolves with the git
|
|
// root path (Some) or None if no git repo was found.
|
|
let path_str = msg.path.clone();
|
|
let git_future = DetectedRepositories::handle(ctx).update(ctx, |repos, ctx| {
|
|
repos.detect_possible_local_git_repo(
|
|
&path_str,
|
|
RepoDetectionSource::TerminalNavigation,
|
|
ctx,
|
|
)
|
|
});
|
|
|
|
let request_id_for_response = request_id.clone();
|
|
let conn_id_for_response = conn_id;
|
|
let handle = self.spawn_request_handler(
|
|
request_id.clone(),
|
|
git_future,
|
|
move |me, git_root, ctx| {
|
|
let (indexed_path, is_git) = if let Some(root) = git_root {
|
|
// Git repo found. Full indexing was already triggered by
|
|
// DetectedGitRepo → LocalRepoMetadataModel. The client
|
|
// waits for RepositoryIndexedPush before FetchFileTree.
|
|
let root_str = root.to_string_lossy().to_string();
|
|
log::info!("Git repo detected at {root_str} for path {}", std_path);
|
|
(root_str, true)
|
|
} else {
|
|
// No git repo. Lazy-load the directory for first-level data,
|
|
// then push the snapshot immediately.
|
|
RepoMetadataModel::handle(ctx).update(ctx, |repo_model, ctx| {
|
|
if let Err(e) = repo_model.index_lazy_loaded_path(&std_path, ctx) {
|
|
log::warn!("Failed to lazy-load directory {std_path}: {e}");
|
|
}
|
|
});
|
|
(std_path.to_string(), false)
|
|
};
|
|
|
|
me.send_server_message(
|
|
Some(conn_id_for_response),
|
|
Some(&request_id_for_response),
|
|
server_message::Message::NavigatedToDirectoryResponse(
|
|
NavigatedToDirectoryResponse {
|
|
indexed_path: indexed_path.clone(),
|
|
is_git,
|
|
},
|
|
),
|
|
);
|
|
// After responding, push a snapshot if metadata is available.
|
|
//
|
|
// For git repos this is an opportunistic push for the case
|
|
// where the repo was already indexed and RepositoryUpdated
|
|
// won't fire again (which would otherwise leave the client
|
|
// with only a placeholder root). We skip if a snapshot was
|
|
// already sent for this connection+root.
|
|
//
|
|
// For non-git directories the lazy-loaded tree is always
|
|
// broadcast to all connections.
|
|
if let Ok(root_path) =
|
|
StandardizedPath::from_local_canonicalized(Path::new(&indexed_path))
|
|
{
|
|
if is_git {
|
|
// Navigation is the interest signal: record this
|
|
// connection as subscribed to the repo, ensure the
|
|
// per-repo git-status model exists, and opportunistically
|
|
// push its current value before relying on watcher ticks
|
|
// or explicit get-status notifications.
|
|
me.subscribe_git_status(conn_id_for_response, &root_path);
|
|
me.subscribe_to_git_status_updates(&root_path, ctx);
|
|
me.push_git_status(&root_path, ctx);
|
|
let already_sent = me
|
|
.snapshot_sent_roots_by_connection
|
|
.get(&conn_id_for_response)
|
|
.is_some_and(|roots| roots.contains(&root_path));
|
|
if already_sent {
|
|
log::debug!(
|
|
"Snapshot already sent for repo {indexed_path} \
|
|
to conn {conn_id_for_response}, skipping"
|
|
);
|
|
return;
|
|
}
|
|
} else {
|
|
// Navigated out of any git repo: drop this connection's
|
|
// subscription so the previously-current repo's models
|
|
// are evicted once no connection remains in it.
|
|
me.unsubscribe_git_status(conn_id_for_response);
|
|
}
|
|
|
|
let id = RepositoryIdentifier::local(root_path.clone());
|
|
let repo_model = RepoMetadataModel::handle(ctx);
|
|
if let Some(state) = repo_model.as_ref(ctx).get_repository(&id, ctx) {
|
|
let entries = super::repo_metadata_proto::file_tree_entry_to_snapshot_proto(
|
|
&state.entry,
|
|
);
|
|
let standing_results = repo_model
|
|
.as_ref(ctx)
|
|
.standing_query_results(&id, ctx)
|
|
.map(|results| (&results.as_snapshot_delta()).into());
|
|
// Git snapshots target the requesting connection;
|
|
// non-git snapshots broadcast to all.
|
|
let target = if is_git {
|
|
Some(conn_id_for_response)
|
|
} else {
|
|
None
|
|
};
|
|
me.send_server_message(
|
|
target,
|
|
None,
|
|
server_message::Message::RepoMetadataSnapshot(
|
|
super::proto::RepoMetadataSnapshot {
|
|
repo_path: indexed_path,
|
|
entries,
|
|
sync_complete: true,
|
|
standing_results,
|
|
},
|
|
),
|
|
);
|
|
if is_git {
|
|
if let Some(sent_roots) = me
|
|
.snapshot_sent_roots_by_connection
|
|
.get_mut(&conn_id_for_response)
|
|
{
|
|
sent_roots.insert(root_path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
ctx,
|
|
);
|
|
HandlerOutcome::Async(Some(handle))
|
|
}
|
|
|
|
/// Handles `LoadRepoMetadataDirectory` by loading a subdirectory on the
|
|
/// server's local model and returning the children synchronously.
|
|
fn handle_load_repo_metadata_directory(
|
|
&mut self,
|
|
msg: super::proto::LoadRepoMetadataDirectory,
|
|
request_id: &RequestId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!(
|
|
"Handling LoadRepoMetadataDirectory repo_path={} dir_path={} (request_id={request_id})",
|
|
msg.repo_path,
|
|
msg.dir_path
|
|
);
|
|
|
|
let repo_path = match StandardizedPath::from_local_canonicalized(Path::new(&msg.repo_path))
|
|
{
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
return HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::InvalidRequest.into(),
|
|
message: format!("Invalid repo_path: {e}"),
|
|
}));
|
|
}
|
|
};
|
|
|
|
let dir_path = match StandardizedPath::from_local_canonicalized(Path::new(&msg.dir_path)) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
return HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::InvalidRequest.into(),
|
|
message: format!("Invalid dir_path: {e}"),
|
|
}));
|
|
}
|
|
};
|
|
|
|
// Validate that the directory is a descendant of the repo.
|
|
if !dir_path.starts_with(&repo_path) {
|
|
return HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::InvalidRequest.into(),
|
|
message: format!(
|
|
"dir_path {dir_path} is not a descendant of repo_path {repo_path}"
|
|
),
|
|
}));
|
|
}
|
|
|
|
// Load the directory on the server's local model.
|
|
let load_result = RepoMetadataModel::handle(ctx).update(ctx, |model, ctx| {
|
|
model.load_directory(&repo_path, &dir_path, ctx)
|
|
});
|
|
|
|
if let Err(e) = load_result {
|
|
log::warn!("LoadRepoMetadataDirectory failed: {e}");
|
|
return HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::Internal.into(),
|
|
message: format!("Failed to load directory: {e}"),
|
|
}));
|
|
}
|
|
|
|
// Read back the loaded children and serialize them.
|
|
let id = RepositoryIdentifier::local(repo_path.clone());
|
|
let entries = RepoMetadataModel::handle(ctx)
|
|
.as_ref(ctx)
|
|
.get_repository(&id, ctx)
|
|
.map(|state| {
|
|
super::repo_metadata_proto::file_tree_children_to_proto_entries(
|
|
&state.entry,
|
|
&dir_path,
|
|
)
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
HandlerOutcome::Sync(server_message::Message::LoadRepoMetadataDirectoryResponse(
|
|
super::proto::LoadRepoMetadataDirectoryResponse {
|
|
repo_path: msg.repo_path,
|
|
dir_path: msg.dir_path,
|
|
entries,
|
|
},
|
|
))
|
|
}
|
|
|
|
/// Handles `WriteFile` by registering the path and triggering an async
|
|
/// write via `FileModel`. On a successful dispatch, returns
|
|
/// `HandlerOutcome::Async(None)` — the response is sent later by the
|
|
/// `FileModel` event subscription, and the op is not cancellable via
|
|
/// `Abort`. On failure to dispatch, returns a `HandlerOutcome::Sync`
|
|
/// error response.
|
|
fn handle_write_file(
|
|
&mut self,
|
|
msg: WriteFile,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!(
|
|
"Handling WriteFile path={} (request_id={request_id})",
|
|
msg.path
|
|
);
|
|
let path = Path::new(&msg.path);
|
|
|
|
let (file_id, version) =
|
|
self.pending_file_ops
|
|
.insert(path, request_id.clone(), conn_id, FileOpKind::Write, ctx);
|
|
|
|
let file_model = FileModel::handle(ctx);
|
|
if let Err(err) =
|
|
file_model.update(ctx, |m, ctx| m.save(file_id, msg.content, version, ctx))
|
|
{
|
|
self.pending_file_ops.remove(file_id, ctx);
|
|
return HandlerOutcome::Sync(server_message::Message::WriteFileResponse(
|
|
WriteFileResponse {
|
|
result: Some(write_file_response::Result::Error(FileOperationError {
|
|
message: format!("Failed to initiate write: {err}"),
|
|
})),
|
|
},
|
|
));
|
|
}
|
|
|
|
// Response sent asynchronously via the event subscription.
|
|
HandlerOutcome::Async(None)
|
|
}
|
|
|
|
/// Handles `DeleteFile` by registering the path and triggering an async
|
|
/// delete via `FileModel`. On a successful dispatch, returns
|
|
/// `HandlerOutcome::Async(None)` — the response is sent later by the
|
|
/// `FileModel` event subscription, and the op is not cancellable via
|
|
/// `Abort`. On failure to dispatch, returns a `HandlerOutcome::Sync`
|
|
/// error response.
|
|
fn handle_delete_file(
|
|
&mut self,
|
|
msg: DeleteFile,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!(
|
|
"Handling DeleteFile path={} (request_id={request_id})",
|
|
msg.path
|
|
);
|
|
let path = Path::new(&msg.path);
|
|
|
|
let (file_id, version) = self.pending_file_ops.insert(
|
|
path,
|
|
request_id.clone(),
|
|
conn_id,
|
|
FileOpKind::Delete,
|
|
ctx,
|
|
);
|
|
|
|
let file_model = FileModel::handle(ctx);
|
|
if let Err(err) = file_model.update(ctx, |m, ctx| m.delete(file_id, version, ctx)) {
|
|
self.pending_file_ops.remove(file_id, ctx);
|
|
return HandlerOutcome::Sync(server_message::Message::DeleteFileResponse(
|
|
DeleteFileResponse {
|
|
result: Some(delete_file_response::Result::Error(FileOperationError {
|
|
message: format!("Failed to initiate delete: {err}"),
|
|
})),
|
|
},
|
|
));
|
|
}
|
|
|
|
// Response sent asynchronously via the event subscription.
|
|
HandlerOutcome::Async(None)
|
|
}
|
|
|
|
/// Handles `ReadFileContext` by spawning an async batch file read on the
|
|
/// background executor. Returns `HandlerOutcome::Async` with the spawned
|
|
/// handle so the request can be cancelled via `Abort`.
|
|
fn handle_read_file_context(
|
|
&mut self,
|
|
msg: super::proto::ReadFileContextRequest,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!(
|
|
"Handling ReadFileContext ({} files, request_id={request_id})",
|
|
msg.files.len()
|
|
);
|
|
|
|
let max_file_bytes = msg.max_file_bytes.map(|b| b as usize);
|
|
let max_batch_bytes = msg.max_batch_bytes.map(|b| b as usize);
|
|
let file_locations: Vec<FileLocations> = msg
|
|
.files
|
|
.into_iter()
|
|
.map(|f| FileLocations {
|
|
name: f.path,
|
|
lines: f
|
|
.line_ranges
|
|
.into_iter()
|
|
.map(|r| r.start as usize..r.end as usize)
|
|
.collect(),
|
|
})
|
|
.collect();
|
|
let request_id_for_response = request_id.clone();
|
|
|
|
let handle = self.spawn_request_handler(
|
|
request_id.clone(),
|
|
async move {
|
|
read_local_file_context(
|
|
&file_locations,
|
|
None,
|
|
None,
|
|
max_file_bytes,
|
|
max_batch_bytes,
|
|
)
|
|
.await
|
|
},
|
|
move |me, result: anyhow::Result<ReadFileContextResult>, _ctx| {
|
|
let response = match result {
|
|
Ok(result) => file_context_result_to_proto(result),
|
|
Err(err) => ReadFileContextResponse {
|
|
file_contexts: vec![],
|
|
failed_files: vec![FailedFileRead {
|
|
path: String::new(),
|
|
error: Some(FileOperationError {
|
|
message: format!("{err:#}"),
|
|
}),
|
|
}],
|
|
},
|
|
};
|
|
me.send_server_message(
|
|
Some(conn_id),
|
|
Some(&request_id_for_response),
|
|
server_message::Message::ReadFileContextResponse(response),
|
|
);
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
HandlerOutcome::Async(Some(handle))
|
|
}
|
|
|
|
/// Handles `OpenBuffer` by opening the file via `GlobalBufferModel`.
|
|
/// The response is sent asynchronously when `BufferLoaded` fires.
|
|
///
|
|
/// When `force_reload` is set, the server re-reads the file from disk
|
|
/// even if the buffer is already loaded. This broadcasts a
|
|
/// `BufferUpdatedPush` to other connections and responds with the
|
|
/// fresh content via `OpenBufferResponse`.
|
|
fn handle_open_buffer(
|
|
&mut self,
|
|
msg: OpenBuffer,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!(
|
|
"Handling OpenBuffer path={} force_reload={} (request_id={request_id})",
|
|
msg.path,
|
|
msg.force_reload,
|
|
);
|
|
|
|
// For force_reload on an already-tracked buffer, skip open_server_local
|
|
// to avoid a spurious BufferLoaded event that would consume the pending
|
|
// request before ServerLocalBufferUpdated can use it for exclusion.
|
|
if msg.force_reload {
|
|
if let Some(file_id) = self.buffers.file_id_for_path(&msg.path) {
|
|
self.buffers.add_connection(file_id, conn_id);
|
|
let gbm = GlobalBufferModel::handle(ctx);
|
|
|
|
self.buffers.insert_pending(
|
|
file_id,
|
|
request_id.clone(),
|
|
conn_id,
|
|
PendingBufferRequestKind::OpenBuffer,
|
|
);
|
|
if let Err(e) =
|
|
gbm.update(ctx, |gbm, ctx| gbm.force_reload_server_local(file_id, ctx))
|
|
{
|
|
self.buffers
|
|
.take_pending_by_kind(&file_id, PendingBufferRequestKind::OpenBuffer);
|
|
return HandlerOutcome::Sync(server_message::Message::OpenBufferResponse(
|
|
OpenBufferResponse {
|
|
result: Some(
|
|
remote_server::proto::open_buffer_response::Result::Error(
|
|
FileOperationError { message: e },
|
|
),
|
|
),
|
|
},
|
|
));
|
|
}
|
|
return HandlerOutcome::Async(None);
|
|
}
|
|
// Buffer not yet tracked — fall through to open_server_local below.
|
|
}
|
|
|
|
let path = PathBuf::from(&msg.path);
|
|
let gbm = GlobalBufferModel::handle(ctx);
|
|
let buffer_state = gbm.update(ctx, |gbm, ctx| gbm.open_server_local(path, ctx));
|
|
let file_id = buffer_state.file_id;
|
|
|
|
// Track path → FileId mapping and connection.
|
|
// Retain the strong buffer handle so the model stays alive until
|
|
// all connections close the buffer.
|
|
self.buffers
|
|
.track_open_buffer(msg.path.clone(), file_id, buffer_state.buffer);
|
|
self.buffers.add_connection(file_id, conn_id);
|
|
|
|
if gbm.as_ref(ctx).buffer_loaded(file_id) {
|
|
let Some(content) = gbm.as_ref(ctx).content_for_file(file_id, ctx) else {
|
|
return HandlerOutcome::Sync(server_message::Message::OpenBufferResponse(
|
|
OpenBufferResponse {
|
|
result: Some(remote_server::proto::open_buffer_response::Result::Error(
|
|
FileOperationError {
|
|
message: "Buffer loaded but has no file content".to_string(),
|
|
},
|
|
)),
|
|
},
|
|
));
|
|
};
|
|
let Some(server_version) = gbm
|
|
.as_ref(ctx)
|
|
.sync_clock_for_server_local(file_id)
|
|
.map(|c| c.server_version.as_u64())
|
|
else {
|
|
return HandlerOutcome::Sync(server_message::Message::OpenBufferResponse(
|
|
OpenBufferResponse {
|
|
result: Some(remote_server::proto::open_buffer_response::Result::Error(
|
|
FileOperationError {
|
|
message: "Buffer loaded but has no sync clock".to_string(),
|
|
},
|
|
)),
|
|
},
|
|
));
|
|
};
|
|
return HandlerOutcome::Sync(server_message::Message::OpenBufferResponse(
|
|
OpenBufferResponse {
|
|
result: Some(remote_server::proto::open_buffer_response::Result::Success(
|
|
OpenBufferSuccess {
|
|
content,
|
|
server_version,
|
|
},
|
|
)),
|
|
},
|
|
));
|
|
}
|
|
|
|
// Not yet loaded — stash request info so the GlobalBufferModelEvent
|
|
// subscription can send the response when content arrives.
|
|
self.buffers.insert_pending(
|
|
file_id,
|
|
request_id.clone(),
|
|
conn_id,
|
|
PendingBufferRequestKind::OpenBuffer,
|
|
);
|
|
HandlerOutcome::Async(None)
|
|
}
|
|
|
|
/// Handles `BufferEdit` notification (fire-and-forget).
|
|
/// Delegates to `GlobalBufferModel::apply_client_edit`. On rejection
|
|
/// (stale server version), the edit is silently dropped.
|
|
fn handle_buffer_edit(&mut self, msg: BufferEdit, ctx: &mut ModelContext<Self>) {
|
|
log::info!(
|
|
"Handling BufferEdit path={} expected_sv={} new_cv={} edit_count={}",
|
|
msg.path,
|
|
msg.expected_server_version,
|
|
msg.new_client_version,
|
|
msg.edits.len()
|
|
);
|
|
let Some(file_id) = self.buffers.file_id_for_path(&msg.path) else {
|
|
log::warn!("BufferEdit for unknown buffer: {}", msg.path);
|
|
return;
|
|
};
|
|
|
|
let expected_sv = ContentVersion::from_raw(msg.expected_server_version as usize);
|
|
let new_cv = ContentVersion::from_raw(msg.new_client_version as usize);
|
|
|
|
// Per spec: if the edit is rejected (stale server version),
|
|
// the server silently drops it.
|
|
let accepted = GlobalBufferModel::handle(ctx).update(ctx, |gbm, ctx| {
|
|
gbm.apply_client_edit(file_id, &msg.edits, expected_sv, new_cv, ctx)
|
|
});
|
|
log::info!("BufferEdit result: path={} accepted={accepted}", msg.path);
|
|
}
|
|
|
|
/// Handles `SaveBuffer` by persisting the buffer to disk.
|
|
fn handle_save_buffer(
|
|
&mut self,
|
|
msg: SaveBuffer,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!(
|
|
"Handling SaveBuffer path={} (request_id={request_id})",
|
|
msg.path
|
|
);
|
|
|
|
let Some(file_id) = self.buffers.file_id_for_path(&msg.path) else {
|
|
return HandlerOutcome::Sync(server_message::Message::SaveBufferResponse(
|
|
SaveBufferResponse {
|
|
result: Some(save_buffer_response::Result::Error(FileOperationError {
|
|
message: format!("Buffer not open: {}", msg.path),
|
|
})),
|
|
},
|
|
));
|
|
};
|
|
|
|
let result = GlobalBufferModel::handle(ctx)
|
|
.update(ctx, |gbm, ctx| gbm.save_server_local(file_id, ctx));
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
// Response will come via the FileSaved event subscription.
|
|
// Track the file_id → (request_id, conn_id) so the event
|
|
// handler can correlate.
|
|
self.buffers.insert_pending(
|
|
file_id,
|
|
request_id.clone(),
|
|
conn_id,
|
|
PendingBufferRequestKind::SaveBuffer,
|
|
);
|
|
HandlerOutcome::Async(None)
|
|
}
|
|
Err(err) => HandlerOutcome::Sync(server_message::Message::SaveBufferResponse(
|
|
SaveBufferResponse {
|
|
result: Some(save_buffer_response::Result::Error(FileOperationError {
|
|
message: format!("Failed to save: {err}"),
|
|
})),
|
|
},
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Handles `ResolveConflict` by replacing the server buffer with the
|
|
/// client's content and persisting to disk. Returns an async
|
|
/// `HandlerOutcome` — the response is sent when `FileSaved` or
|
|
/// `FailedToSave` fires.
|
|
fn handle_resolve_conflict(
|
|
&mut self,
|
|
msg: ResolveConflict,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!(
|
|
"Handling ResolveConflict path={} (request_id={request_id})",
|
|
msg.path
|
|
);
|
|
|
|
let Some(file_id) = self.buffers.file_id_for_path(&msg.path) else {
|
|
return HandlerOutcome::Sync(server_message::Message::ResolveConflictResponse(
|
|
ResolveConflictResponse {
|
|
result: Some(resolve_conflict_response::Result::Error(
|
|
FileOperationError {
|
|
message: format!("Buffer not open: {}", msg.path),
|
|
},
|
|
)),
|
|
},
|
|
));
|
|
};
|
|
|
|
let ack_sv = ContentVersion::from_raw(msg.acknowledged_server_version as usize);
|
|
let current_cv = ContentVersion::from_raw(msg.current_client_version as usize);
|
|
let result = GlobalBufferModel::handle(ctx).update(ctx, |gbm, ctx| {
|
|
gbm.resolve_conflict(file_id, ack_sv, current_cv, &msg.client_content, ctx)
|
|
});
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
self.buffers.insert_pending(
|
|
file_id,
|
|
request_id.clone(),
|
|
conn_id,
|
|
PendingBufferRequestKind::ResolveConflict,
|
|
);
|
|
HandlerOutcome::Async(None)
|
|
}
|
|
Err(err) => HandlerOutcome::Sync(server_message::Message::ResolveConflictResponse(
|
|
ResolveConflictResponse {
|
|
result: Some(resolve_conflict_response::Result::Error(
|
|
FileOperationError {
|
|
message: format!("Failed to resolve conflict: {err}"),
|
|
},
|
|
)),
|
|
},
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Handles `CloseBuffer` notification (fire-and-forget).
|
|
/// Removes the connection from the buffer's connection set.
|
|
/// Deallocates the buffer if no connections remain.
|
|
fn handle_close_buffer(
|
|
&mut self,
|
|
msg: CloseBuffer,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
log::info!("Handling CloseBuffer path={} conn={conn_id}", msg.path);
|
|
self.buffers.close_buffer(&msg.path, conn_id, ctx);
|
|
}
|
|
|
|
/// Handles `GetDiffState` — subscribe to a (repo, mode) pair.
|
|
fn handle_get_diff_state(
|
|
&mut self,
|
|
msg: super::proto::GetDiffState,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
// Proto3 message fields are always optional on the wire, so `mode`
|
|
// cannot be made required at the schema level — validate at runtime.
|
|
let Some(mode_proto) = &msg.mode else {
|
|
return HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::InvalidRequest.into(),
|
|
message: "Missing mode in GetDiffState".to_string(),
|
|
}));
|
|
};
|
|
|
|
let std_path = match StandardizedPath::from_local_canonicalized(Path::new(&msg.repo_path)) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
log::warn!("Invalid repo_path for GetDiffState: {e}");
|
|
return HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::InvalidRequest.into(),
|
|
message: format!("Invalid repo_path: {e}"),
|
|
}));
|
|
}
|
|
};
|
|
|
|
let mode: DiffMode = mode_proto.into();
|
|
|
|
log::info!(
|
|
"Handling GetDiffState repo={} mode={mode:?} (request_id={request_id})",
|
|
msg.repo_path,
|
|
);
|
|
|
|
let outcome = self.diff_states.update(ctx, |mgr, ctx| {
|
|
mgr.subscribe(std_path, mode, request_id, conn_id, ctx)
|
|
});
|
|
|
|
match outcome {
|
|
SubscribeOutcome::RespondWithSnapshot {
|
|
key,
|
|
state,
|
|
metadata,
|
|
} => {
|
|
let snapshot = diff_state_proto::build_diff_state_snapshot(
|
|
key.repo_path.as_str(),
|
|
&key.mode,
|
|
metadata.as_ref(),
|
|
&state,
|
|
None,
|
|
);
|
|
HandlerOutcome::Sync(server_message::Message::GetDiffStateResponse(
|
|
GetDiffStateResponse {
|
|
result: Some(get_diff_state_response::Result::Snapshot(snapshot)),
|
|
},
|
|
))
|
|
}
|
|
SubscribeOutcome::Async => HandlerOutcome::Async(None),
|
|
}
|
|
}
|
|
|
|
/// Handles `UnsubscribeDiffState` — notification (fire-and-forget).
|
|
fn handle_unsubscribe_diff_state(
|
|
&mut self,
|
|
msg: super::proto::UnsubscribeDiffState,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let Some(mode_proto) = &msg.mode else {
|
|
log::warn!("UnsubscribeDiffState from conn={conn_id}: missing mode");
|
|
return;
|
|
};
|
|
let Ok(std_path) = StandardizedPath::from_local_canonicalized(Path::new(&msg.repo_path))
|
|
else {
|
|
log::warn!(
|
|
"UnsubscribeDiffState from conn={conn_id}: invalid repo_path={}",
|
|
msg.repo_path
|
|
);
|
|
return;
|
|
};
|
|
|
|
let key = DiffModelKey {
|
|
repo_path: std_path,
|
|
mode: mode_proto.into(),
|
|
};
|
|
|
|
log::info!(
|
|
"Handling UnsubscribeDiffState repo={} mode={:?} conn={conn_id}",
|
|
msg.repo_path,
|
|
key.mode
|
|
);
|
|
|
|
self.diff_states
|
|
.update(ctx, |mgr, _| mgr.unsubscribe_connection(&key, conn_id));
|
|
}
|
|
|
|
/// Converts a domain-level diff state dispatch to proto messages
|
|
/// and sends them to the appropriate connections.
|
|
fn handle_diff_state_update(&mut self, update: &DiffStateUpdate) {
|
|
match update {
|
|
DiffStateUpdate::Snapshot {
|
|
repo_path,
|
|
mode,
|
|
state,
|
|
metadata,
|
|
diffs,
|
|
subscribers,
|
|
} => {
|
|
let snapshot = diff_state_proto::build_diff_state_snapshot(
|
|
repo_path,
|
|
mode,
|
|
metadata.as_ref(),
|
|
state,
|
|
diffs.as_deref(),
|
|
);
|
|
for (conn_id, request_id) in subscribers {
|
|
if let Some(request_id) = request_id {
|
|
self.send_server_message(
|
|
Some(*conn_id),
|
|
Some(request_id),
|
|
server_message::Message::GetDiffStateResponse(GetDiffStateResponse {
|
|
result: Some(get_diff_state_response::Result::Snapshot(
|
|
snapshot.clone(),
|
|
)),
|
|
}),
|
|
);
|
|
} else {
|
|
self.send_server_message(
|
|
Some(*conn_id),
|
|
None,
|
|
server_message::Message::DiffStateSnapshot(snapshot.clone()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
DiffStateUpdate::MetadataUpdate {
|
|
repo_path,
|
|
mode,
|
|
metadata,
|
|
subscribers,
|
|
} => {
|
|
let update = diff_state_proto::build_diff_state_metadata_update(
|
|
repo_path.as_str(),
|
|
mode,
|
|
metadata,
|
|
);
|
|
for conn_id in subscribers {
|
|
self.send_server_message(
|
|
Some(*conn_id),
|
|
None,
|
|
server_message::Message::DiffStateMetadataUpdate(update.clone()),
|
|
);
|
|
}
|
|
}
|
|
DiffStateUpdate::FileDelta {
|
|
repo_path,
|
|
mode,
|
|
path,
|
|
diff,
|
|
metadata,
|
|
subscribers,
|
|
} => {
|
|
let delta = diff_state_proto::build_diff_state_file_delta(
|
|
repo_path.as_str(),
|
|
mode,
|
|
path,
|
|
diff.as_deref(),
|
|
metadata.as_ref(),
|
|
);
|
|
for conn_id in subscribers {
|
|
self.send_server_message(
|
|
Some(*conn_id),
|
|
None,
|
|
server_message::Message::DiffStateFileDelta(delta.clone()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handles `UploadHandoffSnapshot` by gathering the workspace snapshot
|
|
/// from the daemon's local filesystem and uploading it to GCS.
|
|
///
|
|
/// Extracts the `AIClient` and HTTP client from `ServerApiProvider`, then
|
|
/// spawns the async gather+upload pipeline. Returns an
|
|
/// `UploadHandoffSnapshotResponse` with the token on success.
|
|
fn handle_upload_handoff_snapshot(
|
|
&mut self,
|
|
msg: UploadHandoffSnapshot,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!(
|
|
"Handling UploadHandoffSnapshot ({} paths, request_id={request_id})",
|
|
msg.paths.len(),
|
|
);
|
|
|
|
let server_api = ServerApiProvider::handle(ctx);
|
|
let ai_client = server_api.as_ref(ctx).get_ai_client();
|
|
let http = server_api.as_ref(ctx).get_http_client();
|
|
|
|
// Convert proto strings → StandardizedPath at the boundary; invalid
|
|
// entries are logged and dropped.
|
|
let paths: Vec<StandardizedPath> = msg
|
|
.paths
|
|
.into_iter()
|
|
.filter_map(|raw| match StandardizedPath::try_new(&raw) {
|
|
Ok(sp) => Some(sp),
|
|
Err(e) => {
|
|
log::warn!("UploadHandoffSnapshot: skipping invalid path: {e}");
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
let request_id_for_response = request_id.clone();
|
|
|
|
let handle = self.spawn_request_handler(
|
|
request_id.clone(),
|
|
async move {
|
|
super::handoff_snapshot::gather_and_upload_handoff_snapshot(paths, ai_client, &http)
|
|
.await
|
|
},
|
|
move |me, result, _ctx| {
|
|
let response = upload_result_to_proto(result);
|
|
me.send_server_message(
|
|
Some(conn_id),
|
|
Some(&request_id_for_response),
|
|
server_message::Message::UploadHandoffSnapshotResponse(response),
|
|
);
|
|
},
|
|
ctx,
|
|
);
|
|
HandlerOutcome::Async(Some(handle))
|
|
}
|
|
|
|
/// Handles `GetBranches` — request/response.
|
|
///
|
|
/// Runs `get_all_branches` on the remote filesystem and responds with
|
|
/// the branch list.
|
|
fn handle_get_branches(
|
|
&mut self,
|
|
msg: super::proto::GetBranches,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
let repo_path = match StandardizedPath::from_local_canonicalized(Path::new(&msg.repo_path))
|
|
{
|
|
Ok(p) => p.to_local_path_lossy(),
|
|
Err(e) => {
|
|
return HandlerOutcome::Sync(server_message::Message::GetBranchesResponse(
|
|
GetBranchesResponse {
|
|
result: Some(super::proto::get_branches_response::Result::Error(
|
|
GetBranchesError {
|
|
message: format!("Invalid repo_path: {e}"),
|
|
},
|
|
)),
|
|
},
|
|
));
|
|
}
|
|
};
|
|
|
|
let max_branch_count = msg
|
|
.max_branch_count
|
|
.map(|c| (c as usize).min(MAX_BRANCH_COUNT_CAP));
|
|
let include_remotes = msg.include_remotes;
|
|
|
|
log::info!(
|
|
"Handling GetBranches repo={} (request_id={request_id})",
|
|
msg.repo_path,
|
|
);
|
|
|
|
let request_id_for_response = request_id.clone();
|
|
let handle =
|
|
self.spawn_request_handler(
|
|
request_id.clone(),
|
|
async move {
|
|
git::get_all_branches(&repo_path, max_branch_count, include_remotes).await
|
|
},
|
|
move |me, branches_result, _ctx| {
|
|
let message = match branches_result {
|
|
Ok(branches) => {
|
|
server_message::Message::GetBranchesResponse(GetBranchesResponse {
|
|
result: Some(super::proto::get_branches_response::Result::Success(
|
|
GetBranchesSuccess {
|
|
branches: branches
|
|
.into_iter()
|
|
.map(|entry| BranchInfo {
|
|
name: entry.name,
|
|
is_main: entry.is_main,
|
|
})
|
|
.collect(),
|
|
},
|
|
)),
|
|
})
|
|
}
|
|
Err(e) => {
|
|
server_message::Message::GetBranchesResponse(GetBranchesResponse {
|
|
result: Some(super::proto::get_branches_response::Result::Error(
|
|
GetBranchesError {
|
|
message: format!("{e:#}"),
|
|
},
|
|
)),
|
|
})
|
|
}
|
|
};
|
|
me.send_server_message(Some(conn_id), Some(&request_id_for_response), message);
|
|
},
|
|
ctx,
|
|
);
|
|
HandlerOutcome::Async(Some(handle))
|
|
}
|
|
|
|
/// Handles `RipgrepSearch` — request/response backing global search in
|
|
/// remote sessions.
|
|
///
|
|
/// Runs the same ripgrep subprocess used by local global search (the
|
|
/// daemon binary includes the `ripgrep-search` worker subcommand) over
|
|
/// the requested roots and responds with all matches once the search
|
|
/// completes, capped to bound response size. Cancellable via `Abort`
|
|
/// like other async handlers.
|
|
fn handle_ripgrep_search(
|
|
&mut self,
|
|
msg: RipgrepSearchRequest,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!(
|
|
"Handling RipgrepSearch ({} roots, request_id={request_id})",
|
|
msg.roots.len()
|
|
);
|
|
|
|
let params = match ripgrep_search::validate_request(msg) {
|
|
Ok(params) => params,
|
|
Err(message) => {
|
|
return HandlerOutcome::Sync(server_message::Message::RipgrepSearchResponse(
|
|
ripgrep_search::error_response(message),
|
|
));
|
|
}
|
|
};
|
|
|
|
let request_id_for_response = request_id.clone();
|
|
let handle = self.spawn_request_handler(
|
|
request_id.clone(),
|
|
async move { ripgrep_search::run_search(params).await },
|
|
move |me, result, _ctx| {
|
|
let response = ripgrep_search::search_result_to_response(result);
|
|
me.send_server_message(
|
|
Some(conn_id),
|
|
Some(&request_id_for_response),
|
|
server_message::Message::RipgrepSearchResponse(response),
|
|
);
|
|
},
|
|
ctx,
|
|
);
|
|
HandlerOutcome::Async(Some(handle))
|
|
}
|
|
|
|
/// Handles `DiscardFilesRequest` — request/response.
|
|
///
|
|
/// Runs git restore/stash on the remote filesystem for the specified files.
|
|
/// The model's `discard_files` spawns async git operations internally.
|
|
/// On success it reloads diffs, which triggers `NewDiffsComputed` pushes
|
|
/// to subscribed connections. On failure it logs the error.
|
|
///
|
|
/// We respond with success synchronously after delegating to the model,
|
|
/// since `discard_files` does not surface completion status to the caller.
|
|
fn handle_discard_files(
|
|
&mut self,
|
|
msg: super::proto::DiscardFilesRequest,
|
|
request_id: &RequestId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
log::info!(
|
|
"Handling DiscardFiles repo={} files={} (request_id={request_id})",
|
|
msg.repo_path,
|
|
msg.files.len()
|
|
);
|
|
|
|
let std_path = match StandardizedPath::from_local_canonicalized(Path::new(&msg.repo_path)) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
return HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::InvalidRequest.into(),
|
|
message: format!("Invalid repo_path: {e}"),
|
|
}));
|
|
}
|
|
};
|
|
|
|
let Some(mode_proto) = &msg.mode else {
|
|
return HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::InvalidRequest.into(),
|
|
message: "Missing mode in DiscardFiles".to_string(),
|
|
}));
|
|
};
|
|
|
|
let key = DiffModelKey {
|
|
repo_path: std_path,
|
|
mode: mode_proto.into(),
|
|
};
|
|
|
|
let model = self
|
|
.diff_states
|
|
.update(ctx, |mgr, _| mgr.get_model(&key).cloned());
|
|
let Some(model) = model else {
|
|
return HandlerOutcome::Sync(server_message::Message::DiscardFilesResponse(
|
|
DiscardFilesResponse {
|
|
result: Some(discard_files_response::Result::Error(DiscardFilesError {
|
|
message: format!(
|
|
"No active diff state model for repo={} mode={:?}",
|
|
msg.repo_path, key.mode
|
|
),
|
|
})),
|
|
},
|
|
));
|
|
};
|
|
|
|
if msg.files.is_empty() {
|
|
return HandlerOutcome::Sync(server_message::Message::DiscardFilesResponse(
|
|
DiscardFilesResponse {
|
|
result: Some(discard_files_response::Result::Error(DiscardFilesError {
|
|
message: "No files specified in DiscardFilesRequest".to_string(),
|
|
})),
|
|
},
|
|
));
|
|
}
|
|
|
|
let file_infos: Vec<_> = msg
|
|
.files
|
|
.iter()
|
|
.filter_map(|f| match FileStatusInfo::try_from(f) {
|
|
Ok(info) => Some(info),
|
|
Err(e) => {
|
|
log::warn!("DiscardFiles: {e}");
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if file_infos.is_empty() {
|
|
return HandlerOutcome::Sync(server_message::Message::DiscardFilesResponse(
|
|
DiscardFilesResponse {
|
|
result: Some(discard_files_response::Result::Error(DiscardFilesError {
|
|
message: "No valid files after path validation".to_string(),
|
|
})),
|
|
},
|
|
));
|
|
}
|
|
|
|
model.update(ctx, |m, ctx| {
|
|
m.discard_files(file_infos, msg.should_stash, msg.branch_name, ctx);
|
|
});
|
|
|
|
HandlerOutcome::Sync(server_message::Message::DiscardFilesResponse(
|
|
DiscardFilesResponse {
|
|
result: Some(discard_files_response::Result::Success(
|
|
DiscardFilesSuccess {},
|
|
)),
|
|
},
|
|
))
|
|
}
|
|
|
|
/// Handles `GitCommitChainRequest` — runs the commit chain (commit, then
|
|
/// optionally push, then optionally create-PR) on the remote filesystem in
|
|
/// a single round trip, returning the post-chain delta (refreshed unpushed
|
|
/// commits + upstream) and any created PR.
|
|
fn handle_git_commit_chain(
|
|
&mut self,
|
|
msg: GitCommitChainRequest,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
let repo_path = match requested_repo_path(&msg.repo_path) {
|
|
Ok(p) => p,
|
|
Err(e) => return invalid_request_response(e),
|
|
};
|
|
let mode = msg.mode();
|
|
log::info!(
|
|
"Handling CommitChain repo={} mode={mode:?} (request_id={request_id})",
|
|
msg.repo_path
|
|
);
|
|
let message = msg.message;
|
|
let include_unstaged = msg.include_unstaged;
|
|
let branch = msg.branch;
|
|
// The create-PR stage AI-generates the title/body only when the client
|
|
// asked for it. Capture the client on the main thread (ctx isn't
|
|
// available inside the spawned future) and only for the PR mode, so
|
|
// commit-only / commit-and-push never touch the AI path.
|
|
let ai_client = (matches!(mode, GitCommitChainMode::CommitAndCreatePr)
|
|
&& msg.autogenerate_pr_content)
|
|
.then(|| ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client());
|
|
let chain_mode = CommitChainMode::from(mode);
|
|
let path_future = Self::interactive_path_future(ctx);
|
|
let request_id_for_response = request_id.clone();
|
|
let handle = self.spawn_request_handler(
|
|
request_id.clone(),
|
|
async move {
|
|
let path_env = path_future.await;
|
|
let path_env = path_env.as_deref();
|
|
// Daemon-side execution-time guard (the local dialog guards
|
|
// pre-emptively via the blocked-state check); the shared
|
|
// `git_actions` orchestration itself is guard-free.
|
|
if git::git_operation_in_progress(&repo_path) {
|
|
anyhow::bail!(
|
|
"another git operation is in progress (merge, rebase, cherry-pick, or a lock file is present)"
|
|
);
|
|
}
|
|
|
|
git_actions::run_commit_chain(
|
|
&repo_path,
|
|
chain_mode,
|
|
&message,
|
|
include_unstaged,
|
|
&branch,
|
|
ai_client.as_deref(),
|
|
path_env,
|
|
)
|
|
.await
|
|
},
|
|
move |me, result, _ctx| {
|
|
let message = match result {
|
|
Ok((commits, upstream_ref, pr_info)) => {
|
|
server_message::Message::GitCommitChainResponse(GitCommitChainResponse {
|
|
result: Some(git_commit_chain_response::Result::Success(
|
|
GitCommitChainSuccess {
|
|
delta: Some(GitOpDelta {
|
|
unpushed_commits: commits
|
|
.iter()
|
|
.map(super::proto::Commit::from)
|
|
.collect(),
|
|
upstream_ref,
|
|
}),
|
|
pr_info: pr_info.as_ref().map(super::proto::PrInfo::from),
|
|
},
|
|
)),
|
|
})
|
|
}
|
|
Err(e) => {
|
|
server_message::Message::GitCommitChainResponse(GitCommitChainResponse {
|
|
result: Some(git_commit_chain_response::Result::Error(GitOpError {
|
|
message: format!("{e:#}"),
|
|
})),
|
|
})
|
|
}
|
|
};
|
|
me.send_server_message(Some(conn_id), Some(&request_id_for_response), message);
|
|
},
|
|
ctx,
|
|
);
|
|
HandlerOutcome::Async(Some(handle))
|
|
}
|
|
|
|
/// Handles `GitPushRequest` — runs `git push --set-upstream` on the remote
|
|
/// filesystem, then returns the refreshed unpushed/upstream delta.
|
|
fn handle_git_push(
|
|
&mut self,
|
|
msg: GitPushRequest,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
let repo_path = match requested_repo_path(&msg.repo_path) {
|
|
Ok(p) => p,
|
|
Err(e) => return invalid_request_response(e),
|
|
};
|
|
log::info!(
|
|
"Handling Push repo={} branch={} (request_id={request_id})",
|
|
msg.repo_path,
|
|
msg.branch,
|
|
);
|
|
let branch = msg.branch;
|
|
let path_future = Self::interactive_path_future(ctx);
|
|
let request_id_for_response = request_id.clone();
|
|
let handle = self.spawn_request_handler(
|
|
request_id.clone(),
|
|
async move {
|
|
let path_env = path_future.await;
|
|
// Daemon-side execution-time guard; see `handle_git_commit_chain`.
|
|
if git::git_operation_in_progress(&repo_path) {
|
|
anyhow::bail!(
|
|
"another git operation is in progress (merge, rebase, cherry-pick, or a lock file is present)"
|
|
);
|
|
}
|
|
git_actions::run_push(&repo_path, &branch, path_env.as_deref()).await
|
|
},
|
|
move |me, result, _ctx| {
|
|
let message = match result {
|
|
Ok((commits, upstream_ref)) => {
|
|
server_message::Message::GitPushResponse(GitPushResponse {
|
|
result: Some(git_push_response::Result::Success(GitOpDelta {
|
|
unpushed_commits: commits
|
|
.iter()
|
|
.map(super::proto::Commit::from)
|
|
.collect(),
|
|
upstream_ref,
|
|
})),
|
|
})
|
|
}
|
|
Err(e) => server_message::Message::GitPushResponse(GitPushResponse {
|
|
result: Some(git_push_response::Result::Error(GitOpError {
|
|
message: format!("{e:#}"),
|
|
})),
|
|
}),
|
|
};
|
|
me.send_server_message(Some(conn_id), Some(&request_id_for_response), message);
|
|
},
|
|
ctx,
|
|
);
|
|
HandlerOutcome::Async(Some(handle))
|
|
}
|
|
|
|
/// Handles `GitCreatePrRequest` — runs `gh pr create` on the remote
|
|
/// filesystem and returns the created PR info.
|
|
fn handle_create_pr(
|
|
&mut self,
|
|
msg: GitCreatePrRequest,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
let repo_path = match requested_repo_path(&msg.repo_path) {
|
|
Ok(p) => p,
|
|
Err(e) => return invalid_request_response(e),
|
|
};
|
|
log::info!(
|
|
"Handling CreatePr repo={} (request_id={request_id})",
|
|
msg.repo_path
|
|
);
|
|
let branch = msg.branch;
|
|
// Generate the PR title/body via AI only when the client asked for it
|
|
// and didn't already supply them. Reuses the same helper the local
|
|
// dialog uses, so local and remote PRs are produced identically
|
|
// (AI-with-`--fill`-fallback). The daemon's `ServerApiProvider` is
|
|
// authenticated with the user's forwarded bearer token.
|
|
let ai_client = msg
|
|
.autogenerate_content
|
|
.then(|| ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client());
|
|
let path_future = Self::interactive_path_future(ctx);
|
|
let request_id_for_response = request_id.clone();
|
|
let handle = self.spawn_request_handler(
|
|
request_id.clone(),
|
|
async move {
|
|
let path_env = path_future.await;
|
|
if git::git_operation_in_progress(&repo_path) {
|
|
anyhow::bail!(
|
|
"another git operation is in progress (merge, rebase, cherry-pick, or a lock file is present)"
|
|
);
|
|
}
|
|
git_actions::create_pr(&repo_path, &branch, ai_client.as_deref(), path_env.as_deref())
|
|
.await
|
|
},
|
|
move |me, result, _ctx| {
|
|
let message = match result {
|
|
Ok(pr) => server_message::Message::GitCreatePrResponse(GitCreatePrResponse {
|
|
result: Some(git_create_pr_response::Result::Success(
|
|
super::proto::PrInfo::from(&pr),
|
|
)),
|
|
}),
|
|
Err(e) => server_message::Message::GitCreatePrResponse(GitCreatePrResponse {
|
|
result: Some(git_create_pr_response::Result::Error(GitOpError {
|
|
message: format!("{e:#}"),
|
|
})),
|
|
}),
|
|
};
|
|
me.send_server_message(Some(conn_id), Some(&request_id_for_response), message);
|
|
},
|
|
ctx,
|
|
);
|
|
HandlerOutcome::Async(Some(handle))
|
|
}
|
|
|
|
/// Handles `GitGetCommittedBranchFilesRequest` — computes the committed
|
|
/// branch diff (`merge_base(HEAD, main)..HEAD`) on the remote filesystem
|
|
/// and returns the per-file change entries for the Create PR dialog's
|
|
/// Changes box. Committed-only, so it excludes uncommitted/untracked files.
|
|
fn handle_get_committed_branch_files(
|
|
&mut self,
|
|
msg: GitGetCommittedBranchFilesRequest,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
let repo_path = match requested_repo_path(&msg.repo_path) {
|
|
Ok(p) => p,
|
|
Err(e) => return invalid_request_response(e),
|
|
};
|
|
log::info!(
|
|
"Handling GetCommittedBranchFiles repo={} (request_id={request_id})",
|
|
msg.repo_path
|
|
);
|
|
let request_id_for_response = request_id.clone();
|
|
let handle = self.spawn_request_handler(
|
|
request_id.clone(),
|
|
async move { git::get_committed_branch_file_entries(&repo_path).await },
|
|
move |me, result, _ctx| {
|
|
let message = match result {
|
|
Ok(files) => server_message::Message::GitGetCommittedBranchFilesResponse(
|
|
GitGetCommittedBranchFilesResponse {
|
|
result: Some(git_get_committed_branch_files_response::Result::Success(
|
|
GitGetCommittedBranchFilesSuccess {
|
|
files: files
|
|
.iter()
|
|
.map(super::proto::FileChangeEntry::from)
|
|
.collect(),
|
|
},
|
|
)),
|
|
},
|
|
),
|
|
Err(e) => server_message::Message::GitGetCommittedBranchFilesResponse(
|
|
GitGetCommittedBranchFilesResponse {
|
|
result: Some(git_get_committed_branch_files_response::Result::Error(
|
|
GitOpError {
|
|
message: format!("{e:#}"),
|
|
},
|
|
)),
|
|
},
|
|
),
|
|
};
|
|
me.send_server_message(Some(conn_id), Some(&request_id_for_response), message);
|
|
},
|
|
ctx,
|
|
);
|
|
HandlerOutcome::Async(Some(handle))
|
|
}
|
|
|
|
/// Handles `GitGenerateCommitMessageRequest` — computes the working-tree
|
|
/// diff locally, then calls the Warp server's code-review content endpoint
|
|
/// via the daemon's authenticated `AIClient` and returns the generated
|
|
/// message.
|
|
fn handle_generate_git_commit_message(
|
|
&mut self,
|
|
msg: GitGenerateCommitMessageRequest,
|
|
request_id: &RequestId,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> HandlerOutcome {
|
|
let repo_path = match requested_repo_path(&msg.repo_path) {
|
|
Ok(p) => p,
|
|
Err(e) => return invalid_request_response(e),
|
|
};
|
|
log::info!(
|
|
"Handling GenerateCommitMessage repo={} (request_id={request_id})",
|
|
msg.repo_path
|
|
);
|
|
let include_unstaged = msg.include_unstaged;
|
|
let branch_name = msg.branch_name;
|
|
let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client();
|
|
let request_id_for_response = request_id.clone();
|
|
let handle = self.spawn_request_handler(
|
|
request_id.clone(),
|
|
async move {
|
|
git_actions::generate_commit_message(
|
|
&repo_path,
|
|
&branch_name,
|
|
include_unstaged,
|
|
ai_client.as_ref(),
|
|
)
|
|
.await
|
|
},
|
|
move |me, result, _ctx| {
|
|
let message = match result {
|
|
Ok(message) => server_message::Message::GitGenerateCommitMessageResponse(
|
|
GitGenerateCommitMessageResponse {
|
|
result: Some(git_generate_commit_message_response::Result::Message(
|
|
message,
|
|
)),
|
|
},
|
|
),
|
|
Err(e) => server_message::Message::GitGenerateCommitMessageResponse(
|
|
GitGenerateCommitMessageResponse {
|
|
result: Some(git_generate_commit_message_response::Result::Error(
|
|
GitOpError {
|
|
message: format!("{e:#}"),
|
|
},
|
|
)),
|
|
},
|
|
),
|
|
};
|
|
me.send_server_message(Some(conn_id), Some(&request_id_for_response), message);
|
|
},
|
|
ctx,
|
|
);
|
|
HandlerOutcome::Async(Some(handle))
|
|
}
|
|
|
|
/// Subscribes the daemon to per-repo local git status updates. On first
|
|
/// creation it wires model events to broadcast a `GitStatusPush`. No-op if
|
|
/// already subscribed, or when the repo is not yet a watched repository;
|
|
/// the next navigation or explicit snapshot request will try again.
|
|
fn subscribe_to_git_status_updates(
|
|
&mut self,
|
|
repo_path: &StandardizedPath,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
if self.git_status_models.contains_key(repo_path) {
|
|
return;
|
|
}
|
|
let repo = LocalOrRemotePath::Local(repo_path.to_local_path_lossy());
|
|
let handle = match GitRepoModels::handle(ctx)
|
|
.update(ctx, |factory, ctx| factory.subscribe(&repo, ctx))
|
|
{
|
|
Ok(handle) => handle,
|
|
Err(e) => {
|
|
log::warn!("Daemon: git status subscribe failed for {repo_path}: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let path_for_sub = repo_path.clone();
|
|
ctx.subscribe_to_model(&handle, move |me, _, _event, ctx| {
|
|
let proto_metadata = {
|
|
let Some(handle) = me.git_status_models.get(&path_for_sub) else {
|
|
return;
|
|
};
|
|
let Some(metadata) = handle.as_ref(ctx).metadata(ctx) else {
|
|
return;
|
|
};
|
|
metadata.into()
|
|
};
|
|
me.send_server_message(
|
|
None,
|
|
None,
|
|
server_message::Message::GitStatusPush(GitStatusPush {
|
|
repo_path: path_for_sub.to_string(),
|
|
metadata: Some(proto_metadata),
|
|
}),
|
|
);
|
|
});
|
|
|
|
self.git_status_models.insert(repo_path.clone(), handle);
|
|
}
|
|
|
|
/// Subscribe `conn` to `repo`'s git status (navigation in), moving it off
|
|
/// any repo it was previously in. Pure bookkeeping — the caller ensures the
|
|
/// per-repo git-status model exists via `subscribe_to_git_status_updates`.
|
|
fn subscribe_git_status(&mut self, conn: ConnectionId, repo: &StandardizedPath) {
|
|
match self.git_status_repo_by_conn.get(&conn) {
|
|
Some(prev) if prev == repo => return,
|
|
Some(prev) => {
|
|
let prev = prev.clone();
|
|
self.drop_subscription(&prev, conn);
|
|
}
|
|
None => {}
|
|
}
|
|
self.git_status_repo_by_conn.insert(conn, repo.clone());
|
|
self.git_status_subscribers
|
|
.entry(repo.clone())
|
|
.or_default()
|
|
.insert(conn);
|
|
}
|
|
|
|
/// Unsubscribe `conn` from its current repo (navigation out of git, or
|
|
/// disconnect). A connection is in at most one repo, so this single method
|
|
/// also serves as the disconnect sweep.
|
|
fn unsubscribe_git_status(&mut self, conn: ConnectionId) {
|
|
if let Some(repo) = self.git_status_repo_by_conn.remove(&conn) {
|
|
self.drop_subscription(&repo, conn);
|
|
}
|
|
}
|
|
|
|
/// Remove one `(repo, conn)` subscription, evicting the per-repo git-status
|
|
/// and GitHub-info models once the repo has no subscribers left. The local
|
|
/// models' `Drop` impls reclaim the filesystem watcher and the `gh` timer.
|
|
fn drop_subscription(&mut self, repo: &StandardizedPath, conn: ConnectionId) {
|
|
let Some(subscribers) = self.git_status_subscribers.get_mut(repo) else {
|
|
return;
|
|
};
|
|
subscribers.remove(&conn);
|
|
if subscribers.is_empty() {
|
|
self.git_status_subscribers.remove(repo);
|
|
// Drop the GitHub model first so it releases its strong handle to
|
|
// the sibling git-status model, then drop the git-status model.
|
|
self.github_repo_models.remove(repo);
|
|
self.git_status_models.remove(repo);
|
|
}
|
|
}
|
|
|
|
/// Handles `UpdateGitStatus` notification (fire-and-forget).
|
|
fn handle_update_git_status(
|
|
&mut self,
|
|
msg: UpdateGitStatus,
|
|
conn_id: ConnectionId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let std_path = match StandardizedPath::from_local_canonicalized(Path::new(&msg.repo_path)) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
log::warn!("Invalid repo_path for UpdateGitStatus: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
// This notification rides an arbitrary connection for the host, so it
|
|
// says nothing about which repo the connection's session is in.
|
|
// Register only when the connection is untracked, which keeps the requested repo's model
|
|
// alive across reconnect until `NavigatedToDirectory` lands.
|
|
if !self.git_status_repo_by_conn.contains_key(&conn_id) {
|
|
self.subscribe_git_status(conn_id, &std_path);
|
|
self.subscribe_to_git_status_updates(&std_path, ctx);
|
|
}
|
|
self.push_git_status(&std_path, ctx);
|
|
}
|
|
|
|
fn push_git_status(&mut self, repo_path: &StandardizedPath, ctx: &mut ModelContext<Self>) {
|
|
let Some(handle) = self.git_status_models.get(repo_path) else {
|
|
return;
|
|
};
|
|
let Some(metadata) = handle.as_ref(ctx).metadata(ctx) else {
|
|
return;
|
|
};
|
|
let proto_metadata = metadata.into();
|
|
self.send_server_message(
|
|
None,
|
|
None,
|
|
server_message::Message::GitStatusPush(GitStatusPush {
|
|
repo_path: repo_path.to_string(),
|
|
metadata: Some(proto_metadata),
|
|
}),
|
|
);
|
|
}
|
|
|
|
/// Handles the `UpdateGitHubPrInfo` notification (fire-and-forget).
|
|
///
|
|
/// Ensures the per-repo `GitHubRepoModel` exists and refreshes PR info.
|
|
fn handle_update_github_pr_info(
|
|
&mut self,
|
|
msg: UpdateGitHubPrInfo,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let std_path = match StandardizedPath::from_local_canonicalized(Path::new(&msg.repo_path)) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
log::warn!("Invalid repo_path for UpdateGitHubPrInfo: {e}");
|
|
return;
|
|
}
|
|
};
|
|
let already_tracked = self.github_repo_models.contains_key(&std_path);
|
|
self.subscribe_to_github_info_updates(&std_path, ctx);
|
|
if already_tracked {
|
|
if let Some(handle) = self.github_repo_models.get(&std_path).cloned() {
|
|
handle.update(ctx, |model, ctx| model.refresh_pr_info(ctx));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handles the `UpdateGitHubRepoInfo` notification (fire-and-forget).
|
|
///
|
|
/// Ensures the per-repo `GitRepoModel` exists and refreshes repo info.
|
|
fn handle_update_github_repo_info(
|
|
&mut self,
|
|
msg: UpdateGitHubRepoInfo,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let std_path = match StandardizedPath::from_local_canonicalized(Path::new(&msg.repo_path)) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
log::warn!("Invalid repo_path for UpdateGitHubRepoInfo: {e}");
|
|
return;
|
|
}
|
|
};
|
|
let already_tracked = self.github_repo_models.contains_key(&std_path);
|
|
self.subscribe_to_github_info_updates(&std_path, ctx);
|
|
if already_tracked {
|
|
if let Some(handle) = self.github_repo_models.get(&std_path).cloned() {
|
|
handle.update(ctx, |model, ctx| model.refresh_repository_info(ctx));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn push_github_pr_info(&mut self, repo_path: &StandardizedPath, ctx: &mut ModelContext<Self>) {
|
|
let Some(handle) = self.github_repo_models.get(repo_path) else {
|
|
return;
|
|
};
|
|
let pr_info = handle.as_ref(ctx).pr_info(ctx).map(Into::into);
|
|
self.send_server_message(
|
|
None,
|
|
None,
|
|
server_message::Message::GithubPrInfoPush(GitHubPrInfoPush {
|
|
repo_path: repo_path.to_string(),
|
|
pr_info,
|
|
}),
|
|
);
|
|
}
|
|
|
|
fn push_github_repository_info(
|
|
&mut self,
|
|
repo_path: &StandardizedPath,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let Some(handle) = self.github_repo_models.get(repo_path) else {
|
|
return;
|
|
};
|
|
let repository_info = handle.as_ref(ctx).repository_info(ctx).map(Into::into);
|
|
self.send_server_message(
|
|
None,
|
|
None,
|
|
server_message::Message::GithubRepositoryInfoPush(GitHubRepositoryInfoPush {
|
|
repo_path: repo_path.to_string(),
|
|
repository_info,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/// Subscribes the daemon to per-repo local GitHub info updates. On first
|
|
/// creation it wires model events to broadcast separate PR-info and
|
|
/// repository-info pushes. No-op if already subscribed, or when the repo is
|
|
/// not yet a watched repository
|
|
/// (the client requests another snapshot on `HostConnected`).
|
|
fn subscribe_to_github_info_updates(
|
|
&mut self,
|
|
repo_path: &StandardizedPath,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
if self.github_repo_models.contains_key(repo_path) {
|
|
return;
|
|
}
|
|
let repo = LocalOrRemotePath::Local(repo_path.to_local_path_lossy());
|
|
let handle = match GitRepoModels::handle(ctx).update(ctx, |factory, ctx| {
|
|
factory.subscribe_github_repo(&repo, ctx)
|
|
}) {
|
|
Ok(handle) => handle,
|
|
Err(e) => {
|
|
log::warn!("Daemon: github repo subscribe failed for {repo_path}: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let path_for_sub = repo_path.clone();
|
|
ctx.subscribe_to_model(&handle, move |me, _, event, ctx| match event {
|
|
GitHubRepoEvent::PrInfoChanged => me.push_github_pr_info(&path_for_sub, ctx),
|
|
GitHubRepoEvent::RepositoryInfoChanged => {
|
|
me.push_github_repository_info(&path_for_sub, ctx)
|
|
}
|
|
});
|
|
|
|
self.github_repo_models.insert(repo_path.clone(), handle);
|
|
}
|
|
|
|
/// Returns a future resolving to the host's interactive login-shell PATH
|
|
/// (or `None` → the daemon process PATH). Delegates to the singleton
|
|
/// `LocalShellState`, which captures lazily through the host's shell,
|
|
/// caches the result, and dedups concurrent callers — so daemon-run `gh` /
|
|
/// hooks / `git-lfs` resolve the same tooling the user has interactively.
|
|
/// Yields `None` on builds without a local tty.
|
|
fn interactive_path_future(
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> futures::future::BoxFuture<'static, Option<String>> {
|
|
#[cfg(feature = "local_tty")]
|
|
{
|
|
LocalShellState::handle(ctx).update(ctx, |s, ctx| s.get_interactive_path_env_var(ctx))
|
|
}
|
|
#[cfg(not(feature = "local_tty"))]
|
|
{
|
|
use futures::FutureExt;
|
|
let _ = ctx;
|
|
futures::future::ready(None).boxed()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn invalid_request_response(message: String) -> HandlerOutcome {
|
|
HandlerOutcome::Sync(server_message::Message::Error(ErrorResponse {
|
|
code: ErrorCode::InvalidRequest.into(),
|
|
message,
|
|
}))
|
|
}
|
|
|
|
fn codebase_index_status_response(status: CodebaseIndexStatus) -> HandlerOutcome {
|
|
HandlerOutcome::Sync(server_message::Message::CodebaseIndexStatusUpdated(
|
|
CodebaseIndexStatusUpdated {
|
|
status: Some(status),
|
|
},
|
|
))
|
|
}
|
|
fn requested_repo_path(repo_path: &str) -> Result<PathBuf, String> {
|
|
if repo_path.is_empty() {
|
|
return Err("repo_path is required".to_string());
|
|
}
|
|
StandardizedPath::from_local_canonicalized(Path::new(repo_path))
|
|
.map(|path| path.to_local_path_lossy())
|
|
.map_err(|error| format!("Invalid repo_path {repo_path}: {error}"))
|
|
}
|
|
|
|
fn canonicalize_index_repo_path(repo_path: &str) -> Result<PathBuf, String> {
|
|
requested_repo_path(repo_path)?;
|
|
let standardized_path = StandardizedPath::from_local_canonicalized(Path::new(repo_path))
|
|
.map_err(|error| format!("Invalid repo_path {repo_path}: {error}"))?;
|
|
Ok(standardized_path
|
|
.to_local_path()
|
|
.unwrap_or_else(|| standardized_path.to_local_path_lossy()))
|
|
}
|
|
|
|
fn missing_fragment_metadata(content_hash: String, message: String) -> MissingFragmentMetadata {
|
|
MissingFragmentMetadata {
|
|
content_hash,
|
|
error: Some(FileOperationError { message }),
|
|
}
|
|
}
|
|
fn fragment_metadata_lookup_error_response(
|
|
code: FragmentMetadataLookupErrorCode,
|
|
message: String,
|
|
current_root_hash: Option<String>,
|
|
) -> HandlerOutcome {
|
|
HandlerOutcome::Sync(
|
|
server_message::Message::GetFragmentMetadataFromHashResponse(
|
|
GetFragmentMetadataFromHashResponse {
|
|
result: Some(get_fragment_metadata_from_hash_response::Result::Error(
|
|
ProtoFragmentMetadataLookupError {
|
|
code: code.into(),
|
|
message,
|
|
current_root_hash,
|
|
},
|
|
)),
|
|
},
|
|
),
|
|
)
|
|
}
|
|
|
|
fn fragment_metadata_lookup_error_response_from_error(
|
|
error: LocalFragmentMetadataLookupError,
|
|
) -> HandlerOutcome {
|
|
let (code, message, current_root_hash) = match error {
|
|
LocalFragmentMetadataLookupError::IndexNotFound => (
|
|
FragmentMetadataLookupErrorCode::IndexNotFound,
|
|
"Codebase index not found".to_string(),
|
|
None,
|
|
),
|
|
LocalFragmentMetadataLookupError::IndexNotSynced => (
|
|
FragmentMetadataLookupErrorCode::IndexNotSynced,
|
|
"Codebase index has no synced root hash".to_string(),
|
|
None,
|
|
),
|
|
LocalFragmentMetadataLookupError::RootHashMismatch { requested, current } => (
|
|
FragmentMetadataLookupErrorCode::RootHashMismatch,
|
|
format!("Codebase index root hash mismatch: requested {requested}, current {current}"),
|
|
Some(current.to_string()),
|
|
),
|
|
};
|
|
|
|
fragment_metadata_lookup_error_response(code, message, current_root_hash)
|
|
}
|
|
|
|
fn fragment_metadata_to_proto(
|
|
content_hash: &ContentHash,
|
|
metadata: &LocalFragmentMetadata,
|
|
) -> ProtoFragmentMetadata {
|
|
ProtoFragmentMetadata {
|
|
content_hash: content_hash.to_string(),
|
|
path: metadata.absolute_path.to_string_lossy().to_string(),
|
|
start_line: metadata.location.start_line as u32,
|
|
end_line: metadata.location.end_line as u32,
|
|
byte_start: metadata.location.byte_range.start.as_usize() as u64,
|
|
byte_end: metadata.location.byte_range.end.as_usize() as u64,
|
|
}
|
|
}
|
|
|
|
/// Converts a [`ReadFileContextResult`] into its protobuf equivalent.
|
|
fn file_context_result_to_proto(result: ReadFileContextResult) -> ReadFileContextResponse {
|
|
use crate::ai::agent::AnyFileContent;
|
|
|
|
let file_contexts = result
|
|
.file_contexts
|
|
.into_iter()
|
|
.map(|fc| {
|
|
let content = match fc.content {
|
|
AnyFileContent::StringContent(text) => {
|
|
super::proto::file_context_proto::Content::TextContent(text)
|
|
}
|
|
AnyFileContent::BinaryContent(bytes) => {
|
|
super::proto::file_context_proto::Content::BinaryContent(bytes)
|
|
}
|
|
};
|
|
let last_modified_epoch_millis = fc
|
|
.last_modified
|
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|d| d.as_millis() as u64);
|
|
FileContextProto {
|
|
file_name: fc.file_name,
|
|
content: Some(content),
|
|
line_range_start: fc.line_range.as_ref().map(|r| r.start as u32),
|
|
line_range_end: fc.line_range.as_ref().map(|r| r.end as u32),
|
|
last_modified_epoch_millis,
|
|
line_count: fc.line_count as u32,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let failed_files = result
|
|
.missing_files
|
|
.into_iter()
|
|
.map(|path| FailedFileRead {
|
|
path,
|
|
error: Some(FileOperationError {
|
|
message: "File not found or could not be read".to_string(),
|
|
}),
|
|
})
|
|
.collect();
|
|
|
|
ReadFileContextResponse {
|
|
file_contexts,
|
|
failed_files,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "server_model_tests.rs"]
|
|
mod tests;
|