first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(¶ms.pattern),
|
||||
¶ms.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
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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'"));
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user