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