first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+13 -1
View File
@@ -10,10 +10,19 @@ use crate::server::server_api::auth::AuthClient;
pub fn server_api_auth_context(
auth_state: Arc<AuthState>,
auth_client: Arc<dyn AuthClient>,
crash_reporting_enabled: bool,
) -> RemoteServerAuthContext {
let token_auth_state = auth_state.clone();
let token_auth_client = auth_client;
let identity_auth_state = auth_state;
let identity_auth_state = auth_state.clone();
let user_id_auth_state = auth_state.clone();
let user_email_auth_state = auth_state;
let user_id = user_id_auth_state
.user_id()
.map(|uid| uid.as_string())
.unwrap_or_default();
let user_email = user_email_auth_state.user_email().unwrap_or_default();
RemoteServerAuthContext::new(
move || -> BoxFuture<'static, Option<String>> {
@@ -30,6 +39,9 @@ pub fn server_api_auth_context(
})
},
move || remote_server_identity_key(&identity_auth_state),
user_id,
user_email,
crash_reporting_enabled,
)
}
@@ -0,0 +1,994 @@
use std::collections::HashMap;
use std::str::FromStr;
use ai::index::full_source_code_embedding::NodeHash;
use remote_server::codebase_index_proto::{RemoteCodebaseIndexState, RemoteCodebaseIndexStatus};
use galaxy_core::{HostId, SessionId};
use warp_util::remote_path::RemotePath;
use warp_util::standardized_path::StandardizedPath;
use warpui::{Entity, ModelContext, SingletonEntity};
use super::manager::{
RemoteCodebaseIndexStatusWithPath, RemoteCodebaseIndexUpdateOperation, RemoteServerManager,
RemoteServerManagerEvent,
};
use crate::ai::blocklist::SessionContext;
use crate::ai::codebase_auto_indexing::{
auto_index_candidate_roots, should_auto_index_codebase, should_use_codebase_indexing,
CodebaseAutoIndexingSurface,
};
use crate::server::telemetry::{
RemoteCodebaseAutoIndexTrigger, RemoteCodebaseIndexStatusTelemetrySource,
};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use crate::{send_telemetry_from_ctx, TelemetryEvent};
#[derive(Clone, Debug)]
pub struct RemoteCodebaseSearchContext {
pub remote_path: RemotePath,
pub root_hash: NodeHash,
pub is_stale: bool,
}
#[derive(Clone, Debug)]
pub enum RemoteCodebaseSearchAvailability {
NoConnectedHost,
NoActiveRepo,
NotIndexed {
remote_path: RemotePath,
},
Indexing {
remote_path: RemotePath,
},
Unavailable {
remote_path: RemotePath,
message: String,
},
Ready(RemoteCodebaseSearchContext),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteCodebaseContextEntry {
pub name: String,
pub path: String,
}
impl RemoteCodebaseSearchAvailability {
pub fn is_ready(&self) -> bool {
matches!(self, Self::Ready(_))
}
fn repo_path(&self) -> Option<&str> {
match self {
Self::NoConnectedHost | Self::NoActiveRepo => None,
Self::NotIndexed { remote_path }
| Self::Indexing { remote_path }
| Self::Unavailable { remote_path, .. } => Some(remote_path.path.as_str()),
Self::Ready(context) => Some(context.remote_path.path.as_str()),
}
}
}
fn remote_path_from_repo_path(host_id: &HostId, repo_path: &str) -> Option<RemotePath> {
StandardizedPath::try_new(repo_path)
.ok()
.map(|path| RemotePath::new(host_id.clone(), path))
}
fn remote_codebase_name(repo_path: &str) -> String {
repo_path
.rsplit('/')
.find(|segment| !segment.is_empty())
.unwrap_or(repo_path)
.to_string()
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct HostLabel {
label: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct PathAtHost {
host: HostLabel,
path: StandardizedPath,
}
#[derive(Default)]
pub struct RemoteCodebaseIndexModel {
statuses: HashMap<PathAtHost, RemoteCodebaseIndexStatus>,
active_repos_by_host: HashMap<HostId, RemotePath>,
host_labels: HashMap<HostId, HostLabel>,
active_git_repos_by_session: HashMap<SessionId, RemotePath>,
last_git_repos_by_host: HashMap<HostId, RemotePath>,
}
#[derive(Clone, Debug)]
pub enum RemoteCodebaseIndexModelEvent {
SettingsEntriesChanged,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteCodebaseIndexSettingsEntry {
pub remote_path: RemotePath,
pub status: RemoteCodebaseIndexStatus,
pub host_label: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct RemoteCodebaseIndexStatusTelemetryUpdate {
state: RemoteCodebaseIndexState,
previous_state: Option<RemoteCodebaseIndexState>,
has_root_hash: bool,
has_failure_message: bool,
progress_completed: Option<u64>,
progress_total: Option<u64>,
}
impl RemoteCodebaseIndexStatusTelemetryUpdate {
fn new(
status: &RemoteCodebaseIndexStatus,
previous_state: Option<RemoteCodebaseIndexState>,
) -> Self {
Self {
state: status.state,
previous_state,
has_root_hash: status
.root_hash
.as_deref()
.is_some_and(|root_hash| !root_hash.is_empty()),
has_failure_message: status
.failure_message
.as_deref()
.is_some_and(|message| !message.is_empty()),
progress_completed: status.progress_completed,
progress_total: status.progress_total,
}
}
}
impl RemoteCodebaseIndexModel {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let manager = RemoteServerManager::handle(ctx);
ctx.subscribe_to_model(&manager, |me, _, event, ctx| {
me.handle_remote_server_manager_event(event, ctx);
});
let user_workspaces = UserWorkspaces::handle(ctx);
ctx.subscribe_to_model(&user_workspaces, |me, _, event, ctx| {
if let UserWorkspacesEvent::CodebaseContextEnablementChanged = event {
me.handle_codebase_context_enablement_changed(ctx);
}
});
Self::default()
}
fn host_label_for_host(&self, host_id: &HostId) -> HostLabel {
self.host_labels
.get(host_id)
.cloned()
.unwrap_or_else(|| HostLabel {
label: host_id.to_string(),
})
}
fn status_key_for_remote_path(&self, remote_path: &RemotePath) -> PathAtHost {
PathAtHost {
host: self.host_label_for_host(&remote_path.host_id),
path: remote_path.path.clone(),
}
}
fn remote_path_for_status_key(&self, key: &PathAtHost) -> RemotePath {
RemotePath::new(self.host_id_for_label(&key.host), key.path.clone())
}
fn host_id_for_label(&self, host_label: &HostLabel) -> HostId {
self.host_labels
.iter()
.find_map(|(host_id, label)| (label == host_label).then_some(host_id.clone()))
.unwrap_or_else(|| HostId::new(host_label.label.clone()))
}
fn move_statuses_to_resolved_host_label(
&mut self,
old_host_label: HostLabel,
new_host_label: HostLabel,
) -> bool {
if old_host_label == new_host_label {
return false;
}
let mut moved_statuses = vec![];
self.statuses.retain(|key, status| {
if key.host == old_host_label {
moved_statuses.push((
PathAtHost {
host: new_host_label.clone(),
path: key.path.clone(),
},
status.clone(),
));
false
} else {
true
}
});
let statuses_moved = !moved_statuses.is_empty();
for (key, status) in moved_statuses {
self.statuses.entry(key).or_insert(status);
}
statuses_moved
}
pub fn active_repo_availability(
&self,
session_context: &SessionContext,
explicit_repo_path: Option<&str>,
) -> RemoteCodebaseSearchAvailability {
let Some(host_id) = session_context.host_id() else {
return RemoteCodebaseSearchAvailability::NoConnectedHost;
};
self.availability_for_remote(
host_id,
session_context.current_working_directory().as_deref(),
explicit_repo_path,
)
}
pub fn active_repo_path(
&self,
session_context: &SessionContext,
explicit_repo_path: Option<&str>,
) -> Option<String> {
self.active_repo_availability(session_context, explicit_repo_path)
.repo_path()
.map(ToOwned::to_owned)
}
pub fn request_active_repo_index(
&self,
session_context: &SessionContext,
explicit_repo_path: Option<&str>,
ctx: &mut ModelContext<Self>,
) -> bool {
if !should_use_codebase_indexing(CodebaseAutoIndexingSurface::Remote, ctx) {
return false;
}
let Some(host_id) = session_context.host_id() else {
return false;
};
let Some(remote_path) = self.resolve_remote_repo_path(
host_id,
session_context.current_working_directory().as_deref(),
explicit_repo_path,
) else {
return false;
};
RemoteServerManager::handle(ctx).update(ctx, |manager, ctx| {
manager.ensure_codebase_indexed(
remote_path,
RemoteCodebaseIndexUpdateOperation::IndexNewRepo {
is_auto_index: false,
},
ctx,
);
});
true
}
pub fn codebases_for_agent_context(&self, host_id: &HostId) -> Vec<RemoteCodebaseContextEntry> {
let host_label = self.host_label_for_host(host_id);
let mut entries = self
.statuses
.iter()
.filter(|&(key, status)| {
key.host == host_label
&& search_availability_for_status(
status,
RemotePath::new(host_id.clone(), key.path.clone()),
)
.is_ready()
})
.map(|(key, _)| {
let path = key.path.as_str().to_string();
RemoteCodebaseContextEntry {
name: remote_codebase_name(&path),
path,
}
})
.collect::<Vec<_>>();
entries.sort_by(|a, b| a.path.cmp(&b.path));
entries
}
pub fn request_index(&self, remote_path: RemotePath, ctx: &mut ModelContext<Self>) {
if !should_use_codebase_indexing(CodebaseAutoIndexingSurface::Remote, ctx) {
return;
}
RemoteServerManager::handle(ctx).update(ctx, |manager, ctx| {
manager.ensure_codebase_indexed(
remote_path,
RemoteCodebaseIndexUpdateOperation::IndexNewRepo {
is_auto_index: false,
},
ctx,
);
});
}
pub fn resync_index(&self, remote_path: RemotePath, ctx: &mut ModelContext<Self>) {
if !should_use_codebase_indexing(CodebaseAutoIndexingSurface::Remote, ctx) {
return;
}
RemoteServerManager::handle(ctx).update(ctx, |manager, ctx| {
manager.resync_codebase(remote_path, ctx);
});
}
pub fn drop_index(&self, remote_path: RemotePath, ctx: &mut ModelContext<Self>) {
RemoteServerManager::handle(ctx).update(ctx, |manager, ctx| {
manager.drop_codebase_index(remote_path, ctx);
});
}
pub fn entries_for_settings(&self) -> Vec<RemoteCodebaseIndexSettingsEntry> {
let mut entries = self
.statuses
.iter()
.map(|(key, status)| RemoteCodebaseIndexSettingsEntry {
remote_path: self.remote_path_for_status_key(key),
status: status.clone(),
host_label: key.host.label.clone(),
})
.collect::<Vec<_>>();
entries.sort_by(|a, b| {
a.host_label
.cmp(&b.host_label)
.then_with(|| a.remote_path.path.as_str().cmp(b.remote_path.path.as_str()))
});
entries
}
fn handle_remote_server_manager_event(
&mut self,
event: &RemoteServerManagerEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
RemoteServerManagerEvent::CodebaseIndexStatusesSnapshot { host_id, statuses } => {
if !should_use_codebase_indexing(CodebaseAutoIndexingSurface::Remote, ctx) {
return;
}
let (changed, telemetry_updates) =
self.apply_statuses_snapshot_with_telemetry(host_id, statuses);
for update in telemetry_updates {
emit_status_changed_telemetry(
update,
None,
RemoteCodebaseIndexStatusTelemetrySource::Snapshot,
ctx,
);
}
if changed {
ctx.emit(RemoteCodebaseIndexModelEvent::SettingsEntriesChanged);
}
}
RemoteServerManagerEvent::CodebaseIndexStatusUpdated {
remote_path,
status,
mutation_kind,
session_id: _,
} => {
if !should_use_codebase_indexing(CodebaseAutoIndexingSurface::Remote, ctx) {
return;
}
if let Some(update) =
self.apply_status_update_with_telemetry(remote_path.clone(), status.clone())
{
let source = if mutation_kind.is_some() {
RemoteCodebaseIndexStatusTelemetrySource::MutationResponse
} else {
RemoteCodebaseIndexStatusTelemetrySource::PushUpdate
};
emit_status_changed_telemetry(update, *mutation_kind, source, ctx);
ctx.emit(RemoteCodebaseIndexModelEvent::SettingsEntriesChanged);
}
}
RemoteServerManagerEvent::NavigatedToDirectory {
session_id,
remote_path,
is_git,
} => {
self.record_navigated_directory(*session_id, remote_path, *is_git);
if *is_git
&& should_auto_index_codebase(CodebaseAutoIndexingSurface::Remote, ctx)
&& self.should_request_auto_index_for_navigated_git_repo(remote_path)
{
// Mirrors local auto-indexing: remote navigation silently requests indexing
// only when the shared auto-index setting allows it.
let remote_path = remote_path.clone();
emit_auto_index_requested_telemetry(
RemoteCodebaseAutoIndexTrigger::NavigatedToGitRepo,
1,
ctx,
);
RemoteServerManager::handle(ctx).update(ctx, |manager, ctx| {
manager.ensure_codebase_indexed(
remote_path,
RemoteCodebaseIndexUpdateOperation::IndexNewRepo {
is_auto_index: true,
},
ctx,
);
});
}
}
RemoteServerManagerEvent::HostDisconnected { host_id } => {
if self.mark_host_unavailable(host_id) {
ctx.emit(RemoteCodebaseIndexModelEvent::SettingsEntriesChanged);
}
}
RemoteServerManagerEvent::SessionConnected {
session_id: _,
host_id,
}
| RemoteServerManagerEvent::SessionReconnected {
session_id: _,
host_id,
attempt: _,
client: _,
} => {
if self.record_host_label(host_id, ctx) {
ctx.emit(RemoteCodebaseIndexModelEvent::SettingsEntriesChanged);
}
}
RemoteServerManagerEvent::SessionDisconnected { session_id, .. }
| RemoteServerManagerEvent::SessionDeregistered { session_id } => {
self.clear_active_git_repo_for_session(*session_id);
}
RemoteServerManagerEvent::SessionConnecting { .. }
| RemoteServerManagerEvent::SessionConnectionFailed { .. }
| RemoteServerManagerEvent::HostConnected { .. }
| RemoteServerManagerEvent::RemoteAgentContextSnapshot { .. }
| RemoteServerManagerEvent::RepoMetadataSnapshot { .. }
| RemoteServerManagerEvent::RepoMetadataUpdated { .. }
| RemoteServerManagerEvent::RepoMetadataDirectoryLoaded { .. }
| RemoteServerManagerEvent::BufferUpdated { .. }
| RemoteServerManagerEvent::BufferConflictDetected { .. }
| RemoteServerManagerEvent::DiffStateSnapshotReceived { .. }
| RemoteServerManagerEvent::DiffStateMetadataUpdateReceived { .. }
| RemoteServerManagerEvent::DiffStateFileDeltaReceived { .. }
| RemoteServerManagerEvent::GitStatusPushReceived { .. }
| RemoteServerManagerEvent::GitHubPrInfoPushReceived { .. }
| RemoteServerManagerEvent::GitHubRepositoryInfoPushReceived { .. }
| RemoteServerManagerEvent::GetBranchesResponse { .. }
| RemoteServerManagerEvent::CommitChainResponse { .. }
| RemoteServerManagerEvent::GitPushResponse { .. }
| RemoteServerManagerEvent::CreatePrResponse { .. }
| RemoteServerManagerEvent::GenerateCommitMessageResponse { .. }
| RemoteServerManagerEvent::GetCommittedBranchFilesResponse { .. }
| RemoteServerManagerEvent::SetupStateChanged { .. }
| RemoteServerManagerEvent::BinaryCheckComplete { .. }
| RemoteServerManagerEvent::BinaryInstallComplete { .. }
| RemoteServerManagerEvent::ClientRequestFailed { .. }
| RemoteServerManagerEvent::CodebaseIndexMutationFailed { .. }
| RemoteServerManagerEvent::ServerMessageDecodingError { .. } => {}
}
}
fn handle_codebase_context_enablement_changed(&mut self, ctx: &mut ModelContext<Self>) {
if !should_use_codebase_indexing(CodebaseAutoIndexingSurface::Remote, ctx) {
let remote_paths = self.clear_remote_codebase_indexing_state();
if !remote_paths.is_empty() {
ctx.emit(RemoteCodebaseIndexModelEvent::SettingsEntriesChanged);
}
for remote_path in remote_paths {
RemoteServerManager::handle(ctx).update(ctx, |manager, ctx| {
manager.drop_codebase_index(remote_path, ctx);
});
}
return;
}
let remote_paths = self.active_git_repo_paths_needing_auto_index();
if remote_paths.is_empty()
|| !should_auto_index_codebase(CodebaseAutoIndexingSurface::Remote, ctx)
{
return;
}
emit_auto_index_requested_telemetry(
RemoteCodebaseAutoIndexTrigger::CodebaseContextEnablementChanged,
remote_paths.len(),
ctx,
);
for remote_path in remote_paths {
RemoteServerManager::handle(ctx).update(ctx, |manager, ctx| {
manager.ensure_codebase_indexed(
remote_path,
RemoteCodebaseIndexUpdateOperation::IndexNewRepo {
is_auto_index: true,
},
ctx,
);
});
}
}
fn clear_remote_codebase_indexing_state(&mut self) -> Vec<RemotePath> {
let statuses = std::mem::take(&mut self.statuses);
statuses
.into_keys()
.map(|key| self.remote_path_for_status_key(&key))
.collect()
}
fn should_request_auto_index_for_navigated_git_repo(&self, remote_path: &RemotePath) -> bool {
let Some(status) = self.status_for_repo(remote_path) else {
return true;
};
match search_availability_for_status(status, remote_path.clone()) {
RemoteCodebaseSearchAvailability::Ready(_)
| RemoteCodebaseSearchAvailability::Indexing { .. } => false,
RemoteCodebaseSearchAvailability::NoConnectedHost
| RemoteCodebaseSearchAvailability::NoActiveRepo
| RemoteCodebaseSearchAvailability::NotIndexed { .. }
| RemoteCodebaseSearchAvailability::Unavailable { .. } => true,
}
}
fn active_git_repo_paths_needing_auto_index(&self) -> Vec<RemotePath> {
auto_index_candidate_roots(
self.active_git_repos_by_session.values().cloned(),
|remote_path| self.should_request_auto_index_for_navigated_git_repo(remote_path),
)
}
fn apply_statuses_snapshot(
&mut self,
host_id: &HostId,
statuses: &[RemoteCodebaseIndexStatusWithPath],
) -> bool {
self.apply_statuses_snapshot_with_telemetry(host_id, statuses)
.0
}
fn apply_statuses_snapshot_with_telemetry(
&mut self,
host_id: &HostId,
statuses: &[RemoteCodebaseIndexStatusWithPath],
) -> (bool, Vec<RemoteCodebaseIndexStatusTelemetryUpdate>) {
let status_count = statuses.len();
log::info!(
"[Remote codebase indexing] Client received bootstrap codebase index statuses snapshot: host_id={host_id} status_count={status_count}"
);
for status_with_path in statuses {
log::debug!(
"[Remote codebase indexing] Client received bootstrap codebase index status: repo_path={} state={:?} has_root_hash={}",
status_with_path.status.repo_path,
status_with_path.status.state,
status_with_path
.status
.root_hash
.as_deref()
.is_some_and(|root_hash| !root_hash.is_empty()),
);
}
let host_label = self.host_label_for_host(host_id);
let incoming_statuses = statuses
.iter()
.map(|status_with_path| {
(
PathAtHost {
host: host_label.clone(),
path: status_with_path.remote_path.path.clone(),
},
status_with_path.status.clone(),
)
})
.collect::<HashMap<_, _>>();
let existing_status_count = self
.statuses
.keys()
.filter(|key| key.host == host_label)
.count();
let snapshot_is_unchanged = existing_status_count == incoming_statuses.len()
&& self
.statuses
.iter()
.filter(|(key, _)| key.host == host_label)
.all(|(key, status)| incoming_statuses.get(key) == Some(status));
if snapshot_is_unchanged {
return (false, vec![]);
}
let previous_statuses = self
.statuses
.iter()
.filter(|(key, _)| key.host == host_label)
.map(|(key, status)| (key.clone(), status.clone()))
.collect::<HashMap<_, _>>();
self.statuses.retain(|key, _| key.host != host_label);
let mut telemetry_updates = vec![];
for (key, status) in incoming_statuses {
if previous_statuses.get(&key) == Some(&status) {
self.statuses.insert(key, status);
continue;
}
let previous_state = previous_statuses
.get(&key)
.map(|previous_status| previous_status.state);
let remote_path = RemotePath::new(host_id.clone(), key.path.clone());
self.log_status_update(&remote_path, &status);
self.statuses.insert(key, status.clone());
telemetry_updates.push(RemoteCodebaseIndexStatusTelemetryUpdate::new(
&status,
previous_state,
));
}
(true, telemetry_updates)
}
fn apply_status_update(
&mut self,
remote_path: RemotePath,
status: RemoteCodebaseIndexStatus,
) -> bool {
self.apply_status_update_with_telemetry(remote_path, status)
.is_some()
}
fn apply_status_update_with_telemetry(
&mut self,
remote_path: RemotePath,
status: RemoteCodebaseIndexStatus,
) -> Option<RemoteCodebaseIndexStatusTelemetryUpdate> {
let key = self.status_key_for_remote_path(&remote_path);
if self.statuses.get(&key) == Some(&status) {
return None;
}
let previous_state = self
.statuses
.get(&key)
.map(|previous_status| previous_status.state);
self.log_status_update(&remote_path, &status);
self.statuses.insert(key, status.clone());
Some(RemoteCodebaseIndexStatusTelemetryUpdate::new(
&status,
previous_state,
))
}
fn log_status_update(&self, remote_path: &RemotePath, status: &RemoteCodebaseIndexStatus) {
log::info!(
"[Remote codebase indexing] Client applying codebase index status update: host_id={} repo_path={} state={:?} has_root_hash={}",
remote_path.host_id,
status.repo_path,
status.state,
status
.root_hash
.as_deref()
.is_some_and(|root_hash| !root_hash.is_empty()),
);
}
fn record_navigated_directory(
&mut self,
session_id: SessionId,
remote_path: &RemotePath,
is_git: bool,
) {
self.active_repos_by_host
.insert(remote_path.host_id.clone(), remote_path.clone());
if is_git {
self.active_git_repos_by_session
.insert(session_id, remote_path.clone());
self.last_git_repos_by_host
.insert(remote_path.host_id.clone(), remote_path.clone());
} else {
self.active_git_repos_by_session.remove(&session_id);
}
}
fn clear_active_git_repo_for_session(&mut self, session_id: SessionId) {
self.active_git_repos_by_session.remove(&session_id);
}
fn record_host_label(&mut self, host_id: &HostId, ctx: &mut ModelContext<Self>) -> bool {
let Some(host_label) = RemoteServerManager::as_ref(ctx)
.host_label(host_id)
.map(|label| HostLabel {
label: label.to_string(),
})
else {
return false;
};
if self.host_labels.get(host_id) == Some(&host_label) {
return false;
}
let previous_host_label = self.host_label_for_host(host_id);
self.host_labels.insert(host_id.clone(), host_label);
self.move_statuses_to_resolved_host_label(
previous_host_label,
self.host_label_for_host(host_id),
);
true
}
fn mark_host_unavailable(&mut self, host_id: &HostId) -> bool {
let host_label = self.host_label_for_host(host_id);
self.active_repos_by_host.remove(host_id);
self.active_git_repos_by_session
.retain(|_, remote_path| remote_path.host_id != *host_id);
self.last_git_repos_by_host.remove(host_id);
let mut updated = false;
for (key, status) in &mut self.statuses {
if key.host == host_label {
let failure_message = "The remote host is currently disconnected.".to_string();
if status.state != RemoteCodebaseIndexState::Unavailable
|| status.failure_message.as_ref() != Some(&failure_message)
{
status.state = RemoteCodebaseIndexState::Unavailable;
status.failure_message = Some(failure_message);
updated = true;
}
}
}
updated
}
fn availability_for_remote(
&self,
host_id: &HostId,
current_working_directory: Option<&str>,
explicit_repo_path: Option<&str>,
) -> RemoteCodebaseSearchAvailability {
let remote_path =
self.resolve_remote_repo_path(host_id, current_working_directory, explicit_repo_path);
let Some(remote_path) = remote_path else {
return RemoteCodebaseSearchAvailability::NoActiveRepo;
};
let Some(status) = self.status_for_repo(&remote_path) else {
return RemoteCodebaseSearchAvailability::NotIndexed { remote_path };
};
search_availability_for_status(status, remote_path)
}
fn resolve_remote_repo_path(
&self,
host_id: &HostId,
current_working_directory: Option<&str>,
explicit_repo_path: Option<&str>,
) -> Option<RemotePath> {
if let Some(explicit_repo_path) = explicit_repo_path {
let explicit_remote_path = remote_path_from_repo_path(host_id, explicit_repo_path);
if let Some(remote_path) = explicit_remote_path
.as_ref()
.filter(|remote_path| self.status_for_repo(remote_path).is_some())
{
// Remote branch: exact explicit matches are authoritative, mirroring local
// `SearchCodebase` behavior where a provided `codebase_path` targets that repo
// instead of the current working directory.
return Some(remote_path.clone());
}
if let Some((remote_path, _)) = self.best_status_for_path(host_id, explicit_repo_path) {
// Remote branch: an explicit path inside an indexed remote repo should search that
// indexed repo root. This preserves remote cross-repo search for paths that can be
// matched against daemon-reported index state.
return Some(remote_path);
}
// Remote branch: an explicit path that does not match known index state is still
// authoritative. Return it so callers surface `NotIndexed` (and can request indexing)
// for the explicit target instead of silently searching the active remote repo.
return explicit_remote_path;
}
if let Some((remote_path, _)) =
current_working_directory.and_then(|cwd| self.best_status_for_path(host_id, cwd))
{
// Remote branch: if the remote cwd is inside a known indexed repo, use the indexed root
// rather than re-indexing the nested directory.
return Some(remote_path);
}
if let Some(remote_path) = self.active_repos_by_host.get(host_id) {
if self.status_for_repo(remote_path).is_some() {
// Remote branch: only implicit searches (no `codebase_path`) fall back to the
// active repo recorded by daemon navigation events.
return Some(remote_path.clone());
}
}
if let Some(remote_path) = self.last_git_repo_for_context(
host_id,
current_working_directory,
self.active_repos_by_host
.get(host_id)
.map(|remote_path| remote_path.path.as_str()),
) {
return Some(remote_path);
}
if let Some((remote_path, _)) = current_working_directory
.and_then(|cwd| self.single_descendant_status_for_path(host_id, cwd))
{
return Some(remote_path);
}
if let Some(remote_path) = self.active_repos_by_host.get(host_id) {
// Remote branch: only implicit searches (no `codebase_path`) fall back to the active
// repo recorded by daemon navigation events.
return Some(remote_path.clone());
}
current_working_directory.and_then(|cwd| {
// Remote branch: only when we have no indexed/active remote repo do we fall back to the
// remote session cwd as the candidate to index. Local sessions never use this path; they
// resolve search roots in the local `SearchCodebase` executor branch instead.
remote_path_from_repo_path(host_id, cwd)
})
}
fn resolve_known_remote_repo_path(
&self,
host_id: &HostId,
current_working_directory: Option<&str>,
requested_codebase_path: Option<&str>,
) -> Option<RemotePath> {
let remote_path = self.resolve_remote_repo_path(
host_id,
current_working_directory,
requested_codebase_path,
)?;
self.status_for_repo(&remote_path)?;
Some(remote_path)
}
fn status_for_repo(&self, remote_path: &RemotePath) -> Option<&RemoteCodebaseIndexStatus> {
self.statuses
.get(&self.status_key_for_remote_path(remote_path))
}
fn best_status_for_path(
&self,
host_id: &HostId,
path: &str,
) -> Option<(RemotePath, &RemoteCodebaseIndexStatus)> {
let host_label = self.host_label_for_host(host_id);
let path = StandardizedPath::try_new(path).ok()?;
self.statuses
.iter()
.filter(|(key, _)| key.host == host_label && path.starts_with(&key.path))
.max_by_key(|(key, _)| key.path.as_str().len())
.map(|(key, status)| (RemotePath::new(host_id.clone(), key.path.clone()), status))
}
fn single_descendant_status_for_path(
&self,
host_id: &HostId,
path: &str,
) -> Option<(RemotePath, &RemoteCodebaseIndexStatus)> {
let host_label = self.host_label_for_host(host_id);
let path = StandardizedPath::try_new(path).ok()?;
let mut descendants = self
.statuses
.iter()
.filter(|(key, _)| key.host == host_label && key.path.starts_with(&path));
let (key, status) = descendants.next()?;
descendants
.next()
.is_none()
.then(|| (RemotePath::new(host_id.clone(), key.path.clone()), status))
}
fn last_git_repo_for_context(
&self,
host_id: &HostId,
current_working_directory: Option<&str>,
active_repo_path: Option<&str>,
) -> Option<RemotePath> {
let remote_path = self.last_git_repos_by_host.get(host_id)?;
let repo_path = &remote_path.path;
let is_related_to_context = current_working_directory
.and_then(|cwd| StandardizedPath::try_new(cwd).ok())
.is_some_and(|cwd| cwd.starts_with(repo_path) || repo_path.starts_with(&cwd))
|| active_repo_path
.and_then(|active_path| StandardizedPath::try_new(active_path).ok())
.is_some_and(|active_path| {
active_path.starts_with(repo_path) || repo_path.starts_with(&active_path)
});
is_related_to_context.then(|| remote_path.clone())
}
}
impl Entity for RemoteCodebaseIndexModel {
type Event = RemoteCodebaseIndexModelEvent;
}
impl SingletonEntity for RemoteCodebaseIndexModel {}
fn search_availability_for_status(
status: &RemoteCodebaseIndexStatus,
remote_path: RemotePath,
) -> RemoteCodebaseSearchAvailability {
match status.state {
RemoteCodebaseIndexState::Ready | RemoteCodebaseIndexState::Stale => {
let Some(root_hash) = status
.root_hash
.as_deref()
.and_then(|hash| NodeHash::from_str(hash).ok())
else {
return RemoteCodebaseSearchAvailability::Unavailable {
remote_path,
message: "The remote codebase index is missing its root hash.".to_string(),
};
};
RemoteCodebaseSearchAvailability::Ready(RemoteCodebaseSearchContext {
remote_path,
root_hash,
is_stale: status.state == RemoteCodebaseIndexState::Stale,
})
}
RemoteCodebaseIndexState::Queued | RemoteCodebaseIndexState::Indexing => {
RemoteCodebaseSearchAvailability::Indexing { remote_path }
}
RemoteCodebaseIndexState::Failed
| RemoteCodebaseIndexState::NotEnabled
| RemoteCodebaseIndexState::Unavailable
| RemoteCodebaseIndexState::Disabled => RemoteCodebaseSearchAvailability::Unavailable {
remote_path,
message: status
.failure_message
.clone()
.unwrap_or_else(|| "Remote codebase search is not available.".to_string()),
},
}
}
fn emit_status_changed_telemetry(
update: RemoteCodebaseIndexStatusTelemetryUpdate,
mutation_kind: Option<RemoteCodebaseIndexUpdateOperation>,
source: RemoteCodebaseIndexStatusTelemetrySource,
ctx: &mut ModelContext<RemoteCodebaseIndexModel>,
) {
send_telemetry_from_ctx!(
TelemetryEvent::RemoteCodebaseIndexStatusChanged {
state: update.state,
previous_state: update.previous_state,
has_root_hash: update.has_root_hash,
has_failure_message: update.has_failure_message,
progress_completed: update.progress_completed,
progress_total: update.progress_total,
mutation_kind,
source,
remote_os: None,
remote_arch: None,
},
ctx
);
}
fn emit_auto_index_requested_telemetry(
trigger: RemoteCodebaseAutoIndexTrigger,
requested_count: usize,
ctx: &mut ModelContext<RemoteCodebaseIndexModel>,
) {
if requested_count == 0 {
return;
}
send_telemetry_from_ctx!(
TelemetryEvent::RemoteCodebaseAutoIndexRequested {
trigger,
requested_count,
remote_os: None,
remote_arch: None,
},
ctx
);
}
#[cfg(test)]
#[path = "codebase_index_model_tests.rs"]
mod tests;
@@ -0,0 +1,741 @@
use super::*;
fn host() -> HostId {
HostId::new("host".to_string())
}
fn host_with_name(name: &str) -> HostId {
HostId::new(name.to_string())
}
fn host_label(label: &str) -> HostLabel {
HostLabel {
label: label.to_string(),
}
}
fn remote_path(repo_path: &str) -> RemotePath {
remote_path_from_repo_path(&host(), repo_path).unwrap()
}
fn remote_path_for_host(host: &HostId, repo_path: &str) -> RemotePath {
remote_path_from_repo_path(host, repo_path).unwrap()
}
fn session(id: u64) -> SessionId {
SessionId::from(id)
}
fn ready_status(repo_path: &str) -> RemoteCodebaseIndexStatus {
RemoteCodebaseIndexStatus {
repo_path: repo_path.to_string(),
state: RemoteCodebaseIndexState::Ready,
last_updated_epoch_millis: Some(1),
progress_completed: None,
progress_total: None,
failure_message: None,
root_hash: Some(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
),
}
}
fn status_with_state(
repo_path: &str,
state: RemoteCodebaseIndexState,
) -> RemoteCodebaseIndexStatus {
let mut status = ready_status(repo_path);
status.state = state;
status
}
fn status_with_path(repo_path: &str) -> RemoteCodebaseIndexStatusWithPath {
RemoteCodebaseIndexStatusWithPath {
remote_path: remote_path(repo_path),
status: ready_status(repo_path),
}
}
#[test]
fn snapshot_replaces_statuses_for_host() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model.apply_status_update(remote_path("/old"), ready_status("/old"));
assert!(model.apply_statuses_snapshot(&host, &[status_with_path("/new")]));
assert!(model.status_for_repo(&remote_path("/old")).is_none());
assert!(model.status_for_repo(&remote_path("/new")).is_some());
}
#[test]
fn snapshot_replaces_statuses_for_same_host_label() {
let mut model = RemoteCodebaseIndexModel::default();
let old_host = host_with_name("old-daemon");
let new_host = host_with_name("new-daemon");
model
.host_labels
.insert(old_host.clone(), host_label("user@remote-host"));
model
.host_labels
.insert(new_host.clone(), host_label("user@remote-host"));
assert!(model.apply_statuses_snapshot(
&old_host,
&[RemoteCodebaseIndexStatusWithPath {
remote_path: remote_path_for_host(&old_host, "/old"),
status: ready_status("/old"),
}],
));
assert!(model.apply_statuses_snapshot(
&new_host,
&[RemoteCodebaseIndexStatusWithPath {
remote_path: remote_path_for_host(&new_host, "/new"),
status: ready_status("/new"),
}],
));
assert!(model
.status_for_repo(&remote_path_for_host(&new_host, "/old"))
.is_none());
assert!(model
.status_for_repo(&remote_path_for_host(&new_host, "/new"))
.is_some());
assert_eq!(model.entries_for_settings().len(), 1);
}
#[test]
fn status_update_reports_only_actual_changes() {
let mut model = RemoteCodebaseIndexModel::default();
let remote_path = remote_path("/repo");
let status = ready_status("/repo");
assert!(model.apply_status_update(remote_path.clone(), status.clone()));
assert!(!model.apply_status_update(remote_path.clone(), status));
assert!(model.apply_status_update(
remote_path,
status_with_state("/repo", RemoteCodebaseIndexState::Stale),
));
}
#[test]
fn snapshot_reports_only_actual_changes_for_host() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
let snapshot = [status_with_path("/repo")];
assert!(model.apply_statuses_snapshot(&host, &snapshot));
assert!(!model.apply_statuses_snapshot(&host, &snapshot));
assert!(model.apply_statuses_snapshot(
&host,
&[RemoteCodebaseIndexStatusWithPath {
remote_path: remote_path("/repo"),
status: status_with_state("/repo", RemoteCodebaseIndexState::Stale),
}],
));
}
#[test]
fn entries_for_settings_are_sorted_by_host_then_path() {
let mut model = RemoteCodebaseIndexModel::default();
let host_b = host_with_name("host-b");
let host_a = host_with_name("host-a");
model.apply_status_update(
remote_path_for_host(&host_b, "/z-repo"),
ready_status("/z-repo"),
);
model.apply_status_update(
remote_path_for_host(&host_a, "/b-repo"),
ready_status("/b-repo"),
);
model.apply_status_update(
remote_path_for_host(&host_a, "/a-repo"),
ready_status("/a-repo"),
);
let entries = model.entries_for_settings();
let labels_and_paths = entries
.iter()
.map(|entry| (entry.host_label.as_str(), entry.remote_path.path.as_str()))
.collect::<Vec<_>>();
assert_eq!(
labels_and_paths,
vec![
("host-a", "/a-repo"),
("host-a", "/b-repo"),
("host-b", "/z-repo")
]
);
}
#[test]
fn entries_for_settings_use_host_label_when_available() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model
.host_labels
.insert(host.clone(), host_label("user@ssh-testing"));
model.apply_status_update(remote_path("/repo"), ready_status("/repo"));
let entries = model.entries_for_settings();
assert_eq!(entries[0].host_label, "user@ssh-testing");
}
#[test]
fn entries_for_settings_fall_back_to_host_id_without_label() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model.apply_status_update(remote_path("/repo"), ready_status("/repo"));
let entries = model.entries_for_settings();
assert_eq!(entries[0].host_label, host.to_string());
}
#[test]
fn entries_for_settings_dedupe_same_ssh_host_and_path() {
let mut model = RemoteCodebaseIndexModel::default();
let old_host = host_with_name("old-daemon");
let new_host = host_with_name("new-daemon");
model
.host_labels
.insert(old_host.clone(), host_label("user@remote-host"));
model
.host_labels
.insert(new_host.clone(), host_label("user@remote-host"));
model.apply_status_update(
remote_path_for_host(&old_host, "/repo"),
ready_status("/repo"),
);
model.apply_status_update(
remote_path_for_host(&new_host, "/repo"),
ready_status("/repo"),
);
let entries = model.entries_for_settings();
assert_eq!(entries.len(), 1);
assert_eq!(model.statuses.len(), 1);
assert_eq!(entries[0].host_label, "user@remote-host");
assert_eq!(entries[0].remote_path.path.as_str(), "/repo");
}
#[test]
fn entries_for_settings_do_not_dedupe_different_ssh_hosts_or_paths() {
let mut model = RemoteCodebaseIndexModel::default();
let first_host = host_with_name("first-daemon");
let second_host = host_with_name("second-daemon");
model
.host_labels
.insert(first_host.clone(), host_label("user@first-host"));
model
.host_labels
.insert(second_host.clone(), host_label("user@second-host"));
model.apply_status_update(
remote_path_for_host(&first_host, "/repo"),
ready_status("/repo"),
);
model.apply_status_update(
remote_path_for_host(&second_host, "/repo"),
ready_status("/repo"),
);
model.apply_status_update(
remote_path_for_host(&first_host, "/other-repo"),
ready_status("/other-repo"),
);
let entries = model.entries_for_settings();
assert_eq!(entries.len(), 3);
}
#[test]
fn host_disconnect_marks_settings_entries_unavailable_without_removing_them() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model.apply_status_update(remote_path("/repo"), ready_status("/repo"));
model.record_navigated_directory(session(1), &remote_path("/repo"), true);
assert!(model.mark_host_unavailable(&host));
assert!(!model.mark_host_unavailable(&host));
let status = model.status_for_repo(&remote_path("/repo")).unwrap();
assert_eq!(status.state, RemoteCodebaseIndexState::Unavailable);
assert_eq!(
status.failure_message.as_deref(),
Some("The remote host is currently disconnected.")
);
assert_eq!(model.entries_for_settings().len(), 1);
assert!(matches!(
model.availability_for_remote(&host, Some("/repo"), None),
RemoteCodebaseSearchAvailability::Unavailable { .. }
));
}
#[test]
fn availability_uses_active_navigated_repo() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model.record_navigated_directory(session(1), &remote_path("/repo"), true);
model.apply_status_update(remote_path("/repo"), ready_status("/repo"));
let availability = model.availability_for_remote(&host, Some("/repo/src"), None);
assert!(availability.is_ready());
assert_eq!(availability.repo_path(), Some("/repo"));
}
#[test]
fn availability_uses_active_navigated_non_git_directory() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model.record_navigated_directory(session(1), &remote_path("/directory"), false);
model.apply_status_update(remote_path("/directory"), ready_status("/directory"));
let availability = model.availability_for_remote(&host, Some("/repo/src"), None);
assert!(availability.is_ready());
assert_eq!(availability.repo_path(), Some("/directory"));
}
#[test]
fn availability_falls_back_to_longest_status_prefix() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model.apply_status_update(remote_path("/repo"), ready_status("/repo"));
model.apply_status_update(remote_path("/repo/nested"), ready_status("/repo/nested"));
let availability = model.availability_for_remote(&host, Some("/repo/nested/src"), None);
assert!(availability.is_ready());
assert_eq!(availability.repo_path(), Some("/repo/nested"));
}
#[test]
fn availability_uses_unmatched_explicit_path_as_not_indexed() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model.record_navigated_directory(session(1), &remote_path("/workspaces/warp"), true);
model.apply_status_update(
remote_path("/workspaces/warp"),
ready_status("/workspaces/warp"),
);
let availability = model.availability_for_remote(
&host,
Some("/workspaces/warp"),
Some("/Users/user/code/warp"),
);
assert!(matches!(
availability,
RemoteCodebaseSearchAvailability::NotIndexed { .. }
));
assert_eq!(availability.repo_path(), Some("/Users/user/code/warp"));
}
#[test]
fn availability_uses_unknown_explicit_remote_path_as_not_indexed() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model.record_navigated_directory(session(1), &remote_path("/workspaces/active"), true);
model.apply_status_update(
remote_path("/workspaces/active"),
ready_status("/workspaces/active"),
);
let availability =
model.availability_for_remote(&host, Some("/workspaces/active"), Some("/workspaces/other"));
assert!(matches!(
availability,
RemoteCodebaseSearchAvailability::NotIndexed { .. }
));
assert_eq!(availability.repo_path(), Some("/workspaces/other"));
}
#[test]
fn availability_uses_requested_path_when_it_matches_known_remote_repo() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model.record_navigated_directory(session(1), &remote_path("/workspaces/other"), true);
model.apply_status_update(
remote_path("/workspaces/other"),
ready_status("/workspaces/other"),
);
model.apply_status_update(
remote_path("/workspaces/warp"),
ready_status("/workspaces/warp"),
);
let availability = model.availability_for_remote(
&host,
Some("/workspaces/other"),
Some("/workspaces/warp/app"),
);
assert!(availability.is_ready());
assert_eq!(availability.repo_path(), Some("/workspaces/warp"));
}
#[test]
fn codebases_for_agent_context_includes_searchable_remote_paths() {
let mut model = RemoteCodebaseIndexModel::default();
model.apply_status_update(
remote_path("/workspaces/warp"),
ready_status("/workspaces/warp"),
);
model.apply_status_update(
remote_path("/workspaces/stale"),
status_with_state("/workspaces/stale", RemoteCodebaseIndexState::Stale),
);
let entries = model.codebases_for_agent_context(&host());
assert_eq!(
entries,
vec![
RemoteCodebaseContextEntry {
name: "stale".to_string(),
path: "/workspaces/stale".to_string(),
},
RemoteCodebaseContextEntry {
name: "warp".to_string(),
path: "/workspaces/warp".to_string(),
},
]
);
}
#[test]
fn codebases_for_agent_context_skips_unsearchable_remote_paths() {
let mut model = RemoteCodebaseIndexModel::default();
let mut missing_root_hash = ready_status("/workspaces/missing-root-hash");
missing_root_hash.root_hash = None;
model.apply_status_update(
remote_path("/workspaces/missing-root-hash"),
missing_root_hash,
);
model.apply_status_update(
remote_path("/workspaces/indexing"),
status_with_state("/workspaces/indexing", RemoteCodebaseIndexState::Indexing),
);
model.apply_status_update(
remote_path("/workspaces/failed"),
status_with_state("/workspaces/failed", RemoteCodebaseIndexState::Failed),
);
assert!(model.codebases_for_agent_context(&host()).is_empty());
}
#[test]
fn codebases_for_agent_context_only_includes_active_host_paths() {
let mut model = RemoteCodebaseIndexModel::default();
let active_host = host_with_name("active-host");
let other_host = host_with_name("other-host");
model.apply_status_update(
remote_path_for_host(&active_host, "/workspaces/active"),
ready_status("/workspaces/active"),
);
model.apply_status_update(
remote_path_for_host(&other_host, "/workspaces/other"),
ready_status("/workspaces/other"),
);
let entries = model.codebases_for_agent_context(&active_host);
assert_eq!(
entries,
vec![RemoteCodebaseContextEntry {
name: "active".to_string(),
path: "/workspaces/active".to_string(),
}]
);
}
#[test]
fn clear_remote_codebase_indexing_state_returns_paths_and_removes_client_state() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model.apply_status_update(
remote_path("/workspaces/warp"),
ready_status("/workspaces/warp"),
);
model.record_navigated_directory(session(1), &remote_path("/workspaces/warp"), true);
let remote_paths = model.clear_remote_codebase_indexing_state();
assert_eq!(remote_paths, vec![remote_path("/workspaces/warp")]);
assert!(model.entries_for_settings().is_empty());
assert!(matches!(
model.availability_for_remote(&host, Some("/workspaces/warp"), None),
RemoteCodebaseSearchAvailability::NotIndexed { .. }
));
}
#[test]
fn active_git_repo_paths_needing_auto_index_skips_searchable_and_indexing_repos_across_hosts() {
let mut model = RemoteCodebaseIndexModel::default();
let host_ready = host_with_name("ready-host");
let host_new = host_with_name("new-host");
let host_indexing = host_with_name("indexing-host");
let ready_path = remote_path_for_host(&host_ready, "/ready");
let new_path = remote_path_for_host(&host_new, "/new");
let indexing_path = remote_path_for_host(&host_indexing, "/indexing");
model.record_navigated_directory(session(1), &ready_path, true);
model.record_navigated_directory(session(2), &new_path, true);
model.record_navigated_directory(session(3), &indexing_path, true);
model.apply_status_update(ready_path, ready_status("/ready"));
model.apply_status_update(
indexing_path,
status_with_state("/indexing", RemoteCodebaseIndexState::Indexing),
);
let remote_paths = model.active_git_repo_paths_needing_auto_index();
assert_eq!(remote_paths, vec![new_path]);
}
#[test]
fn resolve_remote_repo_path_falls_back_to_current_remote_cwd_when_no_repo_is_known() {
let model = RemoteCodebaseIndexModel::default();
let host = host();
let remote_path = model.resolve_remote_repo_path(&host, Some("/workspaces/new"), None);
assert_eq!(
remote_path.map(|remote_path| remote_path.path.as_str().to_string()),
Some("/workspaces/new".to_string())
);
}
#[test]
fn indexing_state_is_not_ready() {
let mut status = ready_status("/repo");
status.state = RemoteCodebaseIndexState::Indexing;
let availability = search_availability_for_status(&status, remote_path("/repo"));
assert!(matches!(
availability,
RemoteCodebaseSearchAvailability::Indexing { .. }
));
}
#[test]
fn stale_state_is_ready_and_marked_stale() {
let mut status = ready_status("/repo");
status.state = RemoteCodebaseIndexState::Stale;
let availability = search_availability_for_status(&status, remote_path("/repo"));
let RemoteCodebaseSearchAvailability::Ready(context) = availability else {
panic!("Expected stale index to remain searchable");
};
assert!(context.is_stale);
}
#[test]
fn known_remote_repo_path_does_not_fall_back_to_unknown_cwd() {
let model = RemoteCodebaseIndexModel::default();
let host = host();
let remote_path = model.resolve_known_remote_repo_path(&host, Some("/workspaces/new"), None);
assert!(remote_path.is_none());
}
#[test]
fn known_remote_repo_path_finds_indexed_parent_for_cwd() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model.apply_status_update(
remote_path("/workspaces/repo"),
ready_status("/workspaces/repo"),
);
let remote_path =
model.resolve_known_remote_repo_path(&host, Some("/workspaces/repo/src"), None);
assert_eq!(
remote_path.map(|remote_path| remote_path.path.as_str().to_string()),
Some("/workspaces/repo".to_string())
);
}
#[test]
fn known_remote_repo_path_uses_single_indexed_descendant_for_broad_cwd() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
model.record_navigated_directory(session(1), &remote_path("/workspaces"), false);
model.apply_status_update(
remote_path("/workspaces/warp"),
ready_status("/workspaces/warp"),
);
let remote_path = model.resolve_known_remote_repo_path(&host, Some("/workspaces"), None);
assert_eq!(
remote_path.map(|remote_path| remote_path.path.as_str().to_string()),
Some("/workspaces/warp".to_string())
);
}
#[test]
fn missing_root_hash_is_unavailable() {
let mut status = ready_status("/repo");
status.root_hash = None;
let availability = search_availability_for_status(&status, remote_path("/repo"));
assert!(matches!(
availability,
RemoteCodebaseSearchAvailability::Unavailable { .. }
));
}
#[test]
fn auto_index_navigated_git_repo_when_status_is_missing() {
let model = RemoteCodebaseIndexModel::default();
assert!(model.should_request_auto_index_for_navigated_git_repo(&remote_path("/repo")));
}
#[test]
fn auto_index_navigated_git_repo_skips_existing_searchable_index() {
let mut model = RemoteCodebaseIndexModel::default();
model.apply_status_update(remote_path("/ready"), ready_status("/ready"));
model.apply_status_update(
remote_path("/stale"),
status_with_state("/stale", RemoteCodebaseIndexState::Stale),
);
assert!(!model.should_request_auto_index_for_navigated_git_repo(&remote_path("/ready")));
assert!(!model.should_request_auto_index_for_navigated_git_repo(&remote_path("/stale")));
}
#[test]
fn auto_index_navigated_git_repo_skips_index_already_in_progress() {
let mut model = RemoteCodebaseIndexModel::default();
model.apply_status_update(
remote_path("/queued"),
status_with_state("/queued", RemoteCodebaseIndexState::Queued),
);
model.apply_status_update(
remote_path("/indexing"),
status_with_state("/indexing", RemoteCodebaseIndexState::Indexing),
);
assert!(!model.should_request_auto_index_for_navigated_git_repo(&remote_path("/queued")));
assert!(!model.should_request_auto_index_for_navigated_git_repo(&remote_path("/indexing")));
}
#[test]
fn auto_index_navigated_git_repo_when_existing_index_is_unusable() {
let mut model = RemoteCodebaseIndexModel::default();
let mut missing_root_hash = ready_status("/missing-root-hash");
missing_root_hash.root_hash = None;
model.apply_status_update(remote_path("/missing-root-hash"), missing_root_hash);
model.apply_status_update(
remote_path("/failed"),
status_with_state("/failed", RemoteCodebaseIndexState::Failed),
);
assert!(
model.should_request_auto_index_for_navigated_git_repo(&remote_path("/missing-root-hash"))
);
assert!(model.should_request_auto_index_for_navigated_git_repo(&remote_path("/failed")));
}
#[test]
fn active_git_repo_paths_needing_auto_index_includes_missing_active_git_repo() {
let mut model = RemoteCodebaseIndexModel::default();
model.record_navigated_directory(session(1), &remote_path("/repo"), true);
assert_eq!(
model.active_git_repo_paths_needing_auto_index(),
vec![remote_path("/repo")]
);
}
#[test]
fn active_git_repo_paths_needing_auto_index_skips_ready_and_indexing_repos() {
let mut model = RemoteCodebaseIndexModel::default();
model.record_navigated_directory(session(1), &remote_path("/ready"), true);
model.apply_status_update(remote_path("/ready"), ready_status("/ready"));
let other_host = HostId::new("other-host".to_string());
let indexing_path = remote_path_from_repo_path(&other_host, "/indexing").unwrap();
model.record_navigated_directory(session(2), &indexing_path, true);
model.apply_status_update(
indexing_path,
status_with_state("/indexing", RemoteCodebaseIndexState::Indexing),
);
assert!(model.active_git_repo_paths_needing_auto_index().is_empty());
}
#[test]
fn active_git_repo_paths_needing_auto_index_includes_each_active_remote_session_repo() {
let mut model = RemoteCodebaseIndexModel::default();
model.record_navigated_directory(session(1), &remote_path("/repo-a"), true);
model.record_navigated_directory(session(2), &remote_path("/repo-b"), true);
let mut paths = model
.active_git_repo_paths_needing_auto_index()
.into_iter()
.map(|remote_path| remote_path.path.as_str().to_string())
.collect::<Vec<_>>();
paths.sort();
assert_eq!(paths, vec!["/repo-a", "/repo-b"]);
}
#[test]
fn active_git_repo_paths_needing_auto_index_dedupes_matching_session_repos() {
let mut model = RemoteCodebaseIndexModel::default();
model.record_navigated_directory(session(1), &remote_path("/repo"), true);
model.record_navigated_directory(session(2), &remote_path("/repo"), true);
assert_eq!(
model.active_git_repo_paths_needing_auto_index(),
vec![remote_path("/repo")]
);
}
#[test]
fn non_git_navigation_clears_only_that_sessions_active_git_repo() {
let mut model = RemoteCodebaseIndexModel::default();
model.record_navigated_directory(session(1), &remote_path("/repo-a"), true);
model.record_navigated_directory(session(2), &remote_path("/repo-b"), true);
model.record_navigated_directory(session(1), &remote_path("/not-git"), false);
assert_eq!(
model.active_git_repo_paths_needing_auto_index(),
vec![remote_path("/repo-b")]
);
}
#[test]
fn clearing_session_clears_only_that_sessions_active_git_repo() {
let mut model = RemoteCodebaseIndexModel::default();
model.record_navigated_directory(session(1), &remote_path("/repo-a"), true);
model.record_navigated_directory(session(2), &remote_path("/repo-b"), true);
model.clear_active_git_repo_for_session(session(1));
assert_eq!(
model.active_git_repo_paths_needing_auto_index(),
vec![remote_path("/repo-b")]
);
}
#[test]
fn remove_host_clears_active_git_repo_for_host() {
let mut model = RemoteCodebaseIndexModel::default();
let host = host();
let other_host = host_with_name("other-host");
let other_path = remote_path_for_host(&other_host, "/other-repo");
model.record_navigated_directory(session(1), &remote_path("/repo"), true);
model.record_navigated_directory(session(2), &other_path, true);
model.mark_host_unavailable(&host);
assert_eq!(
model.active_git_repo_paths_needing_auto_index(),
vec![other_path]
);
}
@@ -0,0 +1,135 @@
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use ::ai::index::full_source_code_embedding::manager::{
CodebaseIndexFinishedStatus, CodebaseIndexStatus as LocalCodebaseIndexStatus,
};
use ::ai::index::full_source_code_embedding::SyncProgress;
use super::proto::{CodebaseIndexStatus, CodebaseIndexStatusState};
fn current_epoch_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or_default()
}
pub(super) fn queued_codebase_index_status(repo_path: String) -> CodebaseIndexStatus {
base_codebase_index_status(repo_path, CodebaseIndexStatusState::Queued)
}
pub(super) fn not_enabled_codebase_index_status(repo_path: String) -> CodebaseIndexStatus {
base_codebase_index_status(repo_path, CodebaseIndexStatusState::NotEnabled)
}
pub(super) fn disabled_codebase_index_status(repo_path: String) -> CodebaseIndexStatus {
base_codebase_index_status(repo_path, CodebaseIndexStatusState::Disabled)
}
pub(super) fn unavailable_codebase_index_status(
repo_path: String,
failure_message: String,
) -> CodebaseIndexStatus {
CodebaseIndexStatus {
failure_message: Some(failure_message),
..base_codebase_index_status(repo_path, CodebaseIndexStatusState::Unavailable)
}
}
fn base_codebase_index_status(
repo_path: String,
state: CodebaseIndexStatusState,
) -> CodebaseIndexStatus {
CodebaseIndexStatus {
repo_path,
state: state.into(),
last_updated_epoch_millis: Some(current_epoch_millis()),
progress_completed: None,
progress_total: None,
failure_message: None,
root_hash: None,
}
}
pub(super) fn codebase_index_status_to_proto(
repo_path: &Path,
status: &LocalCodebaseIndexStatus,
) -> CodebaseIndexStatus {
let state = codebase_index_status_state(status);
let (progress_completed, progress_total) = progress_from_codebase_index_status(status);
CodebaseIndexStatus {
repo_path: repo_path.to_string_lossy().to_string(),
state: state.into(),
last_updated_epoch_millis: Some(current_epoch_millis()),
progress_completed,
progress_total,
failure_message: failure_message_from_codebase_index_status(status),
root_hash: status.root_hash().map(|hash| hash.to_string()),
}
}
fn codebase_index_status_state(status: &LocalCodebaseIndexStatus) -> CodebaseIndexStatusState {
codebase_index_status_state_from_parts(
status.has_pending(),
status.has_synced_version(),
status.last_sync_result(),
)
}
fn codebase_index_status_state_from_parts(
has_pending: bool,
has_synced_version: bool,
last_sync_result: Option<&CodebaseIndexFinishedStatus>,
) -> CodebaseIndexStatusState {
match (has_synced_version, has_pending, last_sync_result) {
(true, false, Some(CodebaseIndexFinishedStatus::Completed)) => {
CodebaseIndexStatusState::Ready
}
// Match local search behavior: any status with a synced root can still serve search
// requests from that last good root while new incremental work catches up.
(true, _, _) => CodebaseIndexStatusState::Stale,
(false, true, _) => CodebaseIndexStatusState::Indexing,
(false, false, Some(CodebaseIndexFinishedStatus::Failed(_))) => {
CodebaseIndexStatusState::Failed
}
(false, false, Some(CodebaseIndexFinishedStatus::Completed)) => {
CodebaseIndexStatusState::Ready
}
(false, false, None) => CodebaseIndexStatusState::Queued,
}
}
fn progress_from_sync_progress(sync_progress: Option<&SyncProgress>) -> (Option<u64>, Option<u64>) {
match sync_progress {
Some(SyncProgress::Discovering { total_nodes }) => (Some(0), Some(*total_nodes as u64)),
Some(SyncProgress::Syncing {
completed_nodes,
total_nodes,
}) => (Some(*completed_nodes as u64), Some(*total_nodes as u64)),
None => (None, None),
}
}
fn progress_from_codebase_index_status(
status: &LocalCodebaseIndexStatus,
) -> (Option<u64>, Option<u64>) {
progress_from_sync_progress(status.sync_progress())
}
fn failure_message_from_last_sync_result(
last_sync_result: Option<&CodebaseIndexFinishedStatus>,
) -> Option<String> {
match last_sync_result {
Some(CodebaseIndexFinishedStatus::Failed(error)) => Some(error.to_string()),
Some(CodebaseIndexFinishedStatus::Completed) | None => None,
}
}
fn failure_message_from_codebase_index_status(status: &LocalCodebaseIndexStatus) -> Option<String> {
failure_message_from_last_sync_result(status.last_sync_result())
}
#[cfg(test)]
#[path = "codebase_index_status_tests.rs"]
mod tests;
@@ -0,0 +1,76 @@
use ::ai::index::full_source_code_embedding::manager::CodebaseIndexingError;
use super::*;
#[test]
fn pending_codebase_index_without_synced_version_maps_to_indexing() {
assert_eq!(
codebase_index_status_state_from_parts(true, false, None),
CodebaseIndexStatusState::Indexing
);
}
#[test]
fn pending_codebase_index_with_synced_version_maps_to_stale() {
assert_eq!(
codebase_index_status_state_from_parts(true, true, None),
CodebaseIndexStatusState::Stale
);
}
#[test]
fn completed_codebase_index_maps_to_ready() {
let result = CodebaseIndexFinishedStatus::Completed;
assert_eq!(
codebase_index_status_state_from_parts(false, true, Some(&result)),
CodebaseIndexStatusState::Ready
);
}
#[test]
fn syncing_codebase_index_with_synced_version_maps_to_stale() {
assert_eq!(
codebase_index_status_state_from_parts(false, true, None),
CodebaseIndexStatusState::Stale
);
}
#[test]
fn failed_codebase_index_with_synced_version_maps_to_stale() {
let result = CodebaseIndexFinishedStatus::Failed(CodebaseIndexingError::BuildTreeError);
assert_eq!(
codebase_index_status_state_from_parts(false, true, Some(&result)),
CodebaseIndexStatusState::Stale
);
}
#[test]
fn failed_codebase_index_maps_to_failed_and_includes_message() {
let result = CodebaseIndexFinishedStatus::Failed(CodebaseIndexingError::BuildTreeError);
assert_eq!(
codebase_index_status_state_from_parts(false, false, Some(&result)),
CodebaseIndexStatusState::Failed
);
assert_eq!(
failure_message_from_last_sync_result(Some(&result)).as_deref(),
Some("Build tree error")
);
}
#[test]
fn sync_progress_maps_to_remote_progress_fields() {
assert_eq!(
progress_from_sync_progress(Some(&SyncProgress::Discovering { total_nodes: 5 })),
(Some(0), Some(5))
);
assert_eq!(
progress_from_sync_progress(Some(&SyncProgress::Syncing {
completed_nodes: 3,
total_nodes: 8,
})),
(Some(3), Some(8))
);
assert_eq!(progress_from_sync_progress(None), (None, None));
}
+706
View File
@@ -0,0 +1,706 @@
//! Conversion between diff state Rust types and proto-generated types.
//!
//! Rust types are canonical, proto types are the wire format.
//! Only the directions needed by the server are implemented here.
//!
//! This module lives in `app/` (rather than in the `remote_server` crate alongside
//! `repo_metadata_proto`) because it depends on app-level types
//! (`code_review::diff_state`, `util::git`) that are not available in the crate.
use std::sync::Arc;
use warp_util::standardized_path::StandardizedPath;
use super::proto;
use crate::code_review::diff_size_limits::{DiffSize, UnrenderableReason, MAX_DIFF_SIZE};
use crate::code_review::diff_state::{
CommitChainMode, DiffHunk, DiffLine, DiffLineType, DiffMetadata, DiffMetadataAgainstBase,
DiffMode, DiffState, DiffStats, FileDiff, FileDiffAndContent, FileStatusInfo, GitDiffData,
GitDiffWithBaseContent, GitFileStatus,
};
use crate::util::git::{Commit, FileChangeEntry, PrInfo};
// ── Proto → Rust (for incoming client messages) ────────────────────
impl From<&proto::DiffMode> for DiffMode {
fn from(proto_mode: &proto::DiffMode) -> Self {
match &proto_mode.mode {
Some(proto::diff_mode::Mode::Head(_)) | None => DiffMode::Head,
Some(proto::diff_mode::Mode::MainBranch(_)) => DiffMode::MainBranch,
Some(proto::diff_mode::Mode::OtherBranch(ob)) => {
DiffMode::OtherBranch(ob.branch_name.clone())
}
}
}
}
impl From<&proto::PrInfo> for PrInfo {
fn from(pr_info: &proto::PrInfo) -> Self {
PrInfo {
number: pr_info.number,
url: pr_info.url.clone(),
state: pr_info.state.clone(),
draft: pr_info.draft,
base_branch: pr_info.base_branch.clone(),
}
}
}
impl TryFrom<&proto::GitFileStatus> for GitFileStatus {
type Error = String;
fn try_from(proto_status: &proto::GitFileStatus) -> Result<Self, Self::Error> {
match &proto_status.status {
Some(proto::git_file_status::Status::NewFile(_)) => Ok(GitFileStatus::New),
Some(proto::git_file_status::Status::Modified(_)) => Ok(GitFileStatus::Modified),
Some(proto::git_file_status::Status::Deleted(_)) => Ok(GitFileStatus::Deleted),
Some(proto::git_file_status::Status::Renamed(r)) => Ok(GitFileStatus::Renamed {
old_path: r.old_path.clone(),
}),
Some(proto::git_file_status::Status::Copied(c)) => Ok(GitFileStatus::Copied {
old_path: c.old_path.clone(),
}),
Some(proto::git_file_status::Status::Untracked(_)) => Ok(GitFileStatus::Untracked),
Some(proto::git_file_status::Status::Conflicted(_)) => Ok(GitFileStatus::Conflicted),
None => Err("missing status variant in GitFileStatus".to_string()),
}
}
}
impl TryFrom<&proto::FileStatusInfo> for FileStatusInfo {
type Error = String;
fn try_from(proto_info: &proto::FileStatusInfo) -> Result<Self, Self::Error> {
let path = StandardizedPath::try_new(&proto_info.path).map_err(|e| e.to_string())?;
let status: GitFileStatus = proto_info
.status
.as_ref()
.ok_or_else(|| "missing status in FileStatusInfo".to_string())
.and_then(GitFileStatus::try_from)?;
// Validate old_path in Renamed/Copied variants — these also flow
// into git restore/checkout commands during discard.
match &status {
GitFileStatus::Renamed { old_path } | GitFileStatus::Copied { old_path } => {
StandardizedPath::try_new(old_path).map_err(|e| e.to_string())?;
}
_ => {}
}
Ok(FileStatusInfo { path, status })
}
}
impl From<&proto::DiffStats> for DiffStats {
fn from(stats: &proto::DiffStats) -> Self {
DiffStats {
files_changed: stats.files_changed as usize,
total_additions: stats.total_additions as usize,
total_deletions: stats.total_deletions as usize,
}
}
}
impl TryFrom<&proto::DiffMetadataAgainstBase> for DiffMetadataAgainstBase {
type Error = String;
fn try_from(base: &proto::DiffMetadataAgainstBase) -> Result<Self, Self::Error> {
Ok(DiffMetadataAgainstBase {
aggregate_stats: base
.aggregate_stats
.as_ref()
.map(DiffStats::from)
.ok_or_else(|| "missing aggregate_stats in DiffMetadataAgainstBase".to_string())?,
files: base.files.iter().map(FileChangeEntry::from).collect(),
})
}
}
impl From<&proto::FileChangeEntry> for FileChangeEntry {
fn from(file: &proto::FileChangeEntry) -> Self {
FileChangeEntry {
path: file.path.clone(),
additions: file.additions as usize,
deletions: file.deletions as usize,
}
}
}
impl From<&proto::Commit> for Commit {
fn from(commit: &proto::Commit) -> Self {
Commit {
hash: commit.hash.clone(),
subject: commit.subject.clone(),
files_changed: commit.files_changed as usize,
additions: commit.additions as usize,
deletions: commit.deletions as usize,
files: commit.files.iter().map(FileChangeEntry::from).collect(),
}
}
}
impl TryFrom<&proto::DiffMetadata> for DiffMetadata {
type Error = String;
fn try_from(metadata: &proto::DiffMetadata) -> Result<Self, Self::Error> {
Ok(DiffMetadata {
main_branch_name: metadata.main_branch_name.clone(),
current_branch_name: metadata.current_branch_name.clone(),
against_head: metadata
.against_head
.as_ref()
.ok_or_else(|| "missing against_head in DiffMetadata".to_string())
.and_then(DiffMetadataAgainstBase::try_from)?,
against_base_branch: metadata
.against_base_branch
.as_ref()
.map(DiffMetadataAgainstBase::try_from)
.transpose()?,
has_head_commit: metadata.has_head_commit,
unpushed_commits: metadata.unpushed_commits.iter().map(Commit::from).collect(),
upstream_ref: metadata.upstream_ref.clone(),
})
}
}
impl TryFrom<proto::DiffLineType> for DiffLineType {
type Error = String;
fn try_from(t: proto::DiffLineType) -> Result<Self, Self::Error> {
match t {
proto::DiffLineType::Context => Ok(DiffLineType::Context),
proto::DiffLineType::Add => Ok(DiffLineType::Add),
proto::DiffLineType::Delete => Ok(DiffLineType::Delete),
proto::DiffLineType::HunkHeader => Ok(DiffLineType::HunkHeader),
proto::DiffLineType::Unspecified => Err("missing DiffLineType".to_string()),
}
}
}
impl TryFrom<&proto::DiffLine> for DiffLine {
type Error = String;
fn try_from(l: &proto::DiffLine) -> Result<Self, Self::Error> {
let line_type = proto::DiffLineType::try_from(l.line_type)
.map_err(|_| format!("invalid DiffLineType value {}", l.line_type))
.and_then(DiffLineType::try_from)?;
Ok(DiffLine {
line_type,
old_line_number: l.old_line_number.map(|n| n as usize),
new_line_number: l.new_line_number.map(|n| n as usize),
text: l.text.clone(),
no_trailing_newline: l.no_trailing_newline,
})
}
}
impl TryFrom<&proto::DiffHunk> for DiffHunk {
type Error = String;
fn try_from(hunk: &proto::DiffHunk) -> Result<Self, Self::Error> {
Ok(DiffHunk {
old_start_line: hunk.old_start_line as usize,
old_line_count: hunk.old_line_count as usize,
new_start_line: hunk.new_start_line as usize,
new_line_count: hunk.new_line_count as usize,
lines: hunk
.lines
.iter()
.map(DiffLine::try_from)
.collect::<Result<Vec<_>, _>>()?,
unified_diff_start: hunk.unified_diff_start as usize,
unified_diff_end: hunk.unified_diff_end as usize,
})
}
}
impl TryFrom<proto::DiffSize> for DiffSize {
type Error = String;
fn try_from(s: proto::DiffSize) -> Result<Self, Self::Error> {
match s {
proto::DiffSize::Normal => Ok(DiffSize::Normal),
proto::DiffSize::Large => Ok(DiffSize::Large),
proto::DiffSize::UnrenderableDiffTooLarge => {
Ok(DiffSize::Unrenderable(UnrenderableReason::DiffTooLarge))
}
proto::DiffSize::UnrenderableFileTooLarge => {
Ok(DiffSize::Unrenderable(UnrenderableReason::FileTooLarge))
}
proto::DiffSize::Unspecified => Err("missing DiffSize".to_string()),
}
}
}
impl TryFrom<&proto::FileDiff> for FileDiff {
type Error = String;
fn try_from(file: &proto::FileDiff) -> Result<Self, Self::Error> {
if file.file_path.is_empty() {
return Err("missing file path in FileDiff".to_string());
}
let status = file
.status
.as_ref()
.ok_or_else(|| "missing status in FileDiff".to_string())
.and_then(GitFileStatus::try_from)?;
let hunks = file
.hunks
.iter()
.map(DiffHunk::try_from)
.collect::<Result<Vec<_>, _>>()?;
let size = proto::DiffSize::try_from(file.size)
.map_err(|_| format!("invalid DiffSize value {}", file.size))
.and_then(DiffSize::try_from)?;
Ok(FileDiff {
file_path: file.file_path.clone(),
status,
hunks: Arc::new(hunks),
is_binary: file.is_binary,
is_autogenerated: file.is_autogenerated,
max_line_number: file.max_line_number as usize,
has_hidden_bidi_chars: file.has_hidden_bidi_chars,
size,
})
}
}
impl TryFrom<&proto::FileDiff> for FileDiffAndContent {
type Error = String;
fn try_from(file: &proto::FileDiff) -> Result<Self, Self::Error> {
Ok(Self {
file_diff: FileDiff::try_from(file)?,
content_at_head: file.content_at_base.clone(),
})
}
}
impl TryFrom<&proto::GitDiffData> for GitDiffData {
type Error = String;
fn try_from(data: &proto::GitDiffData) -> Result<Self, Self::Error> {
Ok(GitDiffData {
files: data
.files
.iter()
.map(FileDiff::try_from)
.collect::<Result<Vec<_>, _>>()?,
total_additions: data.total_additions as usize,
total_deletions: data.total_deletions as usize,
files_changed: data.files_changed as usize,
})
}
}
impl TryFrom<&proto::GitDiffData> for GitDiffWithBaseContent {
type Error = String;
fn try_from(data: &proto::GitDiffData) -> Result<Self, Self::Error> {
Ok(Self {
files: data
.files
.iter()
.map(FileDiffAndContent::try_from)
.collect::<Result<Vec<_>, _>>()?,
total_additions: data.total_additions as usize,
total_deletions: data.total_deletions as usize,
files_changed: data.files_changed as usize,
})
}
}
impl TryFrom<Option<&proto::DiffState>> for DiffState {
type Error = String;
fn try_from(state: Option<&proto::DiffState>) -> Result<Self, String> {
let state = state.ok_or_else(|| "missing DiffState".to_string())?;
match &state.state {
Some(proto::diff_state::State::NotInRepository(_)) => Ok(DiffState::NotInRepository),
Some(proto::diff_state::State::Loading(_)) => Ok(DiffState::Loading),
Some(proto::diff_state::State::Error(e)) => Ok(DiffState::Error(e.message.clone())),
Some(proto::diff_state::State::Loaded(_)) => Ok(DiffState::Loaded),
None => Err("missing DiffState variant".to_string()),
}
}
}
/// Decodes a `DiffStateSnapshot` wire message into the domain types consumed
/// by `RemoteDiffStateModel`, short-circuiting on the first conversion error.
pub(crate) fn try_decode_snapshot(
snapshot: &proto::DiffStateSnapshot,
) -> Result<
(
Option<DiffMetadata>,
DiffState,
Option<GitDiffWithBaseContent>,
),
String,
> {
let metadata = snapshot
.metadata
.as_ref()
.map(DiffMetadata::try_from)
.transpose()?;
let state = DiffState::try_from(snapshot.state.as_ref())?;
let diffs = snapshot
.diffs
.as_ref()
.map(GitDiffWithBaseContent::try_from)
.transpose()?;
Ok((metadata, state, diffs))
}
/// Decodes a `DiffStateFileDelta` wire message into the domain types consumed
/// by `RemoteDiffStateModel`, short-circuiting on the first conversion error.
pub(crate) fn try_decode_file_delta(
delta: &proto::DiffStateFileDelta,
) -> Result<(String, Option<FileDiffAndContent>, Option<DiffMetadata>), String> {
if delta.file_path.is_empty() {
return Err("missing file path in DiffStateFileDelta".to_string());
}
let diff = delta
.diff
.as_ref()
.map(FileDiffAndContent::try_from)
.transpose()?;
let metadata = delta
.metadata
.as_ref()
.map(DiffMetadata::try_from)
.transpose()?;
Ok((delta.file_path.clone(), diff, metadata))
}
// ── Rust → Proto (for server pushes) ─────────────────────────────────────
impl From<&DiffMode> for proto::DiffMode {
fn from(mode: &DiffMode) -> Self {
let mode_oneof = match mode {
DiffMode::Head => proto::diff_mode::Mode::Head(proto::DiffModeHead {}),
DiffMode::MainBranch => {
proto::diff_mode::Mode::MainBranch(proto::DiffModeMainBranch {})
}
DiffMode::OtherBranch(branch) => {
proto::diff_mode::Mode::OtherBranch(proto::DiffModeOtherBranch {
branch_name: branch.clone(),
})
}
};
proto::DiffMode {
mode: Some(mode_oneof),
}
}
}
impl From<&DiffStats> for proto::DiffStats {
fn from(stats: &DiffStats) -> Self {
proto::DiffStats {
files_changed: stats.files_changed as u64,
total_additions: stats.total_additions as u64,
total_deletions: stats.total_deletions as u64,
}
}
}
impl From<&DiffMetadataAgainstBase> for proto::DiffMetadataAgainstBase {
fn from(m: &DiffMetadataAgainstBase) -> Self {
proto::DiffMetadataAgainstBase {
aggregate_stats: Some((&m.aggregate_stats).into()),
files: m.files.iter().map(proto::FileChangeEntry::from).collect(),
}
}
}
impl From<&FileChangeEntry> for proto::FileChangeEntry {
fn from(file: &FileChangeEntry) -> Self {
proto::FileChangeEntry {
path: file.path.clone(),
additions: file.additions as u64,
deletions: file.deletions as u64,
}
}
}
impl From<&Commit> for proto::Commit {
fn from(c: &Commit) -> Self {
proto::Commit {
hash: c.hash.clone(),
subject: c.subject.clone(),
files_changed: c.files_changed as u64,
additions: c.additions as u64,
deletions: c.deletions as u64,
files: c.files.iter().map(proto::FileChangeEntry::from).collect(),
}
}
}
impl From<&PrInfo> for proto::PrInfo {
fn from(pr_info: &PrInfo) -> Self {
proto::PrInfo {
number: pr_info.number,
url: pr_info.url.clone(),
state: pr_info.state.clone(),
draft: pr_info.draft,
base_branch: pr_info.base_branch.clone(),
}
}
}
impl From<&DiffMetadata> for proto::DiffMetadata {
fn from(m: &DiffMetadata) -> Self {
proto::DiffMetadata {
main_branch_name: m.main_branch_name.clone(),
current_branch_name: m.current_branch_name.clone(),
against_head: Some((&m.against_head).into()),
against_base_branch: m.against_base_branch.as_ref().map(|b| b.into()),
has_head_commit: m.has_head_commit,
unpushed_commits: m.unpushed_commits.iter().map(proto::Commit::from).collect(),
upstream_ref: m.upstream_ref.clone(),
}
}
}
impl From<&GitFileStatus> for proto::GitFileStatus {
fn from(s: &GitFileStatus) -> Self {
let status = match s {
GitFileStatus::New => {
proto::git_file_status::Status::NewFile(proto::GitFileStatusNew {})
}
GitFileStatus::Modified => {
proto::git_file_status::Status::Modified(proto::GitFileStatusModified {})
}
GitFileStatus::Deleted => {
proto::git_file_status::Status::Deleted(proto::GitFileStatusDeleted {})
}
GitFileStatus::Renamed { old_path } => {
proto::git_file_status::Status::Renamed(proto::GitFileStatusRenamed {
old_path: old_path.clone(),
})
}
GitFileStatus::Copied { old_path } => {
proto::git_file_status::Status::Copied(proto::GitFileStatusCopied {
old_path: old_path.clone(),
})
}
GitFileStatus::Untracked => {
proto::git_file_status::Status::Untracked(proto::GitFileStatusUntracked {})
}
GitFileStatus::Conflicted => {
proto::git_file_status::Status::Conflicted(proto::GitFileStatusConflicted {})
}
};
proto::GitFileStatus {
status: Some(status),
}
}
}
impl From<&FileStatusInfo> for proto::FileStatusInfo {
fn from(info: &FileStatusInfo) -> Self {
proto::FileStatusInfo {
path: info.path.to_string(),
status: Some((&info.status).into()),
}
}
}
impl From<&DiffLineType> for proto::DiffLineType {
fn from(t: &DiffLineType) -> Self {
match t {
DiffLineType::Context => proto::DiffLineType::Context,
DiffLineType::Add => proto::DiffLineType::Add,
DiffLineType::Delete => proto::DiffLineType::Delete,
DiffLineType::HunkHeader => proto::DiffLineType::HunkHeader,
}
}
}
impl From<&DiffLine> for proto::DiffLine {
fn from(l: &DiffLine) -> Self {
proto::DiffLine {
line_type: proto::DiffLineType::from(&l.line_type).into(),
old_line_number: l.old_line_number.map(|n| n as u64),
new_line_number: l.new_line_number.map(|n| n as u64),
text: l.text.clone(),
no_trailing_newline: l.no_trailing_newline,
}
}
}
impl From<&DiffHunk> for proto::DiffHunk {
fn from(h: &DiffHunk) -> Self {
proto::DiffHunk {
old_start_line: h.old_start_line as u64,
old_line_count: h.old_line_count as u64,
new_start_line: h.new_start_line as u64,
new_line_count: h.new_line_count as u64,
lines: h.lines.iter().map(proto::DiffLine::from).collect(),
unified_diff_start: h.unified_diff_start as u64,
unified_diff_end: h.unified_diff_end as u64,
}
}
}
impl From<&DiffSize> for proto::DiffSize {
fn from(s: &DiffSize) -> Self {
match s {
DiffSize::Normal => proto::DiffSize::Normal,
DiffSize::Large => proto::DiffSize::Large,
DiffSize::Unrenderable(UnrenderableReason::DiffTooLarge) => {
proto::DiffSize::UnrenderableDiffTooLarge
}
DiffSize::Unrenderable(UnrenderableReason::FileTooLarge) => {
proto::DiffSize::UnrenderableFileTooLarge
}
}
}
}
impl From<&CommitChainMode> for proto::GitCommitChainMode {
fn from(mode: &CommitChainMode) -> Self {
match mode {
CommitChainMode::CommitOnly => proto::GitCommitChainMode::CommitOnly,
CommitChainMode::CommitAndPush => proto::GitCommitChainMode::CommitAndPush,
CommitChainMode::CommitAndCreatePr => proto::GitCommitChainMode::CommitAndCreatePr,
}
}
}
impl From<proto::GitCommitChainMode> for CommitChainMode {
fn from(mode: proto::GitCommitChainMode) -> Self {
match mode {
proto::GitCommitChainMode::CommitOnly => CommitChainMode::CommitOnly,
proto::GitCommitChainMode::CommitAndPush => CommitChainMode::CommitAndPush,
proto::GitCommitChainMode::CommitAndCreatePr => CommitChainMode::CommitAndCreatePr,
}
}
}
impl From<&DiffState> for proto::DiffState {
fn from(state: &DiffState) -> Self {
let state_oneof = match state {
DiffState::NotInRepository => {
proto::diff_state::State::NotInRepository(proto::DiffStateNotInRepository {})
}
DiffState::Loading => proto::diff_state::State::Loading(proto::DiffStateLoading {}),
DiffState::Error(msg) => proto::diff_state::State::Error(proto::DiffStateErrorValue {
message: msg.clone(),
}),
DiffState::Loaded => proto::diff_state::State::Loaded(proto::DiffStateLoaded {}),
// Disconnected is a client-only state; the server never
// serialises it. Map to Loading as a safe fallback.
DiffState::Disconnected => {
proto::diff_state::State::Loading(proto::DiffStateLoading {})
}
};
proto::DiffState {
state: Some(state_oneof),
}
}
}
/// Converts a `FileDiff` to proto with an optional `content_at_base`.
/// Cannot be a `From` impl because of the extra parameter.
pub fn file_diff_to_proto(f: &FileDiff, content_at_base: Option<&str>) -> proto::FileDiff {
// Decide what base content (if any) to send over the wire, adjusting the
// rendered size accordingly. This gating is remote-only: it runs when the
// daemon serializes a diff for a subscriber, never on the local in-memory
// path, so local rendering keeps full content regardless of size.
let (size, content_at_base) = if f.is_binary {
// Binary base content is never rendered by the client; never ship it.
(f.size, None)
} else if content_at_base.is_some_and(|c| c.len() > MAX_DIFF_SIZE) {
// Base blob too large for the wire and won't be rendered by the client.
(
DiffSize::Unrenderable(UnrenderableReason::FileTooLarge),
None,
)
} else {
(f.size, content_at_base)
};
proto::FileDiff {
file_path: f.file_path.clone(),
status: Some((&f.status).into()),
hunks: f.hunks.iter().map(proto::DiffHunk::from).collect(),
is_binary: f.is_binary,
is_autogenerated: f.is_autogenerated,
max_line_number: f.max_line_number as u64,
has_hidden_bidi_chars: f.has_hidden_bidi_chars,
size: proto::DiffSize::from(&size).into(),
content_at_base: content_at_base.map(|s| s.to_string()),
}
}
fn file_diff_and_content_to_proto(f: &FileDiffAndContent) -> proto::FileDiff {
file_diff_to_proto(&f.file_diff, f.content_at_head.as_deref())
}
fn git_diff_with_base_content_to_proto(d: &GitDiffWithBaseContent) -> proto::GitDiffData {
proto::GitDiffData {
files: d.files.iter().map(file_diff_and_content_to_proto).collect(),
total_additions: d.total_additions as u64,
total_deletions: d.total_deletions as u64,
files_changed: d.files_changed as u64,
}
}
// ── Higher-level message builders ───────────────────────────────────
/// Builds a `DiffStateSnapshot` proto message.
///
/// Accepts an optional `GitDiffWithBaseContent` and converts it to proto
/// internally. Pass `None` for terminal states (Error, NotInRepository)
/// or when diffs are not yet available.
pub fn build_diff_state_snapshot(
repo_path: &str,
mode: &DiffMode,
metadata: Option<&DiffMetadata>,
state: &DiffState,
diffs: Option<&GitDiffWithBaseContent>,
) -> proto::DiffStateSnapshot {
proto::DiffStateSnapshot {
repo_path: repo_path.to_string(),
mode: Some(mode.into()),
metadata: metadata.map(proto::DiffMetadata::from),
state: Some(state.into()),
diffs: diffs.map(git_diff_with_base_content_to_proto),
}
}
/// Builds a `DiffStateMetadataUpdate` proto message.
pub fn build_diff_state_metadata_update(
repo_path: &str,
mode: &DiffMode,
metadata: &DiffMetadata,
) -> proto::DiffStateMetadataUpdate {
proto::DiffStateMetadataUpdate {
repo_path: repo_path.to_string(),
mode: Some(mode.into()),
metadata: Some(metadata.into()),
}
}
/// Builds a `DiffStateFileDelta` proto message.
pub fn build_diff_state_file_delta(
repo_path: &str,
mode: &DiffMode,
repo_relative_path: &str,
diff: Option<&FileDiffAndContent>,
metadata: Option<&DiffMetadata>,
) -> proto::DiffStateFileDelta {
proto::DiffStateFileDelta {
repo_path: repo_path.to_string(),
mode: Some(mode.into()),
file_path: repo_relative_path.to_string(),
diff: diff.map(file_diff_and_content_to_proto),
metadata: metadata.map(proto::DiffMetadata::from),
}
}
#[cfg(test)]
#[path = "diff_state_proto_tests.rs"]
mod tests;
@@ -0,0 +1,264 @@
use std::sync::Arc;
use warp_util::standardized_path::StandardizedPath;
use super::super::proto;
use crate::code_review::diff_size_limits::{DiffSize, UnrenderableReason, MAX_DIFF_SIZE};
use crate::code_review::diff_state::{
DiffMetadata, DiffMetadataAgainstBase, DiffMode, DiffState, FileDiff, FileDiffAndContent,
FileStatusInfo, GitDiffWithBaseContent, GitFileStatus,
};
use crate::util::git::PrInfo;
// ── FileStatusInfo path validation (TryFrom) ────────────────────
#[test]
fn file_status_info_valid_absolute_path() {
let proto_info = proto::FileStatusInfo {
path: "/repo/src/main.rs".into(),
status: Some(proto::GitFileStatus {
status: Some(proto::git_file_status::Status::NewFile(
proto::GitFileStatusNew {},
)),
}),
};
let info = FileStatusInfo::try_from(&proto_info).unwrap();
assert_eq!(
info.path,
StandardizedPath::try_new("/repo/src/main.rs").unwrap()
);
assert_eq!(info.status, GitFileStatus::New);
}
#[test]
fn file_status_info_missing_status_is_error() {
let proto_info = proto::FileStatusInfo {
path: "/repo/file.rs".into(),
status: None,
};
assert!(FileStatusInfo::try_from(&proto_info).is_err());
}
#[test]
fn file_status_info_missing_status_variant_is_error() {
let proto_info = proto::FileStatusInfo {
path: "/repo/file.rs".into(),
status: Some(proto::GitFileStatus { status: None }),
};
assert!(FileStatusInfo::try_from(&proto_info).is_err());
}
#[test]
fn file_status_info_validates_renamed_old_path() {
let proto_info = proto::FileStatusInfo {
path: "/repo/new_name.rs".into(),
status: Some(proto::GitFileStatus {
status: Some(proto::git_file_status::Status::Renamed(
proto::GitFileStatusRenamed {
old_path: "relative/old.rs".into(),
},
)),
}),
};
// old_path is relative — should fail validation.
assert!(FileStatusInfo::try_from(&proto_info).is_err());
}
#[test]
fn diff_metadata_requires_against_head() {
let metadata = proto::DiffMetadata {
main_branch_name: "main".into(),
current_branch_name: "feature".into(),
against_head: None,
against_base_branch: None,
has_head_commit: true,
unpushed_commits: vec![],
upstream_ref: None,
};
assert!(DiffMetadata::try_from(&metadata).is_err());
}
#[test]
fn diff_metadata_against_base_requires_stats() {
let against_base = proto::DiffMetadataAgainstBase {
aggregate_stats: None,
files: vec![],
};
assert!(DiffMetadataAgainstBase::try_from(&against_base).is_err());
}
#[test]
fn pr_info_round_trips_through_proto() {
let pr_info = PrInfo {
number: 42,
url: "https://github.com/warpdotdev/Warp/pull/42".into(),
state: "OPEN".into(),
draft: true,
base_branch: "develop".into(),
};
let proto_info = proto::PrInfo::from(&pr_info);
let decoded = PrInfo::from(&proto_info);
assert_eq!(decoded, pr_info);
}
#[test]
fn file_diff_to_proto_preserves_repo_relative_path() {
let file_diff = FileDiff {
file_path: "src/main.rs".to_string(),
status: GitFileStatus::Modified,
hunks: Arc::new(vec![]),
is_binary: false,
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
size: DiffSize::Normal,
};
let proto_diff = super::file_diff_to_proto(&file_diff, None);
assert_eq!(proto_diff.file_path, "src/main.rs");
}
#[test]
fn build_diff_state_snapshot_preserves_repo_relative_file_paths() {
let diff = FileDiffAndContent {
file_diff: FileDiff {
file_path: "src/main.rs".to_string(),
status: GitFileStatus::Modified,
hunks: Arc::new(vec![]),
is_binary: false,
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
size: DiffSize::Normal,
},
content_at_head: None,
};
let diffs = GitDiffWithBaseContent {
files: vec![diff],
total_additions: 0,
total_deletions: 0,
files_changed: 1,
};
let snapshot = super::build_diff_state_snapshot(
"/repo",
&DiffMode::Head,
None,
&DiffState::Loaded,
Some(&diffs),
);
assert_eq!(
snapshot
.diffs
.expect("snapshot should include diffs")
.files
.first()
.expect("snapshot should include a file")
.file_path,
"src/main.rs"
);
}
#[test]
fn build_diff_state_file_delta_preserves_repo_relative_file_path() {
let delta =
super::build_diff_state_file_delta("/repo", &DiffMode::Head, "src/main.rs", None, None);
assert_eq!(delta.file_path, "src/main.rs");
}
/// Builds a minimal text `FileDiff` for content-gating tests.
fn text_file_diff(file_path: &str) -> FileDiff {
FileDiff {
file_path: file_path.to_string(),
status: GitFileStatus::Modified,
hunks: Arc::new(vec![]),
is_binary: false,
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
size: DiffSize::Normal,
}
}
#[test]
fn diff_size_round_trips_through_proto() {
for size in [
DiffSize::Normal,
DiffSize::Large,
DiffSize::Unrenderable(UnrenderableReason::DiffTooLarge),
DiffSize::Unrenderable(UnrenderableReason::FileTooLarge),
] {
let proto_size = proto::DiffSize::from(&size);
let decoded = DiffSize::try_from(proto_size).expect("DiffSize should decode");
assert_eq!(decoded, size);
}
}
#[test]
fn file_diff_to_proto_drops_binary_content() {
let mut file_diff = text_file_diff("image.png");
file_diff.is_binary = true;
let proto_diff = super::file_diff_to_proto(&file_diff, Some("binary blob content"));
assert!(proto_diff.is_binary);
assert_eq!(proto_diff.content_at_base, None);
// Size is untouched for binary files; the client renders the binary
// placeholder via `is_binary`, not via size.
assert_eq!(proto_diff.size, proto::DiffSize::Normal as i32);
}
#[test]
fn file_diff_to_proto_withholds_oversized_content() {
let file_diff = text_file_diff("huge.txt");
let oversized = "a".repeat(MAX_DIFF_SIZE + 1);
let proto_diff = super::file_diff_to_proto(&file_diff, Some(&oversized));
assert_eq!(proto_diff.content_at_base, None);
assert_eq!(
proto_diff.size,
proto::DiffSize::UnrenderableFileTooLarge as i32
);
}
#[test]
fn file_diff_to_proto_preserves_content_within_budget() {
let file_diff = text_file_diff("small.txt");
let proto_diff = super::file_diff_to_proto(&file_diff, Some("small base content"));
assert_eq!(
proto_diff.content_at_base.as_deref(),
Some("small base content")
);
assert_eq!(proto_diff.size, proto::DiffSize::Normal as i32);
}
#[test]
fn file_diff_to_proto_preserves_content_at_budget_boundary() {
let file_diff = text_file_diff("at_budget.txt");
// The gate uses a strict `>` comparison, so base content of exactly
// MAX_DIFF_SIZE is the largest blob still sent: content is preserved and
// the size stays Normal rather than flipping to UnrenderableFileTooLarge.
let at_budget = "a".repeat(MAX_DIFF_SIZE);
let proto_diff = super::file_diff_to_proto(&file_diff, Some(&at_budget));
assert_eq!(
proto_diff.content_at_base.as_deref(),
Some(at_budget.as_str())
);
assert_eq!(proto_diff.size, proto::DiffSize::Normal as i32);
}
+472
View File
@@ -0,0 +1,472 @@
//! Server-side diff state management.
//!
//! [`RemoteDiffStateManager`] is an entity that manages per-(repo, mode)
//! `LocalDiffStateModel` instances and tracks which connections are subscribed
//! to each. It owns model creation, event subscriptions, and content reload
//! spawning. `ServerModel` subscribes to its `DiffStateUpdate` events to
//! handle proto conversion and wire delivery.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use itertools::Itertools;
use warp_util::standardized_path::StandardizedPath;
use warpui::r#async::SpawnedFutureHandle;
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
use super::protocol::RequestId;
use super::server_model::ConnectionId;
use crate::code_review::diff_state::{
BackendOrigin, DiffMetadata, DiffMode, DiffState, DiffStateModelEvent, FileDiffAndContent,
GitDiffWithBaseContent, LocalDiffStateModel,
};
// ── Key type ────────────────────────────────────────────────────────
/// Composite key: each (repo, mode) gets its own `LocalDiffStateModel`.
#[derive(Hash, Eq, PartialEq, Clone, Debug)]
pub(super) struct DiffModelKey {
pub repo_path: StandardizedPath,
pub mode: DiffMode,
}
// ── Pending response tracker ────────────────────────────────────────
/// Tracks a `GetDiffState` request that arrived while the model was still loading.
/// The response is sent once `NewDiffsComputed` fires.
pub(super) struct PendingDiffStateResponse {
pub request_id: RequestId,
pub conn_id: ConnectionId,
}
// ── Action / outcome types ─────────────────────────────────────
/// Outcome of [`RemoteDiffStateManager::subscribe`].
#[allow(clippy::large_enum_variant)]
pub(super) enum SubscribeOutcome {
/// Respond with this snapshot immediately.
RespondWithSnapshot {
key: DiffModelKey,
state: DiffState,
metadata: Option<DiffMetadata>,
},
/// An async operation is in flight (content reload or model loading).
/// The manager tracks spawned handles internally.
Async,
}
/// Domain-level dispatch action returned by event processing and content
/// reload completion. Proto conversion is handled by `ServerModel` at
/// dispatch time.
pub(super) enum DiffStateUpdate {
/// Build and send a snapshot to subscribers. Entries with a `request_id`
/// receive a `GetDiffStateResponse`; entries without receive a
/// server-initiated push `DiffStateSnapshot`.
Snapshot {
repo_path: String,
mode: DiffMode,
state: DiffState,
metadata: Option<DiffMetadata>,
diffs: Option<Arc<GitDiffWithBaseContent>>,
/// Each subscriber is a connection plus an optional request ID.
/// `Some(request_id)` → pending `GetDiffState` response (sent with request_id).
/// `None` → already-subscribed connection that receives a server-initiated push.
subscribers: Vec<(ConnectionId, Option<RequestId>)>,
},
/// Build and send a metadata update to all subscribers.
MetadataUpdate {
repo_path: StandardizedPath,
mode: DiffMode,
metadata: DiffMetadata,
subscribers: Vec<ConnectionId>,
},
/// Build and send a single-file delta to all subscribers.
FileDelta {
repo_path: StandardizedPath,
mode: DiffMode,
/// Repo-relative path for the changed file.
path: String,
diff: Option<Arc<FileDiffAndContent>>,
metadata: Option<DiffMetadata>,
subscribers: Vec<ConnectionId>,
},
}
// ── RemoteDiffStateManager ────────────────────────────────────────
/// Manages the lifecycle of server-side `LocalDiffStateModel` instances and
/// per-connection subscription tracking.
///
/// A model is created when the first `GetDiffState` arrives for a given key
/// and dropped when the last connection unsubscribes (or disconnects).
pub(super) struct RemoteDiffStateManager {
/// One model per (repo, mode). Mode is immutable — pinned at construction.
states: HashMap<DiffModelKey, ModelHandle<LocalDiffStateModel>>,
/// Per-key set of subscribed connections.
key_to_connections: HashMap<DiffModelKey, HashSet<ConnectionId>>,
/// Pending `GetDiffState` responses waiting for the model to finish loading.
pending_responses: HashMap<DiffModelKey, Vec<PendingDiffStateResponse>>,
/// In-progress content reload handles, keyed by request ID.
in_progress: HashMap<RequestId, SpawnedFutureHandle>,
}
impl Entity for RemoteDiffStateManager {
type Event = DiffStateUpdate;
}
impl RemoteDiffStateManager {
pub fn new() -> Self {
Self {
states: HashMap::new(),
key_to_connections: HashMap::new(),
pending_responses: HashMap::new(),
in_progress: HashMap::new(),
}
}
// ── Model CRUD ──────────────────────────────────────────────────
pub fn get_model(&self, key: &DiffModelKey) -> Option<&ModelHandle<LocalDiffStateModel>> {
self.states.get(key)
}
pub fn insert_model(&mut self, key: DiffModelKey, model: ModelHandle<LocalDiffStateModel>) {
self.states.insert(key, model);
}
pub fn remove_model(&mut self, key: &DiffModelKey) {
self.states.remove(key);
self.pending_responses.remove(key);
self.key_to_connections.remove(key);
}
/// Reads the current `DiffState` and cloned `DiffMetadata` from the model
/// for `key`. Returns `None` when the model is absent.
pub fn read_state_and_metadata(
&self,
key: &DiffModelKey,
app: &AppContext,
) -> Option<(DiffState, Option<DiffMetadata>)> {
self.states.get(key).map(|model| {
let m = model.as_ref(app);
(m.get(), m.metadata().cloned())
})
}
// ── Connection subscription tracking ────────────────────────────
/// Records that `conn_id` is subscribed to `key`.
pub fn subscribe_connection(&mut self, key: DiffModelKey, conn_id: ConnectionId) {
self.key_to_connections
.entry(key)
.or_default()
.insert(conn_id);
}
/// Removes `conn_id`'s subscription for `key`.
/// If the key has zero remaining subscribers the model is dropped inline.
pub fn unsubscribe_connection(&mut self, key: &DiffModelKey, conn_id: ConnectionId) {
if let Some(pending) = self.pending_responses.get_mut(key) {
pending.retain(|p| p.conn_id != conn_id);
}
if let Some(connections) = self.key_to_connections.get_mut(key) {
connections.remove(&conn_id);
if connections.is_empty() {
self.remove_model(key);
}
}
}
/// Removes all subscriptions for a disconnected connection.
/// Orphaned models (no remaining subscribers) are dropped inline.
pub fn remove_connection(&mut self, conn_id: ConnectionId) {
let keys = self
.key_to_connections
.iter()
.filter(|(_, conns)| conns.contains(&conn_id))
.map(|(key, _)| key.clone())
.collect_vec();
for key in keys {
self.unsubscribe_connection(&key, conn_id);
}
}
/// Returns the connection IDs subscribed to `key`.
pub fn subscribed_connections(&self, key: &DiffModelKey) -> Vec<ConnectionId> {
self.key_to_connections
.get(key)
.map(|conns| conns.iter().copied().collect())
.unwrap_or_default()
}
// ── Pending response tracking ───────────────────────────────────
/// Returns `true` if there are pending responses queued for `key`.
pub fn has_pending_responses(&self, key: &DiffModelKey) -> bool {
self.pending_responses
.get(key)
.is_some_and(|v| !v.is_empty())
}
/// Registers a pending `GetDiffState` response to be sent once the model loads.
pub fn add_pending_response(
&mut self,
key: DiffModelKey,
request_id: RequestId,
conn_id: ConnectionId,
) {
self.pending_responses
.entry(key)
.or_default()
.push(PendingDiffStateResponse {
request_id,
conn_id,
});
}
/// Drains all pending responses for `key`.
pub fn drain_pending_responses(&mut self, key: &DiffModelKey) -> Vec<PendingDiffStateResponse> {
self.pending_responses.remove(key).unwrap_or_default()
}
// ── High-level operations ────────────────────────────────────
/// Handles a `GetDiffState` subscription request.
///
/// Subscribes the connection, looks up or creates the model, and returns
/// an outcome describing the result. When a content reload is needed it
/// is spawned internally; when a new model is created the event
/// subscription is wired up internally.
pub fn subscribe(
&mut self,
repo_path: StandardizedPath,
mode: DiffMode,
request_id: &RequestId,
conn_id: ConnectionId,
ctx: &mut ModelContext<Self>,
) -> SubscribeOutcome {
let key = DiffModelKey { repo_path, mode };
self.subscribe_connection(key.clone(), conn_id);
if let Some(model) = self.get_model(&key) {
let model_ref = model.as_ref(ctx);
let state = model_ref.get();
match state {
DiffState::Loaded => {
let already_in_flight = self.has_pending_responses(&key);
self.add_pending_response(key.clone(), request_id.clone(), conn_id);
if !already_in_flight {
self.spawn_content_reload(key, request_id, ctx);
}
SubscribeOutcome::Async
}
DiffState::Error(_) | DiffState::NotInRepository => {
SubscribeOutcome::RespondWithSnapshot {
key,
state,
metadata: model_ref.metadata().cloned(),
}
}
DiffState::Loading | DiffState::Disconnected => {
self.add_pending_response(key, request_id.clone(), conn_id);
SubscribeOutcome::Async
}
}
} else {
// Model doesn't exist — create it and wire up event subscription.
let repo_path_str = key.repo_path.to_string();
let mode = key.mode.clone();
let model = ctx.add_model(|ctx| {
let mut m =
LocalDiffStateModel::new(Some(repo_path_str), BackendOrigin::RemoteDaemon, ctx);
m.set_diff_mode(mode, false, false, ctx);
m.set_code_review_metadata_refresh_enabled(true, ctx);
m
});
self.insert_model(key.clone(), model.clone());
self.add_pending_response(key.clone(), request_id.clone(), conn_id);
let key_for_sub = key;
ctx.subscribe_to_model(&model, move |me, _, event, ctx| {
me.handle_model_event(&key_for_sub, event, ctx);
});
SubscribeOutcome::Async
}
}
/// Processes a `DiffStateModelEvent`, builds domain-level dispatch
/// actions, and emits them as entity events for `ServerModel` to handle.
fn handle_model_event(
&mut self,
key: &DiffModelKey,
event: &DiffStateModelEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
DiffStateModelEvent::NewDiffsComputed { diffs, .. } => {
let Some((state, metadata)) = self.read_state_and_metadata(key, ctx) else {
log::warn!("NewDiffsComputed for absent model key={key:?}");
return;
};
let pending = self.drain_pending_responses(key);
let responded_conns: HashSet<ConnectionId> =
pending.iter().map(|p| p.conn_id).collect();
let mut subscribers: Vec<(ConnectionId, Option<RequestId>)> = pending
.into_iter()
.map(|p| (p.conn_id, Some(p.request_id)))
.collect();
subscribers.extend(
self.subscribed_connections(key)
.into_iter()
.filter(|c| !responded_conns.contains(c))
.map(|c| (c, None)),
);
ctx.emit(DiffStateUpdate::Snapshot {
repo_path: key.repo_path.to_string(),
mode: key.mode.clone(),
state,
metadata,
diffs: diffs.clone(),
subscribers,
});
}
DiffStateModelEvent::MetadataRefreshed(metadata) => {
ctx.emit(DiffStateUpdate::MetadataUpdate {
repo_path: key.repo_path.clone(),
mode: key.mode.clone(),
metadata: metadata.as_ref().clone(),
subscribers: self.subscribed_connections(key),
});
}
DiffStateModelEvent::CurrentBranchChanged => {
let Some(model) = self.get_model(key) else {
return;
};
let Some(metadata) = model.as_ref(ctx).metadata() else {
return;
};
ctx.emit(DiffStateUpdate::MetadataUpdate {
repo_path: key.repo_path.clone(),
mode: key.mode.clone(),
metadata: metadata.clone(),
subscribers: self.subscribed_connections(key),
});
}
DiffStateModelEvent::SingleFileUpdated { path, diff } => {
let metadata = self
.get_model(key)
.and_then(|m| m.as_ref(ctx).metadata().cloned());
ctx.emit(DiffStateUpdate::FileDelta {
repo_path: key.repo_path.clone(),
mode: key.mode.clone(),
path: path.clone(),
diff: diff.clone(),
metadata,
subscribers: self.subscribed_connections(key),
});
}
DiffStateModelEvent::ConnectionLost => {
// Client-only event — should not occur on the server side.
log::warn!("Unexpected ConnectionLost event on server-side model key={key:?}");
}
DiffStateModelEvent::BranchesReceived(_)
| DiffStateModelEvent::GitOpCompleted(_)
| DiffStateModelEvent::CommitMessageGenerated(_)
| DiffStateModelEvent::BranchCommittedFilesReceived(_) => {
// Client-only events don't go through this tracker.
}
}
}
/// Reads model state, drains pending responses, and emits a `Snapshot`
/// dispatch so `ServerModel` can deliver the results to waiting clients.
fn resolve_pending_responses(
&mut self,
key: &DiffModelKey,
diffs: Option<GitDiffWithBaseContent>,
ctx: &mut ModelContext<Self>,
) {
let Some((state, metadata)) = self.read_state_and_metadata(key, ctx) else {
log::warn!("Content reload completed for absent model key={key:?}");
return;
};
let diffs_arc = diffs.map(Arc::new);
let subscribers = self
.drain_pending_responses(key)
.into_iter()
.map(|p| (p.conn_id, Some(p.request_id)))
.collect();
ctx.emit(DiffStateUpdate::Snapshot {
repo_path: key.repo_path.to_string(),
mode: key.mode.clone(),
state,
metadata,
diffs: diffs_arc,
subscribers,
});
}
/// Spawns an async diff reload with `content_at_base` for late-joining subscribers.
fn spawn_content_reload(
&mut self,
key: DiffModelKey,
request_id: &RequestId,
ctx: &mut ModelContext<Self>,
) {
let diff_mode = key.mode.clone();
let repo_path = std::path::PathBuf::from(key.repo_path.as_str());
let resolve_id = request_id.clone();
let abort_id = request_id.clone();
let abort_key = key.clone();
let handle = ctx.spawn_abortable(
async move {
LocalDiffStateModel::load_diffs_with_content_for_mode(diff_mode, repo_path).await
},
move |me, diffs, ctx| {
me.in_progress.remove(&resolve_id);
me.resolve_pending_responses(&key, diffs, ctx);
},
move |me, ctx| {
log::info!("Request cancelled (request_id={abort_id})");
me.in_progress.remove(&abort_id);
// Drain pending responses with current state instead of orphaning them.
me.resolve_pending_responses(&abort_key, None, ctx);
},
);
self.in_progress.insert(request_id.clone(), handle);
}
/// Cancels an in-progress content reload, if one exists for this request.
/// Returns `true` if a request was found and aborted.
pub fn abort_request(&mut self, request_id: &RequestId) -> bool {
if let Some(handle) = self.in_progress.remove(request_id) {
handle.abort();
true
} else {
false
}
}
/// Removes a specific pending response by request_id across all keys.
/// Called by `handle_abort` when the client times out a request.
/// Returns `true` if a pending response was found and removed.
pub fn abort_pending_response(&mut self, request_id: &RequestId) -> bool {
for pending in self.pending_responses.values_mut() {
if let Some(pos) = pending.iter().position(|p| &p.request_id == request_id) {
pending.remove(pos);
return true;
}
}
false
}
}
#[cfg(test)]
#[path = "diff_state_tracker_tests.rs"]
mod tests;
@@ -0,0 +1,332 @@
use warp_util::standardized_path::StandardizedPath;
use super::super::protocol::RequestId;
use super::super::server_model::ConnectionId;
use super::{DiffModelKey, RemoteDiffStateManager};
use crate::code_review::diff_state::{BackendOrigin, DiffMode, LocalDiffStateModel};
/// Uses `try_new` instead of `try_from_local` so that Unix-style paths
/// like `/repo` are recognised as absolute on all platforms (including Windows).
fn test_key(repo: &str, mode: DiffMode) -> DiffModelKey {
DiffModelKey {
repo_path: StandardizedPath::try_new(repo).unwrap(),
mode,
}
}
fn new_conn() -> ConnectionId {
uuid::Uuid::new_v4()
}
// ── Subscription tracking ───────────────────────────────────────────
#[test]
fn subscribe_registers_connection() {
let mut model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
let conn = new_conn();
model.subscribe_connection(key.clone(), conn);
assert_eq!(model.subscribed_connections(&key), vec![conn]);
}
#[test]
fn subscribe_multiple_connections_to_same_key() {
let mut model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
let conn_a = new_conn();
let conn_b = new_conn();
model.subscribe_connection(key.clone(), conn_a);
model.subscribe_connection(key.clone(), conn_b);
let subs = model.subscribed_connections(&key);
assert_eq!(subs.len(), 2);
assert!(subs.contains(&conn_a));
assert!(subs.contains(&conn_b));
}
#[test]
fn subscribe_same_connection_to_different_keys() {
let mut model = RemoteDiffStateManager::new();
let key_head = test_key("/repo", DiffMode::Head);
let key_main = test_key("/repo", DiffMode::MainBranch);
let conn = new_conn();
model.subscribe_connection(key_head.clone(), conn);
model.subscribe_connection(key_main.clone(), conn);
assert_eq!(model.subscribed_connections(&key_head), vec![conn]);
assert_eq!(model.subscribed_connections(&key_main), vec![conn]);
}
#[test]
fn subscribed_connections_returns_empty_for_unknown_key() {
let model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
assert!(model.subscribed_connections(&key).is_empty());
}
// ── Unsubscribe ─────────────────────────────────────────────────────
#[test]
fn unsubscribe_last_connection_removes_model() {
let mut model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
let conn = new_conn();
// Simulate model insertion + subscription (what handle_get_diff_state does).
warpui::App::test((), |mut app| async move {
let handle =
app.add_model(|ctx| LocalDiffStateModel::new(None, BackendOrigin::ClientLocal, ctx));
model.insert_model(key.clone(), handle);
model.subscribe_connection(key.clone(), conn);
model.unsubscribe_connection(&key, conn);
assert!(model.get_model(&key).is_none());
assert!(model.subscribed_connections(&key).is_empty());
});
}
#[test]
fn unsubscribe_one_of_two_keeps_model() {
let mut model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
let conn_a = new_conn();
let conn_b = new_conn();
warpui::App::test((), |mut app| async move {
let handle =
app.add_model(|ctx| LocalDiffStateModel::new(None, BackendOrigin::ClientLocal, ctx));
model.insert_model(key.clone(), handle);
model.subscribe_connection(key.clone(), conn_a);
model.subscribe_connection(key.clone(), conn_b);
model.unsubscribe_connection(&key, conn_a);
assert!(model.get_model(&key).is_some());
assert_eq!(model.subscribed_connections(&key), vec![conn_b]);
});
}
#[test]
fn unsubscribe_clears_pending_responses_for_that_connection() {
let mut model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
let conn_a = new_conn();
let conn_b = new_conn();
warpui::App::test((), |mut app| async move {
let handle =
app.add_model(|ctx| LocalDiffStateModel::new(None, BackendOrigin::ClientLocal, ctx));
model.insert_model(key.clone(), handle);
model.subscribe_connection(key.clone(), conn_a);
model.subscribe_connection(key.clone(), conn_b);
model.add_pending_response(key.clone(), RequestId::new(), conn_a);
model.add_pending_response(key.clone(), RequestId::new(), conn_b);
model.unsubscribe_connection(&key, conn_a);
// Only conn_b's pending response should remain.
let pending = model.drain_pending_responses(&key);
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].conn_id, conn_b);
});
}
// ── remove_connection ───────────────────────────────────────────────
#[test]
fn remove_connection_unsubscribes_from_all_keys() {
let mut model = RemoteDiffStateManager::new();
let key_head = test_key("/repo", DiffMode::Head);
let key_main = test_key("/repo", DiffMode::MainBranch);
let conn = new_conn();
warpui::App::test((), |mut app| async move {
let h1 =
app.add_model(|ctx| LocalDiffStateModel::new(None, BackendOrigin::ClientLocal, ctx));
let h2 =
app.add_model(|ctx| LocalDiffStateModel::new(None, BackendOrigin::ClientLocal, ctx));
model.insert_model(key_head.clone(), h1);
model.insert_model(key_main.clone(), h2);
model.subscribe_connection(key_head.clone(), conn);
model.subscribe_connection(key_main.clone(), conn);
model.remove_connection(conn);
// Both models dropped because conn was the sole subscriber.
assert!(model.get_model(&key_head).is_none());
assert!(model.get_model(&key_main).is_none());
});
}
#[test]
fn remove_connection_keeps_models_with_other_subscribers() {
let mut model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
let conn_a = new_conn();
let conn_b = new_conn();
warpui::App::test((), |mut app| async move {
let handle =
app.add_model(|ctx| LocalDiffStateModel::new(None, BackendOrigin::ClientLocal, ctx));
model.insert_model(key.clone(), handle);
model.subscribe_connection(key.clone(), conn_a);
model.subscribe_connection(key.clone(), conn_b);
model.remove_connection(conn_a);
assert!(model.get_model(&key).is_some());
assert_eq!(model.subscribed_connections(&key), vec![conn_b]);
});
}
#[test]
fn remove_connection_clears_pending_responses() {
let mut model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
let conn = new_conn();
warpui::App::test((), |mut app| async move {
let handle =
app.add_model(|ctx| LocalDiffStateModel::new(None, BackendOrigin::ClientLocal, ctx));
model.insert_model(key.clone(), handle);
model.subscribe_connection(key.clone(), conn);
model.add_pending_response(key.clone(), RequestId::new(), conn);
model.remove_connection(conn);
// Model removed, so pending responses should be gone too.
assert!(!model.has_pending_responses(&key));
});
}
// ── Pending response tracking ───────────────────────────────────────
#[test]
fn has_pending_responses_false_when_empty() {
let model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
assert!(!model.has_pending_responses(&key));
}
#[test]
fn add_and_drain_pending_responses() {
let mut model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
let conn = new_conn();
let rid = RequestId::new();
model.add_pending_response(key.clone(), rid, conn);
assert!(model.has_pending_responses(&key));
let drained = model.drain_pending_responses(&key);
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].conn_id, conn);
// After drain, no pending responses remain.
assert!(!model.has_pending_responses(&key));
}
#[test]
fn drain_pending_responses_returns_empty_for_unknown_key() {
let mut model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
assert!(model.drain_pending_responses(&key).is_empty());
}
#[test]
fn multiple_pending_responses_for_same_key() {
let mut model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
let conn_a = new_conn();
let conn_b = new_conn();
model.add_pending_response(key.clone(), RequestId::new(), conn_a);
model.add_pending_response(key.clone(), RequestId::new(), conn_b);
let drained = model.drain_pending_responses(&key);
assert_eq!(drained.len(), 2);
assert_eq!(drained[0].conn_id, conn_a);
assert_eq!(drained[1].conn_id, conn_b);
}
// ── Model CRUD ──────────────────────────────────────────────────────
#[test]
fn get_model_returns_none_when_empty() {
let model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
assert!(model.get_model(&key).is_none());
}
#[test]
fn insert_and_get_model() {
let mut model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
warpui::App::test((), |mut app| async move {
let handle =
app.add_model(|ctx| LocalDiffStateModel::new(None, BackendOrigin::ClientLocal, ctx));
model.insert_model(key.clone(), handle);
assert!(model.get_model(&key).is_some());
});
}
#[test]
fn remove_model_clears_pending_and_subscriptions() {
let mut model = RemoteDiffStateManager::new();
let key = test_key("/repo", DiffMode::Head);
let conn = new_conn();
warpui::App::test((), |mut app| async move {
let handle =
app.add_model(|ctx| LocalDiffStateModel::new(None, BackendOrigin::ClientLocal, ctx));
model.insert_model(key.clone(), handle);
model.subscribe_connection(key.clone(), conn);
model.add_pending_response(key.clone(), RequestId::new(), conn);
model.remove_model(&key);
assert!(model.get_model(&key).is_none());
assert!(!model.has_pending_responses(&key));
assert!(model.subscribed_connections(&key).is_empty());
});
}
// ── Key equality ────────────────────────────────────────────────────
#[test]
fn different_modes_are_different_keys() {
let mut model = RemoteDiffStateManager::new();
let key_head = test_key("/repo", DiffMode::Head);
let key_main = test_key("/repo", DiffMode::MainBranch);
let conn = new_conn();
model.subscribe_connection(key_head.clone(), conn);
assert_eq!(model.subscribed_connections(&key_head).len(), 1);
assert!(model.subscribed_connections(&key_main).is_empty());
}
#[test]
fn different_repos_are_different_keys() {
let mut model = RemoteDiffStateManager::new();
let key_a = test_key("/repo-a", DiffMode::Head);
let key_b = test_key("/repo-b", DiffMode::Head);
let conn = new_conn();
model.subscribe_connection(key_a.clone(), conn);
assert_eq!(model.subscribed_connections(&key_a).len(), 1);
assert!(model.subscribed_connections(&key_b).is_empty());
}
+75
View File
@@ -0,0 +1,75 @@
//! Conversion between the git-status / GitHub-info proto types and the app
//! domain types consumed by `RemoteGitRepoStatusModel` and
//! `RemoteGitHubRepoModel`.
//!
//! Rust types are canonical, proto types are the wire format. Git status
//! (branch + HEAD diff stats), GitHub PR info, and GitHub repository info are
//! kept separate so they can be pushed on independent cadences. The
//! `DiffStats` / `PrInfo` conversions are reused from `diff_state_proto`.
use super::proto;
use crate::code_review::diff_state::DiffStats;
use crate::code_review::git_repo_model::GitStatusMetadata;
use crate::context_chips::display_chip::GitBranchTrackingStatus;
use crate::util::git::RepositoryInfo;
impl From<&proto::RepositoryInfo> for RepositoryInfo {
fn from(info: &proto::RepositoryInfo) -> Self {
RepositoryInfo {
name: info.name.clone(),
owner: info.owner.clone(),
}
}
}
impl From<&RepositoryInfo> for proto::RepositoryInfo {
fn from(info: &RepositoryInfo) -> Self {
proto::RepositoryInfo {
name: info.name.clone(),
owner: info.owner.clone(),
}
}
}
impl From<&GitStatusMetadata> for proto::GitStatusMetadata {
fn from(metadata: &GitStatusMetadata) -> Self {
proto::GitStatusMetadata {
current_branch_name: metadata.current_branch_name.clone(),
main_branch_name: metadata.main_branch_name.clone(),
stats_against_head: Some((&metadata.stats_against_head).into()),
tracking_upstream: metadata.branch_tracking_status.upstream.clone(),
tracking_ahead: metadata.branch_tracking_status.ahead,
tracking_behind: metadata.branch_tracking_status.behind,
tracking_counts_available: metadata.branch_tracking_status.counts_available,
}
}
}
impl TryFrom<&proto::GitStatusMetadata> for GitStatusMetadata {
type Error = String;
fn try_from(metadata: &proto::GitStatusMetadata) -> Result<Self, Self::Error> {
let stats = metadata
.stats_against_head
.as_ref()
.ok_or_else(|| "missing stats_against_head in GitStatusMetadata".to_string())?;
Ok(GitStatusMetadata {
current_branch_name: metadata.current_branch_name.clone(),
main_branch_name: metadata.main_branch_name.clone(),
stats_against_head: DiffStats::from(stats),
branch_tracking_status: if metadata.tracking_counts_available {
GitBranchTrackingStatus::new(
metadata.current_branch_name.clone(),
metadata.tracking_upstream.clone(),
metadata.tracking_ahead,
metadata.tracking_behind,
)
} else {
GitBranchTrackingStatus::without_counts(
metadata.current_branch_name.clone(),
metadata.tracking_upstream.clone(),
)
},
})
}
}
+67
View File
@@ -0,0 +1,67 @@
//! Daemon-side handler for the `UploadHandoffSnapshot` RPC.
//!
//! When the client triggers a local-to-cloud handoff from a remote SSH session,
//! the daemon runs this module to gather git patches and orphan file contents
//! from the remote host's filesystem and upload them to GCS via the existing
//! [`upload_snapshot_for_handoff`] pipeline. Because the daemon IS on the remote
//! host, all filesystem and git operations are genuinely local — no SSH
//! tunneling overhead.
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use warp_util::standardized_path::StandardizedPath;
use crate::ai::agent_sdk::driver::upload_snapshot_for_handoff;
use crate::ai::blocklist::handoff::touched_repos::derive_touched_workspace;
use crate::server::server_api::ai::{AIClient, InitialSnapshotToken};
/// Gather the workspace snapshot from the given absolute paths and upload it.
///
/// `paths` must already be validated [`StandardizedPath`] values (the caller
/// converts proto `Vec<String>` at the boundary). This function converts them
/// to local `PathBuf` for filesystem I/O.
///
/// 1. Runs [`derive_touched_workspace`] to discover git roots and orphan files.
/// 2. Calls [`upload_snapshot_for_handoff`] to build patches, allocate a token,
/// and upload everything to GCS.
///
/// Returns `Ok(Some(token))` when the upload succeeds and a token was minted,
/// `Ok(None)` when the workspace was empty or the manifest failed, and `Err`
/// for hard failures (auth, network).
pub(crate) async fn gather_and_upload_handoff_snapshot(
paths: Vec<StandardizedPath>,
ai_client: Arc<dyn AIClient>,
http: &http_client::Client,
) -> Result<Option<InitialSnapshotToken>> {
let resolved_paths: Vec<PathBuf> = paths
.into_iter()
.map(|sp| sp.to_local_path_lossy())
.collect();
if resolved_paths.is_empty() {
log::info!("Handoff snapshot: no resolved paths; skipping upload");
return Ok(None);
}
log::info!(
"Handoff snapshot: deriving workspace from {} path(s)",
resolved_paths.len()
);
// Derive the touched workspace — finds git roots and orphan files.
// On the daemon these are all local filesystem operations.
let workspace = derive_touched_workspace(resolved_paths).await;
let repo_paths: Vec<PathBuf> = workspace.repos.iter().map(|r| r.git_root.clone()).collect();
let orphan_file_paths = workspace.orphan_files;
log::info!(
"Handoff snapshot: {} repo(s), {} orphan file(s)",
repo_paths.len(),
orphan_file_paths.len()
);
upload_snapshot_for_handoff(repo_paths, orphan_file_paths, ai_client, http).await
}
+75 -56
View File
@@ -1,26 +1,57 @@
#[cfg(not(target_family = "wasm"))]
use crate::server::server_api::{ServerApiEvent, ServerApiProvider};
#[cfg(not(target_family = "wasm"))]
use galaxyui::SingletonEntity;
#[cfg(not(target_family = "wasm"))]
use remote_server::manager::RemoteServerManager;
// Re-export everything from the `remote_server` crate so existing
// `crate::remote_server::*` imports in `app` continue to work.
pub use remote_server::*;
#[cfg(not(target_family = "wasm"))]
use warp_server_client::auth::AuthEvent;
#[cfg(not(target_family = "wasm"))]
use warpui::SingletonEntity as _;
#[cfg(not(target_family = "wasm"))]
use crate::ai::{AIRequestUsageModel, AIRequestUsageModelEvent};
#[cfg(not(target_family = "wasm"))]
use crate::server::server_api::ServerApiProvider;
#[cfg(not(target_family = "wasm"))]
pub mod auth_context;
#[cfg(not(target_family = "wasm"))]
pub mod codebase_index_model;
#[cfg(not(target_family = "wasm"))]
mod codebase_index_status;
pub mod diff_state_proto;
#[cfg(not(target_family = "wasm"))]
pub mod diff_state_tracker;
pub mod git_status_proto;
#[cfg(not(target_family = "wasm"))]
pub(crate) mod handoff_snapshot;
#[cfg(not(target_family = "wasm"))]
mod ripgrep_search;
#[cfg(not(target_family = "wasm"))]
pub mod server_buffer_tracker;
#[cfg(not(target_family = "wasm"))]
pub mod server_model;
#[cfg(not(target_family = "wasm"))]
pub mod ssh_transport;
#[cfg(unix)]
pub mod unix;
#[cfg(not(target_family = "wasm"))]
fn current_codebase_index_limits(
ctx: &warpui::AppContext,
) -> remote_server::proto::CodebaseIndexLimits {
let limits = AIRequestUsageModel::as_ref(ctx).codebase_context_limits();
remote_server::proto::CodebaseIndexLimits {
max_indices_allowed: limits.max_indices_allowed.map(|limit| limit as u64),
max_files_per_repo: limits.max_files_per_repo as u64,
embedding_generation_batch_size: limits.embedding_generation_batch_size as u64,
}
}
/// Run the `remote-server-proxy` subcommand.
#[cfg(unix)]
pub fn run_proxy(identity_key: String) -> anyhow::Result<()> {
unix::run_proxy(identity_key)
unix::proxy::run(&identity_key)
}
#[cfg(not(unix))]
@@ -39,65 +70,53 @@ pub fn run_daemon(_identity_key: String) -> anyhow::Result<()> {
anyhow::bail!("remote-server-daemon is not supported on this platform")
}
/// Start the WarpUI headless app with all daemon singleton models.
///
/// This is the platform-agnostic core of every `run_daemon` implementation.
/// Platform-specific code (Unix sockets, Windows named pipes, …) binds a
/// listener and calls this function with the appropriate `ServerModel`
/// constructor — everything else (DirectoryWatcher, DetectedRepositories,
/// RepoMetadataModel, FileModel) is shared.
///
/// # Example
/// ```ignore
/// // In unix/mod.rs:
/// super::run_daemon_app(move |ctx| ServerModel::new(unix_listener, ctx))
/// ```
#[cfg(not(target_family = "wasm"))]
pub(super) fn run_daemon_app(
server_model_init: impl FnOnce(&mut galaxyui::ModelContext<server_model::ServerModel>) -> server_model::ServerModel
+ 'static,
) -> anyhow::Result<()> {
use galaxyui::platform::app::AppCallbacks;
use galaxyui::platform::AppBuilder;
AppBuilder::new_headless(AppCallbacks::default(), Box::new(()), None).run(|ctx| {
// Rotate log files from the previous daemon invocation in the background.
ctx.background_executor()
.spawn(galaxy_logging::rotate_log_files())
.detach();
use crate::server::telemetry::context_provider::NoopTelemetryContextProvider;
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::watcher::DirectoryWatcher;
use repo_metadata::RepoMetadataModel;
// Register a no-op telemetry context so that `send_telemetry_from_ctx!`
// calls (e.g. from RepoMetadataModel on ExceededMaxFileLimit) don't
// panic due to a missing TelemetryContextModel singleton.
ctx.add_singleton_model(NoopTelemetryContextProvider::new_context_provider);
// Order matters: DetectedRepositories must be registered before
// RepoMetadataModel because LocalRepoMetadataModel::new()
// subscribes to DetectedRepositories::handle(ctx).
ctx.add_singleton_model(DirectoryWatcher::new);
ctx.add_singleton_model(|_ctx| DetectedRepositories::default());
ctx.add_singleton_model(RepoMetadataModel::new_with_incremental_updates);
ctx.add_singleton_model(galaxy_files::FileModel::new);
ctx.add_singleton_model(server_model_init);
})?;
Ok(())
}
/// Forwards app auth-token rotation events to the remote-server manager.
/// Forwards app auth-token rotation and privacy preference change events
/// to the remote-server manager.
#[cfg(not(target_family = "wasm"))]
pub fn wire_auth_token_rotation(ctx: &mut galaxyui::AppContext) {
let codebase_index_limits = current_codebase_index_limits(ctx);
RemoteServerManager::handle(ctx).update(ctx, |manager, _| {
manager.update_codebase_index_limits(Some(codebase_index_limits));
});
let server_api = ServerApiProvider::handle(ctx);
let manager = RemoteServerManager::handle(ctx);
ctx.subscribe_to_model(&server_api, move |_, event, ctx| {
if let ServerApiEvent::AccessTokenRefreshed { token } = event {
if let AuthEvent::AccessTokenRefreshed { token } = event {
manager.update(ctx, |manager, _| {
manager.rotate_auth_token(token.clone());
});
}
});
// Forward crash reporting preference changes to all connected daemons.
use crate::settings::{PrivacySettings, PrivacySettingsChangedEvent};
let privacy_settings = PrivacySettings::handle(ctx);
let manager = RemoteServerManager::handle(ctx);
ctx.subscribe_to_model(&privacy_settings, move |_, event, ctx| {
if let &PrivacySettingsChangedEvent::UpdateIsCrashReportingEnabled { new_value, .. } = event
{
let codebase_index_limits = current_codebase_index_limits(ctx);
manager.update(ctx, |manager, _| {
manager.update_codebase_index_limits(Some(codebase_index_limits));
});
for client in manager.as_ref(ctx).all_connected_clients() {
client.update_preferences(new_value, Some(codebase_index_limits));
}
}
});
let request_usage = AIRequestUsageModel::handle(ctx);
let manager = RemoteServerManager::handle(ctx);
ctx.subscribe_to_model(&request_usage, move |_, event, ctx| {
if matches!(event, AIRequestUsageModelEvent::RequestUsageUpdated) {
let codebase_index_limits = current_codebase_index_limits(ctx);
let crash_reporting_enabled = PrivacySettings::as_ref(ctx).is_crash_reporting_enabled;
manager.update(ctx, |manager, _| {
manager.update_codebase_index_limits(Some(codebase_index_limits));
for client in manager.all_connected_clients() {
client.update_preferences(crash_reporting_enabled, Some(codebase_index_limits));
}
});
}
});
}
+131
View File
@@ -0,0 +1,131 @@
use std::path::{Path, PathBuf};
use futures::StreamExt as _;
use super::proto::{
ripgrep_search_response, RipgrepSearchError, RipgrepSearchMatch, RipgrepSearchRequest,
RipgrepSearchResponse, RipgrepSearchSubmatch, RipgrepSearchSuccess,
};
/// Server-side cap on the number of matched lines returned by `RipgrepSearch`.
const MAX_RIPGREP_SEARCH_MATCH_CAP: usize = 5_000;
/// Approximate payload budget for one remote search response.
///
/// Eight MB keeps transfer latency and memory well below the protocol's
/// 64 MB frame limit. Individual matches are never truncated because doing so
/// could remove a late submatch and corrupt its preview and click location.
const MAX_RIPGREP_SEARCH_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
pub(super) struct RipgrepSearchParams {
pattern: String,
roots: Vec<PathBuf>,
ignore_case: bool,
multiline: bool,
match_cap: usize,
}
pub(super) fn validate_request(msg: RipgrepSearchRequest) -> Result<RipgrepSearchParams, String> {
if msg.pattern.is_empty() || msg.roots.is_empty() {
return Err("RipgrepSearch requires a pattern and at least one root".to_string());
}
if let Some(root) = msg.roots.iter().find(|root| !Path::new(root).is_absolute()) {
return Err(format!("RipgrepSearch root must be absolute: {root}"));
}
let roots = msg.roots.iter().map(PathBuf::from).collect();
let match_cap = match msg.max_matches as usize {
0 => MAX_RIPGREP_SEARCH_MATCH_CAP,
requested => requested.min(MAX_RIPGREP_SEARCH_MATCH_CAP),
};
Ok(RipgrepSearchParams {
pattern: msg.pattern,
roots,
ignore_case: msg.ignore_case,
multiline: msg.multiline,
match_cap,
})
}
pub(super) async fn run_search(
params: RipgrepSearchParams,
) -> anyhow::Result<RipgrepSearchSuccess> {
let stream = warp_ripgrep::search::search_streaming(
std::slice::from_ref(&params.pattern),
&params.roots,
params.ignore_case,
params.multiline,
)?;
futures::pin_mut!(stream);
let mut matches = Vec::new();
let mut response_bytes: usize = 0;
let mut capped = false;
while let Some(m) = stream.next().await {
if matches.len() >= params.match_cap {
capped = true;
break;
}
let m = ripgrep_match_to_proto(m);
let match_bytes = m
.file_path
.len()
.saturating_add(m.line_text.len())
.saturating_add(
m.submatches
.len()
.saturating_mul(2 * std::mem::size_of::<u64>()),
);
if response_bytes.saturating_add(match_bytes) > MAX_RIPGREP_SEARCH_RESPONSE_BYTES {
capped = true;
break;
}
response_bytes += match_bytes;
matches.push(m);
}
Ok(RipgrepSearchSuccess { matches, capped })
}
pub(super) fn error_response(message: String) -> RipgrepSearchResponse {
RipgrepSearchResponse {
result: Some(ripgrep_search_response::Result::Error(RipgrepSearchError {
message,
})),
}
}
pub(super) fn search_result_to_response(
result: anyhow::Result<RipgrepSearchSuccess>,
) -> RipgrepSearchResponse {
match result {
Ok(success) => RipgrepSearchResponse {
result: Some(ripgrep_search_response::Result::Success(success)),
},
Err(err) => error_response(format!("{err:#}")),
}
}
/// Converts a ripgrep match to its proto form without altering line text or
/// submatch offsets. Response-wide caps bound payload size without corrupting
/// individual matches.
fn ripgrep_match_to_proto(m: warp_ripgrep::search::Match) -> RipgrepSearchMatch {
RipgrepSearchMatch {
file_path: m.file_path.to_string_lossy().to_string(),
line_number: m.line_number,
line_text: m.line_text,
submatches: m
.submatches
.into_iter()
.map(|submatch| RipgrepSearchSubmatch {
byte_start: submatch.byte_start.as_usize() as u64,
byte_end: submatch.byte_end.as_usize() as u64,
})
.collect(),
}
}
#[cfg(test)]
#[path = "ripgrep_search_tests.rs"]
mod tests;
@@ -0,0 +1,49 @@
use std::path::PathBuf;
use string_offset::ByteOffset;
use warp_ripgrep::search::{Match as RipgrepMatch, Submatch};
use super::ripgrep_match_to_proto;
fn submatch(start: usize, end: usize) -> Submatch {
Submatch {
byte_start: ByteOffset::from(start),
byte_end: ByteOffset::from(end),
}
}
#[test]
fn ripgrep_match_to_proto_maps_fields() {
let m = RipgrepMatch {
file_path: PathBuf::from("/repo/src/main.rs"),
line_number: 42,
line_text: "fn main() {}".to_string(),
submatches: vec![submatch(3, 7)],
};
let proto = ripgrep_match_to_proto(m);
assert_eq!(proto.file_path, "/repo/src/main.rs");
assert_eq!(proto.line_number, 42);
assert_eq!(proto.line_text, "fn main() {}");
assert_eq!(proto.submatches.len(), 1);
assert_eq!(proto.submatches[0].byte_start, 3);
assert_eq!(proto.submatches[0].byte_end, 7);
}
#[test]
fn ripgrep_match_to_proto_preserves_late_submatch_and_full_line() {
let line = format!("{}needle", "x".repeat(8_000));
let m = RipgrepMatch {
file_path: PathBuf::from("/repo/long.rs"),
line_number: 1,
line_text: line.clone(),
submatches: vec![submatch(8_000, 8_006)],
};
let proto = ripgrep_match_to_proto(m);
assert_eq!(proto.line_text, line);
assert_eq!(proto.submatches[0].byte_start, 8_000);
assert_eq!(proto.submatches[0].byte_end, 8_006);
}
@@ -0,0 +1,225 @@
use std::collections::{HashMap, HashSet};
use warp_editor::content::buffer::Buffer;
use warp_util::file::FileId;
use warpui::{ModelContext, ModelHandle, SingletonEntity};
use super::server_model::{ConnectionId, ServerModel};
use crate::code::global_buffer_model::GlobalBufferModel;
use crate::remote_server::protocol::RequestId;
/// Distinguishes the type of pending buffer request so the event
/// subscription can send the correct response message.
#[derive(Clone, Copy, Debug)]
pub enum PendingBufferRequestKind {
OpenBuffer,
SaveBuffer,
ResolveConflict,
}
/// An in-flight buffer request awaiting a `GlobalBufferModelEvent` to
/// correlate it back to the originating connection.
#[derive(Clone, Debug)]
pub struct PendingBufferRequest {
pub request_id: RequestId,
pub connection_id: ConnectionId,
pub kind: PendingBufferRequestKind,
}
/// Bridges the ServerModel's per-connection state with the GlobalBufferModel's
/// tracked buffers. Manages:
/// - Wire path → FileId mappings for open server-local buffers
/// - Per-buffer connection sets (which connections have each buffer open)
/// - Pending async requests (OpenBuffer, SaveBuffer, ResolveConflict) awaiting events
pub struct ServerBufferTracker {
/// Maps wire path strings to `FileId` for open server-local buffers.
open_buffers: HashMap<String, FileId>,
/// Strong references to buffer models, keyed by `FileId`.
/// Prevents the `Buffer` model from being deallocated while the
/// server is tracking it (the `GlobalBufferModel` only holds a
/// `WeakModelHandle`).
buffer_handles: HashMap<FileId, ModelHandle<Buffer>>,
/// Tracks which connections have each buffer open.
/// File-watcher pushes go to all connections in the set.
buffer_connections: HashMap<FileId, HashSet<ConnectionId>>,
/// Tracks in-flight OpenBuffer / SaveBuffer / ResolveConflict requests so
/// `GlobalBufferModelEvent`s can be correlated back to the originating
/// request and connection. Uses a `Vec` to support concurrent requests
/// for the same buffer from different connections.
pending_requests: HashMap<FileId, Vec<PendingBufferRequest>>,
}
impl ServerBufferTracker {
pub fn new() -> Self {
Self {
open_buffers: HashMap::new(),
buffer_handles: HashMap::new(),
buffer_connections: HashMap::new(),
pending_requests: HashMap::new(),
}
}
// ── Path ↔ FileId mapping ─────────────────────────────────────
/// Register a wire path → FileId mapping and retain a strong handle
/// to the buffer model so it stays alive while tracked.
pub fn track_open_buffer(
&mut self,
path: String,
file_id: FileId,
buffer: ModelHandle<Buffer>,
) {
self.open_buffers.insert(path, file_id);
self.buffer_handles.insert(file_id, buffer);
}
/// Look up a FileId by its wire path.
pub fn file_id_for_path(&self, path: &str) -> Option<FileId> {
self.open_buffers.get(path).copied()
}
/// Look up the wire path for a given FileId.
pub fn path_for_file_id(&self, file_id: FileId) -> Option<String> {
self.open_buffers.iter().find_map(|(p, id)| {
if *id == file_id {
Some(p.clone())
} else {
None
}
})
}
// ── Connection tracking ───────────────────────────────────────
/// Add a connection to a buffer's subscriber set.
pub fn add_connection(&mut self, file_id: FileId, conn_id: ConnectionId) {
self.buffer_connections
.entry(file_id)
.or_default()
.insert(conn_id);
}
/// Returns the set of connections subscribed to a buffer.
pub fn connections_for_buffer(&self, file_id: &FileId) -> Option<&HashSet<ConnectionId>> {
self.buffer_connections.get(file_id)
}
/// Remove a connection from all buffer subscription sets.
/// Returns the list of FileIds that have no remaining connections
/// (orphaned buffers that should be deallocated).
pub fn remove_connection(
&mut self,
conn_id: ConnectionId,
ctx: &mut ModelContext<ServerModel>,
) -> Vec<FileId> {
let orphaned: Vec<FileId> = self
.buffer_connections
.iter_mut()
.filter_map(|(file_id, conns)| {
conns.remove(&conn_id);
if conns.is_empty() {
Some(*file_id)
} else {
None
}
})
.collect();
for &file_id in &orphaned {
self.buffer_connections.remove(&file_id);
self.buffer_handles.remove(&file_id);
self.open_buffers.retain(|_, id| *id != file_id);
GlobalBufferModel::handle(ctx).update(ctx, |gbm, ctx| gbm.remove(file_id, ctx));
}
orphaned
}
/// Remove a single connection from a buffer's subscriber set.
/// If no connections remain, deallocates the buffer entirely.
pub fn close_buffer(
&mut self,
path: &str,
conn_id: ConnectionId,
ctx: &mut ModelContext<ServerModel>,
) {
let Some(&file_id) = self.open_buffers.get(path) else {
return;
};
if let Some(conns) = self.buffer_connections.get_mut(&file_id) {
conns.remove(&conn_id);
if !conns.is_empty() {
return; // Other connections still using this buffer.
}
}
// No connections remain — deallocate.
self.buffer_connections.remove(&file_id);
self.buffer_handles.remove(&file_id);
self.open_buffers.remove(path);
GlobalBufferModel::handle(ctx).update(ctx, |gbm, ctx| gbm.remove(file_id, ctx));
}
// ── Pending request tracking ──────────────────────────────────
/// Stash a pending async request for later correlation with an event.
pub fn insert_pending(
&mut self,
file_id: FileId,
request_id: RequestId,
conn_id: ConnectionId,
kind: PendingBufferRequestKind,
) {
self.pending_requests
.entry(file_id)
.or_default()
.push(PendingBufferRequest {
request_id,
connection_id: conn_id,
kind,
});
}
/// Returns the connection IDs that have pending `OpenBuffer` requests
/// for the given FileId, without consuming them. Used by the
/// `ServerLocalBufferUpdated` handler to exclude connections that will
/// receive content via `OpenBufferResponse` instead of the broadcast push.
pub fn pending_connections_for_open_buffer(&self, file_id: &FileId) -> HashSet<ConnectionId> {
self.pending_requests
.get(file_id)
.map(|entries| {
entries
.iter()
.filter(|req| matches!(req.kind, PendingBufferRequestKind::OpenBuffer))
.map(|req| req.connection_id)
.collect()
})
.unwrap_or_default()
}
/// Retrieve and remove pending requests that match `kind` for the given
/// FileId. Other pending requests for the same FileId are left in place.
pub fn take_pending_by_kind(
&mut self,
file_id: &FileId,
kind: PendingBufferRequestKind,
) -> Vec<PendingBufferRequest> {
let Some(entries) = self.pending_requests.get_mut(file_id) else {
return Vec::new();
};
let mut matched = Vec::new();
entries.retain(|req| {
if std::mem::discriminant(&req.kind) == std::mem::discriminant(&kind) {
matched.push(req.clone());
false // remove from the vec
} else {
true // keep
}
});
if entries.is_empty() {
self.pending_requests.remove(file_id);
}
matched
}
}
File diff suppressed because it is too large Load Diff
+455 -50
View File
@@ -1,97 +1,502 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use super::super::proto::{Authenticate, Initialize};
use warp_util::standardized_path::StandardizedPath;
use warpui::App;
use super::super::diff_state_tracker::RemoteDiffStateManager;
use super::super::proto::{
remote_skill_proto, server_message, write_file_response, Authenticate, BundledSkillMetadata,
HomeSkillMetadata, Initialize, RemoteAgentContextSnapshot, RemoteContextFileProto,
RemoteSkillProto, ServerMessage, WriteFileResponse, WriteFileSuccess,
};
use super::super::protocol::RequestId;
use super::{PendingFileOps, ServerModel};
use super::super::server_buffer_tracker::ServerBufferTracker;
use super::{ConnectionId, PendingFileOps, ServerModel};
use crate::auth::auth_state::AuthState;
use crate::code_review::diff_state::DiffMode;
use crate::remote_server::diff_state_tracker::DiffModelKey;
fn test_model() -> ServerModel {
fn test_model(app: &mut App) -> ServerModel {
ServerModel {
connection_senders: HashMap::new(),
snapshot_sent_roots_by_connection: HashMap::new(),
grace_timer_cancel: None,
in_progress: HashMap::new(),
host_id: "test-host-id".to_string(),
bundled_skills: Vec::new(),
remote_agent_context_snapshot: RemoteAgentContextSnapshot {
revision: 1,
home_dir: "/home/user".to_string(),
skills: Vec::new(),
global_rules: Vec::new(),
},
remote_agent_context_snapshot_sent: HashSet::new(),
executors: HashMap::new(),
pending_file_ops: PendingFileOps::new(),
auth_token: None,
auth_state: Arc::new(AuthState::new_logged_out_for_test()),
buffers: ServerBufferTracker::new(),
diff_states: app.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(),
}
}
fn request_id() -> RequestId {
RequestId::from("test-request".to_string())
/// Uses `try_new` instead of `try_from_local` so that Unix-style paths
/// like `/repo` are recognised as absolute on all platforms (including Windows).
fn test_key(repo: &str, mode: DiffMode) -> DiffModelKey {
DiffModelKey {
repo_path: StandardizedPath::try_new(repo).unwrap(),
mode,
}
}
fn test_bundled_skill_proto(id: &str) -> RemoteSkillProto {
RemoteSkillProto {
path: format!(
"/home/user/.warp/remote-server/bundled_resources/bundled/skills/{id}/SKILL.md"
),
content: format!("# {id}"),
source: Some(remote_skill_proto::Source::Bundled(BundledSkillMetadata {
id: id.to_string(),
requires_mcp: None,
})),
}
}
#[test]
fn remote_agent_context_snapshot_broadcasts_replacements_and_initializes_once() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
let conn = uuid::Uuid::new_v4();
let (tx, rx) = async_channel::unbounded();
model.connection_senders.insert(conn, tx);
model.send_remote_agent_context_snapshot_to_connection(conn);
assert!(matches!(
rx.try_recv().map(|msg| msg.message),
Ok(Some(server_message::Message::RemoteAgentContextSnapshot(_)))
));
model.send_remote_agent_context_snapshot_to_connection(conn);
assert!(rx.try_recv().is_err());
model.remote_agent_context_snapshot = RemoteAgentContextSnapshot {
revision: 2,
home_dir: "/home/user".to_string(),
skills: vec![
test_bundled_skill_proto("test-skill"),
RemoteSkillProto {
path: "/home/user/.agents/skills/test/SKILL.md".to_string(),
content: "skill content".to_string(),
source: Some(remote_skill_proto::Source::Home(HomeSkillMetadata {})),
},
],
global_rules: vec![RemoteContextFileProto {
path: "/home/user/.agents/AGENTS.md".to_string(),
content: "rule content".to_string(),
}],
};
model.broadcast_remote_agent_context_snapshot();
match rx
.try_recv()
.expect("remote Agent Mode context replacement")
.message
{
Some(server_message::Message::RemoteAgentContextSnapshot(snapshot)) => {
assert_eq!(snapshot.revision, 2);
assert_eq!(snapshot.skills.len(), 2);
assert_eq!(snapshot.skills[1].content, "skill content");
assert_eq!(snapshot.global_rules[0].content, "rule content");
}
other => panic!("expected RemoteAgentContextSnapshot, got {other:?}"),
}
let late_conn = uuid::Uuid::new_v4();
let (late_tx, late_rx) = async_channel::unbounded();
model.connection_senders.insert(late_conn, late_tx);
model.send_remote_agent_context_snapshot_to_connection(late_conn);
assert!(matches!(
late_rx.try_recv().map(|msg| msg.message),
Ok(Some(server_message::Message::RemoteAgentContextSnapshot(_)))
));
model.send_remote_agent_context_snapshot_to_connection(late_conn);
assert!(late_rx.try_recv().is_err());
});
}
#[test]
fn fresh_model_starts_without_auth_token() {
let model = test_model();
App::test((), |mut app| async move {
let model = test_model(&mut app);
assert_eq!(model.auth_token(), None);
assert_eq!(model.auth_token().as_deref(), None);
assert_eq!(model.auth_state.user_id(), None);
assert_eq!(model.auth_state.user_email(), None);
});
}
#[test]
fn initialize_with_auth_token_stores_token() {
let mut model = test_model();
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
model.handle_initialize(
Initialize {
model.apply_initialize_auth(&Initialize {
auth_token: "initial-token".to_string(),
},
&request_id(),
);
user_id: "test-user-id".to_string(),
user_email: "test@example.com".to_string(),
crash_reporting_enabled: true,
codebase_index_limits: None,
});
assert_eq!(model.auth_token(), Some("initial-token"));
assert_eq!(model.auth_token().as_deref(), Some("initial-token"));
assert_eq!(
model.auth_state.user_id().unwrap().as_string(),
"test-user-id"
);
assert_eq!(
model.auth_state.user_email().as_deref(),
Some("test@example.com")
);
});
}
#[test]
fn empty_initialize_preserves_existing_auth_token() {
let mut model = test_model();
model.handle_initialize(
Initialize {
fn empty_initialize_clears_auth_context() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
model.apply_initialize_auth(&Initialize {
auth_token: "initial-token".to_string(),
},
&request_id(),
);
user_id: "test-user-id".to_string(),
user_email: "test@example.com".to_string(),
crash_reporting_enabled: true,
codebase_index_limits: None,
});
model.handle_initialize(
Initialize {
model.apply_initialize_auth(&Initialize {
auth_token: String::new(),
},
&request_id(),
);
user_id: String::new(),
user_email: String::new(),
crash_reporting_enabled: true,
codebase_index_limits: None,
});
assert_eq!(model.auth_token(), Some("initial-token"));
assert_eq!(model.auth_token().as_deref(), None);
assert_eq!(model.auth_state.user_id(), None);
assert_eq!(model.auth_state.user_email(), None);
});
}
#[test]
fn authenticate_with_auth_token_replaces_auth_token() {
let mut model = test_model();
model.handle_initialize(
Initialize {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
model.apply_initialize_auth(&Initialize {
auth_token: "initial-token".to_string(),
},
&request_id(),
);
user_id: String::new(),
user_email: String::new(),
crash_reporting_enabled: true,
codebase_index_limits: None,
});
model.handle_authenticate(Authenticate {
auth_token: "rotated-token".to_string(),
model.handle_authenticate(Authenticate {
auth_token: "rotated-token".to_string(),
});
assert_eq!(model.auth_token().as_deref(), Some("rotated-token"));
});
assert_eq!(model.auth_token(), Some("rotated-token"));
}
#[test]
fn empty_authenticate_preserves_existing_auth_token() {
let mut model = test_model();
model.handle_initialize(
Initialize {
fn empty_authenticate_clears_auth_token() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
model.apply_initialize_auth(&Initialize {
auth_token: "initial-token".to_string(),
},
&request_id(),
);
user_id: String::new(),
user_email: String::new(),
crash_reporting_enabled: true,
codebase_index_limits: None,
});
model.handle_authenticate(Authenticate {
auth_token: String::new(),
model.handle_authenticate(Authenticate {
auth_token: String::new(),
});
assert_eq!(model.auth_token().as_deref(), None);
});
}
// ── Diff state: connection cleanup ──────────────────────────────────
#[test]
fn deregister_connection_cleans_up_diff_state_subscriptions() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
let conn = uuid::Uuid::new_v4();
// Register the connection.
let (tx, _rx) = async_channel::unbounded();
model.connection_senders.insert(conn, tx);
// Subscribe the connection to diff state via the manager.
let key = test_key("/repo", DiffMode::Head);
let key2 = key.clone();
let key3 = key.clone();
model.diff_states.update(&mut app, |mgr, _ctx| {
mgr.subscribe_connection(key, conn);
});
let has_sub = model.diff_states.read(&app, |mgr, _ctx| {
!mgr.subscribed_connections(&key2).is_empty()
});
assert!(has_sub);
// Simulate deregister_connection's diff state cleanup.
model.diff_states.update(&mut app, |mgr, _ctx| {
mgr.remove_connection(conn);
});
let has_sub = model.diff_states.read(&app, |mgr, _ctx| {
!mgr.subscribed_connections(&key3).is_empty()
});
assert!(!has_sub);
});
}
#[test]
fn diff_states_starts_empty() {
App::test((), |mut app| async move {
let model = test_model(&mut app);
let key = test_key("/repo", DiffMode::Head);
let empty = model.diff_states.read(&app, |mgr, _ctx| {
mgr.subscribed_connections(&key).is_empty()
});
assert!(empty);
});
}
// ── Git status / GitHub: navigation-driven model cleanup ────────────
#[test]
fn subscribe_git_status_records_subscriber_and_current_repo() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
let conn = uuid::Uuid::new_v4();
let repo = StandardizedPath::try_new("/repo").unwrap();
model.subscribe_git_status(conn, &repo);
assert_eq!(model.git_status_repo_by_conn.get(&conn), Some(&repo));
assert!(model.git_status_subscribers[&repo].contains(&conn));
});
}
#[test]
fn navigating_between_repos_moves_the_subscription() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
let conn = uuid::Uuid::new_v4();
let repo_a = StandardizedPath::try_new("/repo-a").unwrap();
let repo_b = StandardizedPath::try_new("/repo-b").unwrap();
model.subscribe_git_status(conn, &repo_a);
model.subscribe_git_status(conn, &repo_b);
// Moved off A (now empty) and onto B.
assert!(!model.git_status_subscribers.contains_key(&repo_a));
assert!(model.git_status_subscribers[&repo_b].contains(&conn));
assert_eq!(model.git_status_repo_by_conn.get(&conn), Some(&repo_b));
});
}
#[test]
fn snapshot_request_does_not_move_another_repos_subscription() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
let conn = uuid::Uuid::new_v4();
let repo_a = StandardizedPath::try_new("/repo-a").unwrap();
let repo_b = StandardizedPath::try_new("/repo-b").unwrap();
// Navigation put the connection in repo A.
model.subscribe_git_status(conn, &repo_a);
// A snapshot request for repo B riding this connection must not move
// the navigation-driven subscription off repo A (mirrors the guard in
// `handle_update_git_status`).
if !model.git_status_repo_by_conn.contains_key(&conn) {
model.subscribe_git_status(conn, &repo_b);
}
assert_eq!(model.git_status_repo_by_conn.get(&conn), Some(&repo_a));
assert!(model.git_status_subscribers[&repo_a].contains(&conn));
assert!(!model.git_status_subscribers.contains_key(&repo_b));
// An untracked connection is registered normally.
let conn2 = uuid::Uuid::new_v4();
if !model.git_status_repo_by_conn.contains_key(&conn2) {
model.subscribe_git_status(conn2, &repo_b);
}
assert!(model.git_status_subscribers[&repo_b].contains(&conn2));
assert_eq!(model.git_status_repo_by_conn.get(&conn2), Some(&repo_b));
});
}
#[test]
fn last_subscriber_leaving_evicts_the_repo() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
let conn = uuid::Uuid::new_v4();
let repo = StandardizedPath::try_new("/repo").unwrap();
model.subscribe_git_status(conn, &repo);
assert!(model.git_status_subscribers.contains_key(&repo));
model.unsubscribe_git_status(conn);
// Subscriber set, current-repo mapping, and the per-repo model maps are
// all cleared once no connection remains in the repo.
assert!(!model.git_status_subscribers.contains_key(&repo));
assert!(!model.git_status_repo_by_conn.contains_key(&conn));
assert!(!model.git_status_models.contains_key(&repo));
assert!(!model.github_repo_models.contains_key(&repo));
});
}
#[test]
fn sibling_connection_keeps_the_repo_alive() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
let conn_a = uuid::Uuid::new_v4();
let conn_b = uuid::Uuid::new_v4();
let repo = StandardizedPath::try_new("/repo").unwrap();
model.subscribe_git_status(conn_a, &repo);
model.subscribe_git_status(conn_b, &repo);
// First connection leaves: the repo stays for the sibling.
model.unsubscribe_git_status(conn_a);
assert!(model.git_status_subscribers[&repo].contains(&conn_b));
// Second connection leaves: now evicted.
model.unsubscribe_git_status(conn_b);
assert!(!model.git_status_subscribers.contains_key(&repo));
});
}
#[test]
fn unsubscribe_unknown_connection_is_a_noop() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
model.unsubscribe_git_status(uuid::Uuid::new_v4());
assert!(model.git_status_subscribers.is_empty());
assert!(model.git_status_repo_by_conn.is_empty());
});
}
// ── Daemon host-scoped response failover ────────────────────────────
/// A throwaway host-scoped response payload used to assert routing.
fn write_file_success_message() -> server_message::Message {
server_message::Message::WriteFileResponse(WriteFileResponse {
result: Some(write_file_response::Result::Success(WriteFileSuccess {})),
})
}
#[test]
fn host_scoped_response_fails_over_when_target_send_fails() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
let request_id = RequestId::new();
let target: ConnectionId = uuid::Uuid::new_v4();
let alternate: ConnectionId = uuid::Uuid::new_v4();
// The target connection's receiver is dropped, so its sender still
// exists in the map but `try_send` fails (channel closed).
let (target_tx, target_rx) = async_channel::bounded(1);
drop(target_rx);
model.connection_senders.insert(target, target_tx);
// The alternate connection has a live receiver.
let (alt_tx, alt_rx) = async_channel::unbounded();
model.connection_senders.insert(alternate, alt_tx);
// Mark the request as host-scoped so failover is eligible.
model
.host_scoped_requests
.insert(request_id.clone(), target);
model.send_server_message(
Some(target),
Some(&request_id),
write_file_success_message(),
);
// The response was re-routed to the alternate connection.
let received = alt_rx
.try_recv()
.expect("alternate should receive failover response");
assert_eq!(received.request_id, request_id.to_string());
// The host-scoped entry is consumed regardless of delivery path.
assert!(!model.host_scoped_requests.contains_key(&request_id));
});
}
#[test]
fn host_scoped_response_fails_over_when_target_missing() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
let request_id = RequestId::new();
let target: ConnectionId = uuid::Uuid::new_v4();
let alternate: ConnectionId = uuid::Uuid::new_v4();
// Target connection is gone entirely (not in the senders map), but the
// request is still tracked as host-scoped.
let (alt_tx, alt_rx) = async_channel::unbounded();
model.connection_senders.insert(alternate, alt_tx);
model
.host_scoped_requests
.insert(request_id.clone(), target);
model.send_server_message(
Some(target),
Some(&request_id),
write_file_success_message(),
);
let received = alt_rx
.try_recv()
.expect("alternate should receive failover response");
assert_eq!(received.request_id, request_id.to_string());
assert!(!model.host_scoped_requests.contains_key(&request_id));
});
}
#[test]
fn non_host_scoped_response_is_not_failed_over() {
App::test((), |mut app| async move {
let mut model = test_model(&mut app);
let request_id = RequestId::new();
let target: ConnectionId = uuid::Uuid::new_v4();
let alternate: ConnectionId = uuid::Uuid::new_v4();
// Target sender exists but is closed; the request is NOT tracked as
// host-scoped, so the message must be dropped rather than re-routed.
let (target_tx, target_rx) = async_channel::bounded(1);
drop(target_rx);
model.connection_senders.insert(target, target_tx);
let (alt_tx, alt_rx) = async_channel::unbounded::<ServerMessage>();
model.connection_senders.insert(alternate, alt_tx);
model.send_server_message(
Some(target),
Some(&request_id),
write_file_success_message(),
);
assert!(
alt_rx.try_recv().is_err(),
"non-host-scoped response must not fail over to another connection"
);
});
assert_eq!(model.auth_token(), Some("initial-token"));
}
+185 -74
View File
@@ -5,21 +5,23 @@
//! whose stdin/stdout become the protocol channel.
use std::fmt;
use std::future::Future;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::Stdio;
use std::sync::Arc;
use anyhow::Result;
use galaxyui::r#async::executor;
use remote_server::auth::RemoteServerAuthContext;
use remote_server::client::RemoteServerClient;
use remote_server::manager::RemoteServerExitStatus;
use remote_server::setup::{
self, remote_server_daemon_dir, RemotePlatform, CHECK_TIMEOUT, INSTALL_TIMEOUT,
parse_uname_output, remote_server_daemon_dir, PreinstallCheckResult, RemotePlatform,
};
use remote_server::ssh::{run_ssh_command, run_ssh_script, ssh_args};
use remote_server::transport::{Connection, RemoteTransport};
use remote_server::ssh::ssh_args;
use remote_server::transport::{Connection, ControlPath, Error, InstallOutcome, RemoteTransport};
use warpui::r#async::executor;
#[path = "ssh_transport/installation.rs"]
pub(crate) mod installation;
/// SSH transport: connects via a ControlMaster socket.
///
@@ -31,21 +33,32 @@ use remote_server::transport::{Connection, RemoteTransport};
pub struct SshTransport {
socket_path: PathBuf,
auth_context: Arc<RemoteServerAuthContext>,
/// Whether Warp owns the ControlMaster behind `socket_path`. `false`
/// when the SSH wrapper attached to a master the user already had
/// running, in which case Warp must not run `ssh -O exit` against it
/// on teardown.
warp_owns_control_master: bool,
}
impl fmt::Debug for SshTransport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SshTransport")
.field("socket_path", &self.socket_path)
.field("warp_owns_control_master", &self.warp_owns_control_master)
.finish_non_exhaustive()
}
}
impl SshTransport {
pub fn new(socket_path: PathBuf, auth_context: Arc<RemoteServerAuthContext>) -> Self {
pub fn new(
socket_path: PathBuf,
auth_context: Arc<RemoteServerAuthContext>,
warp_owns_control_master: bool,
) -> Self {
Self {
socket_path,
auth_context,
warp_owns_control_master,
}
}
@@ -53,17 +66,23 @@ impl SshTransport {
&self.socket_path
}
pub fn warp_owns_control_master(&self) -> bool {
self.warp_owns_control_master
}
pub fn remote_daemon_socket_path(&self) -> String {
format!(
"{}/server.sock",
remote_server_daemon_dir(&self.auth_context.remote_server_identity_key())
"{}/{}",
remote_server_daemon_dir(&self.auth_context.remote_server_identity_key()),
remote_server::setup::daemon_socket_name(),
)
}
pub fn remote_daemon_pid_path(&self) -> String {
format!(
"{}/server.pid",
remote_server_daemon_dir(&self.auth_context.remote_server_identity_key())
"{}/{}",
remote_server_daemon_dir(&self.auth_context.remote_server_identity_key()),
remote_server::setup::daemon_pid_name(),
)
}
@@ -75,73 +94,140 @@ impl SshTransport {
}
}
/// Runs `uname -sm` on the remote host via the ControlMaster socket and
/// parses the output into a [`RemotePlatform`].
async fn detect_remote_platform(socket_path: &Path) -> Result<RemotePlatform, Error> {
let output = remote_server::ssh::run_ssh_command(
socket_path,
"uname -sm",
remote_server::setup::CHECK_TIMEOUT,
)
.await?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
parse_uname_output(&stdout)
} else {
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
Err(Error::Other(anyhow::anyhow!(
"uname -sm exited with code {code}: {stderr}"
)))
}
}
impl RemoteTransport for SshTransport {
fn detect_platform(
&self,
) -> Pin<Box<dyn Future<Output = Result<RemotePlatform, String>> + Send>> {
) -> Pin<Box<dyn Future<Output = Result<RemotePlatform, Error>> + Send>> {
let socket_path = self.socket_path.clone();
Box::pin(async move { detect_remote_platform(&socket_path).await })
}
fn run_preinstall_check(
&self,
) -> Pin<Box<dyn Future<Output = Result<PreinstallCheckResult, Error>> + Send>> {
let socket_path = self.socket_path.clone();
Box::pin(async move {
match run_ssh_command(&socket_path, "uname -sm", CHECK_TIMEOUT).await {
match remote_server::ssh::run_ssh_script(
&socket_path,
remote_server::setup::PREINSTALL_CHECK_SCRIPT,
remote_server::setup::CHECK_TIMEOUT,
)
.await
{
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
setup::parse_uname_output(&stdout).map_err(|e| format!("{e:#}"))
Ok(PreinstallCheckResult::parse(&stdout))
}
Ok(output) => {
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("uname -sm exited with code {code}: {stderr}"))
let exit_code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(Error::ScriptFailed { exit_code, stderr })
}
Err(e) => Err(format!("{e:#}")),
Err(e) => Err(e.into()),
}
})
}
fn check_binary(&self) -> Pin<Box<dyn Future<Output = Result<bool, String>> + Send>> {
fn check_binary(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>> + Send>> {
let socket_path = self.socket_path.clone();
Box::pin(async move {
let bin_path = setup::remote_server_binary();
log::info!("Checking for remote server binary at {bin_path}");
match run_ssh_command(&socket_path, &setup::binary_check_command(), CHECK_TIMEOUT).await
{
Ok(output) => match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("binary check exited with code {code}: {stderr}"))
}
None => Err("binary check terminated by signal".into()),
},
Err(e) => Err(format!("{e:#}")),
let cmd = remote_server::setup::binary_check_command();
log::info!("Running binary check: {cmd}");
let output = remote_server::ssh::run_ssh_command(
&socket_path,
&cmd,
remote_server::setup::CHECK_TIMEOUT,
)
.await?;
// `<binary> --version` exits 0 when present, executable, and
// functional. Exit 127 means the binary was not found, and 126
// means it exists but is not executable. Any other non-zero
// exit (e.g. SSH exit 255 for a dead connection, or signal
// termination) is treated as a transport-level failure.
let code = output.status.code();
let stdout = String::from_utf8_lossy(&output.stdout);
log::info!("Binary check result: exit={code:?} stdout={stdout}");
match code {
Some(0) => Ok(true),
Some(126) | Some(127) => Ok(false),
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(Error::Other(anyhow::anyhow!(
"binary check exited with code {code}: {stderr}"
)))
}
None => Err(Error::Other(anyhow::anyhow!(
"binary check terminated by signal"
))),
}
})
}
fn install_binary(&self) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> {
fn check_has_old_binary(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<bool>> + Send>> {
let socket_path = self.socket_path.clone();
Box::pin(async move {
let script = setup::install_script();
log::info!(
"Installing remote server binary to {}",
setup::remote_server_binary()
);
match run_ssh_script(&socket_path, &script, INSTALL_TIMEOUT).await {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => {
let code = output.status.code().unwrap_or(-1);
// Treat the existence of the remote-server install directory
// itself as evidence of a prior install. If `~/.warp-XX/remote-server`
// exists, something was installed there before, so any mismatch
// with the client's expected binary path should be auto-updated
// rather than surfaced as a first-time install prompt.
let cmd = format!("test -d {}", remote_server::setup::remote_server_dir());
let output = remote_server::ssh::run_ssh_command(
&socket_path,
&cmd,
remote_server::setup::CHECK_TIMEOUT,
)
.await?;
// `test -d` exits 0 when present, 1 when missing.
// Anything else is treated as a check failure.
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("install script failed (exit {code}): {stderr}"))
Err(anyhow::anyhow!(
"remote-server dir check exited with code {code}: {stderr}"
))
}
Err(e) => Err(format!("{e:#}")),
None => Err(anyhow::anyhow!(
"remote-server dir check terminated by signal"
)),
}
})
}
fn install_binary(&self) -> Pin<Box<dyn Future<Output = InstallOutcome> + Send>> {
let socket_path = self.socket_path.clone();
Box::pin(async move { installation::install_binary(&socket_path).await })
}
fn connect(
&self,
executor: Arc<executor::Background>,
) -> Pin<Box<dyn Future<Output = Result<Connection>> + Send>> {
let socket_path = self.socket_path.clone();
let warp_owns_control_master = self.warp_owns_control_master;
let remote_proxy_command = self.remote_proxy_command();
Box::pin(async move {
let mut args = ssh_args(&socket_path);
@@ -154,9 +240,9 @@ impl RemoteTransport for SshTransport {
// spontaneous disconnect) sends SIGKILL to this ssh process.
let mut child = command::r#async::Command::new("ssh")
.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()?;
@@ -173,39 +259,64 @@ impl RemoteTransport for SshTransport {
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture child stderr"))?;
let (client, event_rx) =
let (client, event_rx, failure_rx, host_response_rx, stderr_tail) =
RemoteServerClient::from_child_streams(stdin, stdout, stderr, &executor);
Ok(Connection {
client,
event_rx,
failure_rx,
host_response_rx,
child,
control_path: Some(socket_path),
// Tag the socket with master ownership. Teardown only runs
// `ssh -O exit` against Warp-managed masters; a user-owned
// (external) master must be left running when the Warp
// session exits.
control_path: if warp_owns_control_master {
ControlPath::WarpManaged(socket_path)
} else {
ControlPath::UserOwned(socket_path)
},
stderr_tail,
})
})
}
fn remove_remote_server_binary(
&self,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
let socket_path = self.socket_path.clone();
Box::pin(async move {
let cmd = remote_server::setup::remote_server_removal_command();
log::info!("Removing stale remote server binary: {cmd}");
let output = remote_server::ssh::run_ssh_command(
&socket_path,
&cmd,
remote_server::setup::CHECK_TIMEOUT,
)
.await?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow::anyhow!("Failed to remove binary: {stderr}"))
}
})
}
/// SSH exit code 255 indicates a connection-level error (broken pipe,
/// connection reset, host unreachable) — the ControlMaster's TCP
/// connection is dead. A signal kill also suggests the transport was
/// torn down. In either case, reconnecting through the same
/// ControlMaster is futile.
fn is_reconnectable(&self, exit_status: Option<&RemoteServerExitStatus>) -> bool {
match exit_status {
Some(s) => s.code != Some(255) && !s.signal_killed,
// No exit status available — optimistically allow reconnect.
None => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use galaxyui::r#async::BoxFuture;
fn static_auth_context() -> Arc<RemoteServerAuthContext> {
Arc::new(RemoteServerAuthContext::new(
|| -> BoxFuture<'static, Option<String>> { Box::pin(async { None }) },
|| "user id/with spaces".to_string(),
))
}
#[test]
fn remote_proxy_command_quotes_identity_key() {
let transport = SshTransport::new(
PathBuf::from("/tmp/control-master.sock"),
static_auth_context(),
);
let command = transport.remote_proxy_command();
assert!(command.contains("remote-server-proxy --identity-key"));
assert!(command.contains("'user id/with spaces'"));
}
}
#[path = "ssh_transport_tests.rs"]
mod tests;
@@ -0,0 +1,96 @@
#[path = "installation/scp_fallback.rs"]
mod scp_fallback;
use std::path::Path;
use anyhow::Result;
use remote_server::ssh::SshCommandError;
use remote_server::transport::{Error, InstallOutcome, InstallSource};
/// Runs the binary install sequence for the SSH transport. It first asks the
/// remote host to download directly, then falls back to uploading a cached
/// client-side tarball over SCP when the remote download path fails.
pub(super) async fn install_binary(socket_path: &Path) -> InstallOutcome {
let binary_path = remote_server::setup::remote_server_binary();
log::info!("Installing remote server binary to {binary_path}");
let mut outcome = match install_on_server(socket_path).await {
Ok(()) => InstallOutcome {
source: Some(InstallSource::Server),
result: Ok(()),
},
Err(server_err) => {
if scp_fallback::should_try_install(&server_err) {
log::info!("Remote server install failed; falling back to SCP upload");
match scp_fallback::install(socket_path).await {
Ok(()) => InstallOutcome {
source: Some(InstallSource::Client),
result: Ok(()),
},
Err(e) => InstallOutcome {
source: Some(InstallSource::Client),
result: Err(e),
},
}
} else {
InstallOutcome {
source: Some(InstallSource::Server),
result: Err(server_err),
}
}
}
};
// Post-install verification: confirm the binary actually landed at the
// expected path and is functional. This catches silent install failures
// that would otherwise surface as a cryptic IPC handshake error.
if outcome.result.is_ok() {
log::info!("Running post-install verification for {binary_path}");
let check_cmd = remote_server::setup::binary_check_command();
let verify = remote_server::ssh::run_ssh_command(
socket_path,
&check_cmd,
remote_server::setup::CHECK_TIMEOUT,
)
.await;
match verify {
Ok(output) if output.status.success() => {}
Ok(output) => {
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
outcome.result = Err(Error::Other(anyhow::anyhow!(
"Post-install verification failed: binary not found or not \
executable at {binary_path} (exit {code}): {stderr}"
)));
}
Err(e) => {
outcome.result = Err(Error::Other(anyhow::anyhow!(
"Post-install verification failed: {e}"
)));
}
}
}
outcome
}
/// Runs the install script on the remote host to download and install the
/// binary directly from the CDN.
async fn install_on_server(socket_path: &Path) -> Result<(), Error> {
let script = remote_server::setup::install_script(None);
match remote_server::ssh::run_ssh_script(
socket_path,
&script,
remote_server::setup::INSTALL_TIMEOUT,
)
.await
{
Ok(output) if output.status.success() => Ok(()),
Ok(output) => {
let exit_code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(Error::ScriptFailed { exit_code, stderr })
}
Err(SshCommandError::TimedOut { .. }) => Err(Error::TimedOut),
Err(e) => Err(Error::Other(e.into())),
}
}
@@ -0,0 +1,305 @@
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Context as _;
use futures::{AsyncWriteExt as _, TryStreamExt as _};
use http_client::StatusCode;
use remote_server::setup::RemotePlatform;
use remote_server::transport::Error;
const REMOTE_SERVER_TARBALL_CACHE_FILE_NAME: &str = "oz.tar.gz";
const REMOTE_SERVER_TARBALL_DOWNLOAD_ATTEMPTS: usize = 3;
// The local SCP fallback download can run over slow or captive networks. Match
// the install-script timeout so slow client-side downloads have the same budget
// as remote-host downloads.
const REMOTE_SERVER_TARBALL_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
// Keep retry backoff short because retries only cover transient HTTP failures;
// the longer timeout above handles slow successful downloads.
const REMOTE_SERVER_TARBALL_DOWNLOAD_RETRY_DELAY: Duration = Duration::from_millis(250);
/// Exit codes where SCP fallback would not help because the failure is on the
/// remote host itself, not a network/download issue.
pub(super) fn should_try_install(error: &Error) -> bool {
!matches!(error, Error::ScriptFailed { exit_code, .. } if *exit_code == 2)
}
/// Installs the remote server via SCP fallback.
///
/// The tarball is downloaded or reused from the local cache first, then uploaded
/// to the remote host and passed to the install script as an already-downloaded
/// archive. This avoids requiring the remote host to download the tarball itself.
pub(super) async fn install(socket_path: &Path) -> Result<(), Error> {
let platform = super::super::detect_remote_platform(socket_path).await?;
let client_tarball_path = cached_remote_server_tarball(&platform)
.await
.map_err(Error::Other)?;
let timeout = remote_server::setup::SCP_INSTALL_TIMEOUT;
let install_dir = remote_server::setup::remote_server_dir();
let remote_tarball_name = format!("oz-upload-{}.tar.gz", uuid::Uuid::new_v4());
let remote_tarball_path = format!("{install_dir}/{remote_tarball_name}");
// The normal install script creates this directory before downloading, but
// SCP fallback can run after a failure that happened before that point.
// Ensure the destination exists before uploading the staged tarball.
let mkdir_output = remote_server::ssh::run_ssh_command(
socket_path,
&format!("mkdir -p {install_dir}"),
remote_server::setup::CHECK_TIMEOUT,
)
.await
.map_err(Error::from)?;
if !mkdir_output.status.success() {
let code = mkdir_output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&mkdir_output.stderr).to_string();
return Err(Error::ScriptFailed {
exit_code: code,
stderr,
});
}
log::info!("Uploading tarball to remote at {remote_tarball_path}");
remote_server::ssh::scp_upload(
socket_path,
&client_tarball_path,
&remote_tarball_path,
timeout,
)
.await
.map_err(Error::Other)?;
log::info!("Running extraction via install script with tarball at {remote_tarball_path}");
let script = remote_server::setup::install_script(Some(&remote_tarball_path));
let output = remote_server::ssh::run_ssh_script(socket_path, &script, timeout)
.await
.map_err(Error::from)?;
if output.status.success() {
Ok(())
} else {
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(Error::ScriptFailed {
exit_code: code,
stderr,
})
}
}
fn remote_server_tarball_cache_root() -> PathBuf {
galaxy_core::paths::cache_dir()
.join("remote-server")
.join("tarballs")
}
fn remote_server_tarball_cache_temp_dir() -> PathBuf {
remote_server_tarball_cache_root().join(".tmp")
}
fn current_remote_server_tarball_cache_version() -> &'static str {
remote_server::setup::remote_server_artifact_version()
}
fn remote_server_tarball_cache_path(platform: &RemotePlatform) -> PathBuf {
remote_server_tarball_cache_root()
.join(current_remote_server_tarball_cache_version())
.join(format!(
"{}-{}",
platform.os.as_str(),
platform.arch.as_str()
))
.join(REMOTE_SERVER_TARBALL_CACHE_FILE_NAME)
}
async fn is_valid_cached_tarball(path: &Path) -> bool {
async_fs::metadata(path)
.await
.is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
}
/// Returns a local tarball for the remote platform.
///
/// Reuses an existing cached tarball when available; otherwise downloads the
/// tarball into the cache and returns the newly cached path.
async fn cached_remote_server_tarball(platform: &RemotePlatform) -> anyhow::Result<PathBuf> {
let cache_path = remote_server_tarball_cache_path(platform);
if is_valid_cached_tarball(&cache_path).await {
log::info!(
"Using cached remote-server tarball at {}",
cache_path.display()
);
return Ok(cache_path);
}
if async_fs::metadata(&cache_path).await.is_ok() {
let _ = async_fs::remove_file(&cache_path).await;
}
let url = remote_server::setup::download_tarball_url(platform);
log::info!(
"Downloading remote-server tarball from {url} into cache at {}",
cache_path.display()
);
download_remote_server_tarball_to_cache(&url, &cache_path).await?;
Ok(cache_path)
}
async fn download_remote_server_tarball_to_cache(
url: &str,
cache_path: &Path,
) -> anyhow::Result<()> {
let parent = cache_path
.parent()
.context("remote-server tarball cache path has no parent directory")?;
async_fs::create_dir_all(parent).await.with_context(|| {
format!(
"Failed to create remote-server tarball cache directory '{}'",
parent.display()
)
})?;
let temp_dir = remote_server_tarball_cache_temp_dir();
async_fs::create_dir_all(&temp_dir).await.with_context(|| {
format!(
"Failed to create remote-server tarball cache temp directory '{}'",
temp_dir.display()
)
})?;
// Download into a unique temp path first so a failed or partial download
// never appears at the shared cache path that other installs may reuse.
let temp_path = temp_dir.join(format!(
".{REMOTE_SERVER_TARBALL_CACHE_FILE_NAME}.{}.tmp",
uuid::Uuid::new_v4()
));
if let Err(e) = download_remote_server_tarball_with_retries(url, &temp_path).await {
let _ = async_fs::remove_file(&temp_path).await;
return Err(e);
}
if !is_valid_cached_tarball(&temp_path).await {
let _ = async_fs::remove_file(&temp_path).await;
anyhow::bail!("Downloaded remote-server tarball from {url} was empty");
}
if is_valid_cached_tarball(cache_path).await {
let _ = async_fs::remove_file(&temp_path).await;
return Ok(());
}
// Publish the validated temp file to the shared cache path. If another
// concurrent fallback populated the cache after the check above, that valid
// cache hit is good enough for this install, so discard our temp file.
match async_fs::rename(&temp_path, cache_path).await {
Ok(()) => Ok(()),
Err(e) if is_valid_cached_tarball(cache_path).await => {
let _ = async_fs::remove_file(&temp_path).await;
Ok(())
}
Err(e) => {
let _ = async_fs::remove_file(&temp_path).await;
Err(e).with_context(|| {
format!(
"Failed to move remote-server tarball into cache at '{}'",
cache_path.display()
)
})
}
}
}
async fn download_remote_server_tarball_with_retries(
url: &str,
temp_path: &Path,
) -> anyhow::Result<()> {
let http_client = http_client::Client::new();
let mut last_retryable_error = None;
for attempt in 1..=REMOTE_SERVER_TARBALL_DOWNLOAD_ATTEMPTS {
match download_remote_server_tarball_internal(&http_client, url, temp_path).await {
Ok(()) => return Ok(()),
Err(DownloadAttemptError::Permanent(e)) => return Err(e),
Err(DownloadAttemptError::Retryable(e)) => {
last_retryable_error = Some(e);
if attempt < REMOTE_SERVER_TARBALL_DOWNLOAD_ATTEMPTS {
log::warn!("Remote-server tarball download attempt {attempt} failed; retrying");
tokio::time::sleep(REMOTE_SERVER_TARBALL_DOWNLOAD_RETRY_DELAY).await;
}
}
}
}
Err(last_retryable_error.unwrap_or_else(|| {
anyhow::anyhow!("Remote-server tarball download failed without an error")
}))
}
enum DownloadAttemptError {
Retryable(anyhow::Error),
Permanent(anyhow::Error),
}
async fn download_remote_server_tarball_internal(
http_client: &http_client::Client,
url: &str,
temp_path: &Path,
) -> Result<(), DownloadAttemptError> {
let response = http_client
.get(url)
.timeout(REMOTE_SERVER_TARBALL_DOWNLOAD_TIMEOUT)
.send()
.await
.map_err(|e| {
DownloadAttemptError::Retryable(anyhow::anyhow!(
"Failed to download remote-server tarball from {url}: {e}"
))
})?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
let error =
anyhow::anyhow!("Remote-server tarball download failed with status {status}: {body}");
return if is_retryable_download_status(status) {
Err(DownloadAttemptError::Retryable(error))
} else {
Err(DownloadAttemptError::Permanent(error))
};
}
let mut file = async_fs::File::create(temp_path).await.map_err(|e| {
DownloadAttemptError::Permanent(anyhow::anyhow!(
"Failed to create remote-server tarball cache file '{}': {e}",
temp_path.display()
))
})?;
let mut bytes_stream = response.bytes_stream();
while let Some(chunk) = bytes_stream.try_next().await.map_err(|e| {
DownloadAttemptError::Retryable(anyhow::anyhow!(
"Failed to read remote-server tarball response body from {url}: {e}"
))
})? {
file.write_all(&chunk).await.map_err(|e| {
DownloadAttemptError::Permanent(anyhow::anyhow!(
"Failed to write remote-server tarball cache file '{}': {e}",
temp_path.display()
))
})?;
}
file.sync_data().await.map_err(|e| {
DownloadAttemptError::Permanent(anyhow::anyhow!(
"Failed to sync remote-server tarball cache file '{}': {e}",
temp_path.display()
))
})?;
Ok(())
}
fn is_retryable_download_status(status: StatusCode) -> bool {
matches!(
status,
StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_MANY_REQUESTS
) || status.is_server_error()
}
@@ -0,0 +1,27 @@
use warpui::r#async::BoxFuture;
use super::*;
fn static_auth_context() -> Arc<RemoteServerAuthContext> {
Arc::new(RemoteServerAuthContext::new(
|| -> BoxFuture<'static, Option<String>> { Box::pin(async { None }) },
|| "user id/with spaces".to_string(),
String::new(),
String::new(),
true,
))
}
#[test]
fn remote_proxy_command_quotes_identity_key() {
let transport = SshTransport::new(
PathBuf::from("/tmp/control-master.sock"),
static_auth_context(),
true,
);
let command = transport.remote_proxy_command();
assert!(command.contains("remote-server-proxy --identity-key"));
assert!(command.contains("'user id/with spaces'"));
}
+138 -57
View File
@@ -11,73 +11,90 @@
//! All platform-specific code is contained here so that the parent `mod.rs`
//! is a thin dispatcher with no Unix assumptions.
mod proxy;
pub(super) mod proxy;
use super::server_model::{ConnectionId, ServerModel};
use galaxyui::r#async::executor;
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
/// Run the `remote-server-proxy` subcommand.
///
/// Ensures the daemon is running (starting it if necessary), then bridges
/// this process's stdin/stdout to the daemon's Unix socket for the lifetime
/// of the SSH session.
pub fn run_proxy(identity_key: String) -> anyhow::Result<()> {
env_logger::Builder::from_default_env()
.target(env_logger::Target::Stderr)
.init();
proxy::run(&identity_key)
}
use warpui::r#async::executor;
use warpui::SingletonEntity;
use super::server_model::{ConnectionId, ServerModel};
use crate::{send_telemetry_from_app_ctx, TelemetryEvent};
/// Run the `remote-server-daemon` subcommand.
///
/// Binds a Unix domain socket and writes a PID file, then delegates the
/// WarpUI app startup to [`super::run_daemon_app`] with the Unix-specific
/// `ServerModel` constructor.
/// Delegates to `run_internal` with `LaunchMode::RemoteServerDaemon`.
/// All initialization (feature flags, profiling, logging, resource limits,
/// TLS, `initialize_app`, crash reporting) is handled by `run_internal`.
/// The daemon-specific socket binding and `ServerModel` registration
/// happen in [`launch_daemon`], called from `launch()`.
pub fn run_daemon(identity_key: String) -> anyhow::Result<()> {
// Log to a rotating file so daemon output is preserved across invocations.
// The file is written to the same directory as client logs (~/Library/Logs
// on macOS, ~/.local/share/warp-terminal on Linux). Since the daemon runs
// on the remote host, there is no conflict with client-side log files.
galaxy_logging::init(galaxy_logging::LogConfig {
is_cli: true,
log_destination: Some(galaxy_logging::LogDestination::File),
})?;
let result = crate::run_internal(crate::LaunchMode::RemoteServerDaemon {
identity_key: identity_key.clone(),
});
// socket_path: ~/.warp[-channel]/remote-server/{identity_key}/server.sock
// The Unix domain socket the daemon binds on. Proxy processes connect
// to it and bridge their SSH stdio channel through it.
//
// pid_path: ~/.warp[-channel]/remote-server/{identity_key}/server.pid
// Contains the daemon's PID. Proxy processes read it and use
// kill(pid, 0) to detect whether the daemon is still alive before
// deciding whether to start a new one.
// Clean up socket and PID files after the event loop exits.
let socket_path = proxy::socket_path(&identity_key);
let pid_path = proxy::pid_path(&identity_key);
let _ = std::fs::remove_file(&socket_path);
let _ = std::fs::remove_file(&pid_path);
log::info!("Daemon exiting");
result
}
/// Called from `launch()` inside the headless AppBuilder callback.
/// Binds the Unix domain socket, writes the PID file, spawns the
/// accept loop, and registers the `ServerModel` singleton.
pub(crate) fn launch_daemon(identity_key: &str, ctx: &mut warpui::AppContext) {
let socket_path = proxy::socket_path(identity_key);
let pid_path = proxy::pid_path(identity_key);
if let Some(parent) = socket_path.parent() {
proxy::ensure_private_daemon_dir(parent)?;
if let Err(e) = proxy::ensure_private_daemon_dir(parent) {
log::error!("Failed to create daemon directory: {e}");
return;
}
}
if socket_path.exists() {
std::fs::remove_file(&socket_path)?;
let _ = std::fs::remove_file(&socket_path);
}
// Bind with std (no async runtime needed yet); converted to
// async_io::Async inside the closure where the executor is active.
let listener = std::os::unix::net::UnixListener::bind(&socket_path)?;
std::fs::set_permissions(&socket_path, Permissions::from_mode(0o600))?;
// async_io::Async::new() requires non-blocking mode.
listener.set_nonblocking(true)?;
let listener = match std::os::unix::net::UnixListener::bind(&socket_path) {
Ok(l) => l,
Err(e) => {
log::error!("Daemon: failed to bind socket: {e}");
return;
}
};
let _ = std::fs::set_permissions(&socket_path, Permissions::from_mode(0o600));
listener.set_nonblocking(true).ok();
log::info!("Daemon bound to {}", socket_path.display());
std::fs::write(&pid_path, std::process::id().to_string())?;
// Flush the accumulated IntervalTimer data as telemetry now that the
// daemon is ready to accept connections. The timer was created in
// `run_internal` and carries intervals from the full startup path
// (logging, SQLite, singleton models, etc.).
//
// All telemetry dependencies are ready at this point:
// `AppTelemetryContextProvider` and `AuthStateProvider` are
// registered during `initialize_app` (before `launch` calls us),
// and `TelemetryCollector` is already running its periodic flush.
// The flush sends directly to Rudderstack using a baked-in write
// key — no user auth token is required.
let timing_data =
galaxy_core::interval_timer::IntervalTimer::handle(ctx).update(ctx, |timer, _| {
timer.mark_interval_end("DAEMON_SOCKET_BOUND");
timer.compute_stats()
});
send_telemetry_from_app_ctx!(
TelemetryEvent::RemoteServerDaemonStartup { timing_data },
ctx
);
super::run_daemon_app(move |ctx| {
// Spawn the Unix socket accept loop. The listener and connection
// handling are entirely Unix-specific; ServerModel itself is
// platform-agnostic and only sees register_connection /
// deregister_connection calls.
let _ = std::fs::write(&pid_path, std::process::id().to_string());
ctx.add_singleton_model(move |ctx| {
let spawner = ctx.spawner();
let exec = ctx.background_executor();
let spawner_loop = spawner.clone();
@@ -113,12 +130,7 @@ pub fn run_daemon(identity_key: String) -> anyhow::Result<()> {
.detach();
ServerModel::new(ctx)
})?;
let _ = std::fs::remove_file(&socket_path);
let _ = std::fs::remove_file(&pid_path);
log::info!("Daemon exiting");
Ok(())
});
}
/// Handles a single Unix socket connection from a proxy process.
@@ -186,7 +198,13 @@ pub(super) async fn handle_daemon_connection(
log::warn!("Daemon: skipping malformed message from conn {conn_id}: {e}");
}
Err(e) => {
log::error!("Daemon: fatal read error from conn {conn_id}: {e}");
if is_disconnect_error(&e) {
log::warn!(
"Daemon: read error from conn {conn_id} (client disconnected): {e}"
);
} else {
log::error!("Daemon: fatal read error from conn {conn_id}: {e}");
}
break;
}
}
@@ -206,13 +224,53 @@ pub(super) async fn handle_daemon_connection(
// deregister_connection) or a fatal write error occurs.
while let Ok(msg) = conn_rx.recv().await {
if let Err(e) = remote_server::protocol::write_server_message(&mut writer, &msg).await {
log::error!("Daemon: write error on conn {conn_id}: {e}");
break;
if !e.is_write_recoverable() {
if is_disconnect_protocol_error(&e) {
log::warn!("Daemon: write error on conn {conn_id} (client disconnected): {e}");
} else {
log::error!("Daemon: write error on conn {conn_id}: {e}");
}
break;
}
// Recoverable write error (e.g. MessageTooLarge): nothing was
// written to the stream, so it remains aligned. Log and skip
// rather than tearing down the entire connection.
log::warn!("Daemon: skipping undeliverable message on conn {conn_id}: {e}");
// Send an ErrorResponse so the client doesn't hang waiting
// for a response that will never arrive.
if msg.request_id.is_empty() {
continue;
}
let error_msg = remote_server::proto::ServerMessage {
request_id: msg.request_id.clone(),
message: Some(remote_server::proto::server_message::Message::Error(
remote_server::proto::ErrorResponse {
code: remote_server::proto::ErrorCode::Internal.into(),
message: format!("Response could not be delivered: {e}"),
},
)),
};
if let Err(e2) =
remote_server::protocol::write_server_message(&mut writer, &error_msg).await
{
if !e2.is_write_recoverable() {
log::error!("Daemon: failed to send error response on conn {conn_id}: {e2}");
break;
}
log::warn!("Daemon: failed to send error response on conn {conn_id}: {e2}");
continue;
}
// Fall through to flush the error response.
}
// Flush after every message so responses reach the proxy without
// waiting for the BufWriter's internal buffer to fill up.
if let Err(e) = writer.flush().await {
log::error!("Daemon: flush error on conn {conn_id}: {e}");
if is_disconnect_io_error(&e) {
log::warn!("Daemon: flush error on conn {conn_id} (client disconnected): {e}");
} else {
log::error!("Daemon: flush error on conn {conn_id}: {e}");
}
break;
}
}
@@ -227,3 +285,26 @@ pub(super) async fn handle_daemon_connection(
})
.await;
}
/// Returns `true` if the IO error represents a normal client disconnect.
fn is_disconnect_io_error(e: &std::io::Error) -> bool {
matches!(
e.kind(),
std::io::ErrorKind::BrokenPipe
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::ConnectionAborted
)
}
/// Returns `true` if the `ProtocolError` wraps a disconnect IO error.
fn is_disconnect_error(e: &remote_server::protocol::ProtocolError) -> bool {
match e {
remote_server::protocol::ProtocolError::Io(io_err) => is_disconnect_io_error(io_err),
_ => false,
}
}
/// Alias for [`is_disconnect_error`] — used in the write path for clarity.
fn is_disconnect_protocol_error(e: &remote_server::protocol::ProtocolError) -> bool {
is_disconnect_error(e)
}
+82 -4
View File
@@ -19,18 +19,69 @@ use std::time::Duration;
use super::super::setup;
/// Path to the daemon's Unix domain socket.
/// Path to the daemon's Unix domain socket, versioned on release channels.
pub(super) fn socket_path(identity_key: &str) -> PathBuf {
let dir = setup::remote_server_daemon_dir(identity_key);
let expanded = shellexpand::tilde(&dir).into_owned();
PathBuf::from(expanded).join("server.sock")
PathBuf::from(expanded).join(setup::daemon_socket_name())
}
/// Path to the daemon's PID file (also used as the flock target).
/// Path to the daemon's PID file (also used as the flock target),
/// versioned on release channels.
pub(super) fn pid_path(identity_key: &str) -> PathBuf {
let dir = setup::remote_server_daemon_dir(identity_key);
let expanded = shellexpand::tilde(&dir).into_owned();
PathBuf::from(expanded).join("server.pid")
PathBuf::from(expanded).join(setup::daemon_pid_name())
}
/// Daemon directory for the given identity key (expanded, no tilde).
fn daemon_dir(identity_key: &str) -> PathBuf {
let dir = setup::remote_server_daemon_dir(identity_key);
let expanded = shellexpand::tilde(&dir).into_owned();
PathBuf::from(expanded)
}
/// Scans the identity-key daemon directory and removes socket/PID files
/// from previous daemon versions.
///
/// Old daemons are **not** killed — they may still be serving active
/// connections from an older Warp client. Removing their socket file
/// prevents new proxies from accidentally connecting to them, and the
/// daemon's built-in grace timer (10 min with no connections) will shut
/// it down naturally after the last client disconnects.
///
/// Errors are logged but do not prevent the proxy from proceeding.
fn cleanup_old_versions(identity_key: &str) {
let dir = daemon_dir(identity_key);
let current_socket = setup::daemon_socket_name();
let current_pid = setup::daemon_pid_name();
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
// Remove old PID files: server*.pid that aren't the current version.
if name_str.ends_with(".pid") && name_str.starts_with("server") && name_str != current_pid {
log::info!("Proxy: removing old PID file {name_str}");
let _ = std::fs::remove_file(entry.path());
}
// Remove old socket files: server*.sock that aren't the current version.
if name_str.ends_with(".sock")
&& name_str.starts_with("server")
&& name_str != current_socket
{
log::info!("Proxy: removing old socket file {name_str}");
let _ = std::fs::remove_file(entry.path());
}
}
}
/// Ensures the daemon directory exists with owner-only permissions.
@@ -40,6 +91,13 @@ pub(super) fn ensure_private_daemon_dir(path: &std::path::Path) -> anyhow::Resul
Ok(())
}
/// Maximum usable `sun_path` length for Unix domain sockets.
///
/// macOS has the strictest limit (104 bytes including null terminator,
/// 103 usable). We use 103 on all platforms so a single binary works
/// everywhere without per-target branching.
const SUN_PATH_MAX: usize = 103;
/// Entry point for `remote-server-proxy`.
///
/// Ensures the daemon is running, then bridges stdin/stdout to the daemon's
@@ -48,11 +106,31 @@ pub fn run(identity_key: &str) -> anyhow::Result<()> {
let socket_path = socket_path(identity_key);
let pid_path = pid_path(identity_key);
// Guard against socket paths that exceed the sun_path limit.
// Without this check, UnixListener::bind fails silently in the
// daemon and the proxy times out after 10s with no actionable
// error. With hashed identity + version names the path should
// always fit, so hitting this guard indicates a new path component
// was added without budgeting for sun_path. The error surfaces in
// client telemetry (RemoteServerInitialization) and daemon logs.
let path_len = socket_path.as_os_str().len();
if path_len > SUN_PATH_MAX {
anyhow::bail!(
"daemon socket path is {path_len} bytes, which exceeds the \
sun_path limit of {SUN_PATH_MAX} bytes: {}",
socket_path.display()
);
}
// Ensure the parent directory exists.
if let Some(parent) = socket_path.parent() {
ensure_private_daemon_dir(parent)?;
}
// Clean up socket/PID files from previous daemon versions so stale
// daemons left over after autoupdate don't linger.
cleanup_old_versions(identity_key);
// ---- Acquire exclusive flock on the PID file --------------------------------
//
// This serialises concurrent proxy starts. If two tabs SSH in at the