701 lines
24 KiB
Rust
701 lines
24 KiB
Rust
#![cfg_attr(
|
|
not(target_os = "windows"),
|
|
allow(dead_code, unused_imports, unreachable_code)
|
|
)]
|
|
|
|
pub mod api;
|
|
pub mod clipboard_watcher;
|
|
pub mod db;
|
|
pub mod embedding;
|
|
pub mod error;
|
|
pub mod handlers;
|
|
pub mod indexer;
|
|
pub mod mcp;
|
|
pub mod models;
|
|
pub mod ollama;
|
|
pub mod router;
|
|
pub mod search;
|
|
pub mod state;
|
|
pub mod store;
|
|
pub mod tools;
|
|
pub mod vector_db;
|
|
pub mod watcher;
|
|
|
|
use crate::api::rest::GateSetReq;
|
|
use crate::router::MemoryHandler;
|
|
use crate::state::MemoryState;
|
|
use clap::{Parser, Subcommand};
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::{Arc, RwLock};
|
|
use std::time::Duration;
|
|
use tokio::sync::mpsc;
|
|
|
|
#[derive(Parser)]
|
|
#[command(author, version = env!("APP_VERSION"), about = "Antigravity MCP Memory Server", long_about = None)]
|
|
pub struct Cli {
|
|
#[command(subcommand)]
|
|
pub command: Option<Commands>,
|
|
/// Target URL for the proxy to connect to (e.g., http://127.0.0.1:3000)
|
|
#[arg(long)]
|
|
pub target: Option<String>,
|
|
/// Send a shutdown request to the currently running server
|
|
#[arg(long)]
|
|
pub exit: bool,
|
|
/// Send a shutdown request to the existing server and wait for it to exit
|
|
#[arg(long)]
|
|
pub restart: bool,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum Commands {
|
|
/// Manage authorization gates and verification for actions
|
|
Gate {
|
|
#[command(subcommand)]
|
|
subcmd: GateCommands,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum GateCommands {
|
|
Set {
|
|
#[arg(long)]
|
|
action: String,
|
|
#[arg(long)]
|
|
target: String,
|
|
#[arg(long)]
|
|
namespace: Option<String>,
|
|
#[arg(short = 'p', long = "param")]
|
|
params: Vec<String>,
|
|
#[arg(long, conflicts_with = "block")]
|
|
authorize: bool,
|
|
#[arg(long, conflicts_with = "authorize")]
|
|
block: bool,
|
|
#[arg(long)]
|
|
reason: Option<String>,
|
|
},
|
|
Verify {
|
|
#[arg(long)]
|
|
action: String,
|
|
#[arg(long)]
|
|
target: String,
|
|
#[arg(long)]
|
|
namespace: Option<String>,
|
|
#[arg(short = 'p', long = "param")]
|
|
params: Vec<String>,
|
|
#[arg(long)]
|
|
consume: bool,
|
|
},
|
|
}
|
|
|
|
pub struct AppState {
|
|
pub handler: Arc<MemoryHandler>,
|
|
pub clients: RwLock<HashMap<String, mpsc::Sender<String>>>,
|
|
pub next_id: AtomicUsize,
|
|
pub shutdown_tx: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
|
}
|
|
|
|
pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
|
|
loop {
|
|
tokio::time::sleep(Duration::from_secs(3600)).await;
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
|
|
state.project.tasks.modify(|tasks| {
|
|
tasks.retain(|t| t.expires_at.is_none_or(|exp| exp > now));
|
|
});
|
|
state.code.sticky.modify(|notes| {
|
|
notes.retain(|n| n.expires_at.is_none_or(|exp| exp > now));
|
|
});
|
|
state.telemetry.handoff_memos.modify(|memos| {
|
|
memos.retain(|m| m.expires_at.is_none_or(|exp| exp > now));
|
|
});
|
|
state.telemetry.session_summaries.modify(|summaries| {
|
|
summaries.retain(|s| s.expires_at.is_none_or(|exp| exp > now));
|
|
});
|
|
}
|
|
}
|
|
|
|
pub async fn index_committer_worker(state: Arc<MemoryState>) {
|
|
loop {
|
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
let idx_opt = state.search_index.read().ok().map(|idx| idx.clone());
|
|
if let Some(idx) = idx_opt {
|
|
let _ = idx.commit().await;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn condense_graph_worker(state: Arc<MemoryState>) {
|
|
loop {
|
|
tokio::time::sleep(Duration::from_secs(3600)).await;
|
|
|
|
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
|
|
.unwrap_or_else(|_| "100".to_string())
|
|
.parse()
|
|
.unwrap_or(100);
|
|
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
|
|
let mut condensed_sticky_content = String::new();
|
|
state.code.sticky.modify(|notes| {
|
|
if notes.len() > threshold {
|
|
notes.sort_by_key(|n| n.timestamp);
|
|
let to_remove = notes.len() - (threshold / 2);
|
|
let removed: Vec<_> = notes.drain(0..to_remove).collect();
|
|
for r in removed {
|
|
condensed_sticky_content.push_str(&format!("{}\n", r.content));
|
|
}
|
|
}
|
|
});
|
|
|
|
if !condensed_sticky_content.is_empty() {
|
|
state.modify_graph(|graph| {
|
|
let name = format!("StickyNote History {}", now);
|
|
graph.entities.insert(
|
|
name.clone(),
|
|
crate::models::Entity {
|
|
name: name.clone(),
|
|
entity_type: "Historical Summary".to_string(),
|
|
observations: vec![condensed_sticky_content],
|
|
namespace: crate::models::default_namespace(),
|
|
git_branch: None,
|
|
},
|
|
);
|
|
});
|
|
tracing::info!("Condensed sticky notes into Historical Summary.");
|
|
}
|
|
|
|
let mut condensed_snippet_content = String::new();
|
|
state.code.snippets.modify(|snippets| {
|
|
if snippets.len() > threshold {
|
|
snippets.sort_by_key(|s| s.updated_at);
|
|
let to_remove = snippets.len() - (threshold / 2);
|
|
let removed: Vec<_> = snippets.drain(0..to_remove).collect();
|
|
for r in removed {
|
|
condensed_snippet_content.push_str(&format!(
|
|
"Name: {}\nDesc: {}\nCode: {}\n",
|
|
r.name, r.description, r.code
|
|
));
|
|
}
|
|
}
|
|
});
|
|
|
|
if !condensed_snippet_content.is_empty() {
|
|
state.modify_graph(|graph| {
|
|
let name = format!("Snippet History {}", now);
|
|
graph.entities.insert(
|
|
name.clone(),
|
|
crate::models::Entity {
|
|
name: name.clone(),
|
|
entity_type: "Historical Summary".to_string(),
|
|
observations: vec![condensed_snippet_content],
|
|
namespace: crate::models::default_namespace(),
|
|
git_branch: None,
|
|
},
|
|
);
|
|
});
|
|
tracing::info!("Condensed snippets into Historical Summary.");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>> {
|
|
let state_for_index = Arc::clone(&state);
|
|
tokio::spawn(async move {
|
|
state_for_index.rebuild_index().await;
|
|
tracing::info!("Index rebuild complete.");
|
|
});
|
|
|
|
crate::indexer::start_background_indexer(Arc::clone(&state)).await;
|
|
|
|
tokio::spawn(index_committer_worker(Arc::clone(&state)));
|
|
tokio::spawn(ttl_sweeper_worker(Arc::clone(&state)));
|
|
tokio::spawn(condense_graph_worker(Arc::clone(&state)));
|
|
crate::clipboard_watcher::spawn_watcher(Arc::clone(&state));
|
|
crate::watcher::spawn_watcher(Arc::clone(&state));
|
|
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
|
let app_state = Arc::new(AppState {
|
|
handler: Arc::new(MemoryHandler::new(Arc::clone(&state))),
|
|
clients: RwLock::new(HashMap::new()),
|
|
next_id: AtomicUsize::new(1),
|
|
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
|
|
});
|
|
|
|
let app_state_clone = Arc::clone(&app_state);
|
|
let mut rx = state.activity_tx.subscribe();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
match rx.recv().await {
|
|
Ok(msg) => {
|
|
let senders: Vec<_> = app_state_clone
|
|
.clients
|
|
.read()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.values()
|
|
.cloned()
|
|
.collect();
|
|
for client_tx in senders {
|
|
let _ = client_tx.try_send(msg.clone());
|
|
}
|
|
}
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
|
}
|
|
}
|
|
});
|
|
|
|
let udp_state = Arc::clone(&app_state);
|
|
tokio::spawn(async move {
|
|
let port1 = std::env::var("MCP_UDP_PORT1").unwrap_or_else(|_| "3001".to_string());
|
|
if let Ok(socket) = tokio::net::UdpSocket::bind(format!("127.0.0.1:{}", port1)).await {
|
|
let mut buf = [0; 4096];
|
|
loop {
|
|
if let Ok((len, _addr)) = socket.recv_from(&mut buf).await
|
|
&& let Ok(payload) =
|
|
serde_json::from_slice::<crate::models::TerminalHistory>(&buf[..len])
|
|
{
|
|
udp_state
|
|
.handler
|
|
.state
|
|
.record_terminal_history(payload.clone());
|
|
|
|
let ws_msg = serde_json::json!({
|
|
"type": "terminal_telemetry",
|
|
"data": payload
|
|
});
|
|
let msg_str = ws_msg.to_string();
|
|
|
|
let senders: Vec<_> = udp_state
|
|
.clients
|
|
.read()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.values()
|
|
.cloned()
|
|
.collect();
|
|
for tx in senders {
|
|
let _ = tx.try_send(msg_str.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let nvim_udp_state = Arc::clone(&app_state);
|
|
tokio::spawn(async move {
|
|
let port2 = std::env::var("MCP_UDP_PORT2").unwrap_or_else(|_| "3002".to_string());
|
|
if let Ok(socket) = tokio::net::UdpSocket::bind(format!("127.0.0.1:{}", port2)).await {
|
|
let mut buf = [0; 4096];
|
|
loop {
|
|
if let Ok((len, _addr)) = socket.recv_from(&mut buf).await
|
|
&& let Ok(payload) =
|
|
serde_json::from_slice::<crate::api::telemetry::NvimTelemetry>(&buf[..len])
|
|
{
|
|
if payload.event == "FocusGained"
|
|
|| payload.event == "BufEnter"
|
|
|| payload.event == "VimEnter"
|
|
{
|
|
let session = &payload.session_id;
|
|
let is_unix_socket = session.starts_with('/') || session.starts_with('~');
|
|
if is_unix_socket {
|
|
let wsl_path =
|
|
"\\\\wsl.localhost\\Ubuntu\\home\\riz\\.gemini\\active_nvim.txt";
|
|
let _ = tokio::fs::write(wsl_path, session).await;
|
|
} else {
|
|
let profile = std::env::var("USERPROFILE")
|
|
.unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into());
|
|
let win_path = format!("{}\\.gemini\\active_nvim.txt", profile);
|
|
let _ = tokio::fs::write(&win_path, session).await;
|
|
}
|
|
}
|
|
|
|
let ws_msg = serde_json::json!({
|
|
"type": "nvim_telemetry",
|
|
"data": payload
|
|
});
|
|
let msg_str = ws_msg.to_string();
|
|
|
|
let senders: Vec<_> = nvim_udp_state
|
|
.clients
|
|
.read()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.values()
|
|
.cloned()
|
|
.collect();
|
|
for tx in senders {
|
|
let _ = tx.try_send(msg_str.clone());
|
|
}
|
|
|
|
if payload.event == "BufWritePost"
|
|
&& let Some(ref file_path) = payload.file
|
|
{
|
|
let normalized_file = file_path.replace("\\", "/");
|
|
let topic = format!("nvim:save:{}", normalized_file);
|
|
let event = crate::state::GenericEvent {
|
|
topic,
|
|
session_id: Some(payload.session_id.clone()),
|
|
payload: serde_json::json!(&payload),
|
|
};
|
|
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
|
|
}
|
|
|
|
if payload.event.starts_with("agent_") {
|
|
let topic = format!("nvim:ui:{}", payload.event);
|
|
let event = crate::state::GenericEvent {
|
|
topic,
|
|
session_id: Some(payload.session_id.clone()),
|
|
payload: serde_json::json!(&payload),
|
|
};
|
|
let _ = nvim_udp_state.handler.state.event_bus_tx.send(event);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let app = api::setup::create_router(app_state);
|
|
|
|
let port_str = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
|
tracing::info!(
|
|
"MCP Memory Server running on http://127.0.0.1:{}/ws",
|
|
port_str
|
|
);
|
|
let addr: std::net::SocketAddr = format!("127.0.0.1:{}", port_str)
|
|
.parse()
|
|
.expect("Invalid bind address");
|
|
|
|
let listener = match tokio::net::TcpListener::bind(&addr).await {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
let log_path = dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join(".gemini/mcp_memory/daemon_error.log");
|
|
let _ =
|
|
tokio::fs::write(&log_path, format!("Failed to bind to {}: {}\n", addr, e)).await;
|
|
return Ok(());
|
|
}
|
|
};
|
|
if let Err(e) = axum::serve(listener, app.into_make_service())
|
|
.with_graceful_shutdown(async move {
|
|
let _ = shutdown_rx.await;
|
|
})
|
|
.await
|
|
{
|
|
let log_path = dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join(".gemini/mcp_memory/daemon_error.log");
|
|
let _ = tokio::fs::write(&log_path, format!("Server crashed: {}\n", e)).await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn init_logging(app_name: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
|
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
|
|
dirs::home_dir()
|
|
.map(|mut h| {
|
|
h.push(".gemini/mcp_memory");
|
|
h.to_string_lossy().to_string()
|
|
})
|
|
.unwrap_or_else(|| ".gemini/mcp_memory".into())
|
|
});
|
|
let log_dir = std::path::PathBuf::from(base_dir).join("logs");
|
|
std::fs::create_dir_all(&log_dir).unwrap_or_default();
|
|
|
|
let file_appender = tracing_appender::rolling::daily(log_dir, format!("{}.log", app_name));
|
|
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
|
|
|
let _ = tracing_subscriber::fmt()
|
|
.with_writer(non_blocking)
|
|
.with_ansi(false)
|
|
.with_max_level(tracing::Level::INFO)
|
|
.with_thread_ids(true)
|
|
.with_thread_names(true)
|
|
.try_init();
|
|
|
|
Some(guard)
|
|
}
|
|
|
|
pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
|
|
let _guard = init_logging("mcp-memory-server");
|
|
let cli = Cli::parse();
|
|
|
|
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
|
|
dirs::home_dir()
|
|
.map(|mut h| {
|
|
h.push(".gemini/mcp_memory");
|
|
h.to_string_lossy().into_owned()
|
|
})
|
|
.unwrap_or_else(|| ".gemini/mcp_memory".into())
|
|
});
|
|
let base = PathBuf::from(base_dir);
|
|
|
|
if cli.exit {
|
|
let port = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
|
let token = std::fs::read_to_string(base.join("admin.token")).unwrap_or_default();
|
|
let mut cmd = std::process::Command::new("curl");
|
|
cmd.arg("-k").arg("-X").arg("POST");
|
|
if !token.is_empty() {
|
|
cmd.arg("-H")
|
|
.arg(format!("Authorization: Bearer {}", token.trim()));
|
|
}
|
|
let _ = cmd
|
|
.arg(format!("http://127.0.0.1:{}/shutdown", port))
|
|
.output();
|
|
|
|
if cli.restart {
|
|
std::thread::sleep(Duration::from_secs(2));
|
|
} else {
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
if let Some(Commands::Gate { subcmd }) = cli.command {
|
|
let port = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
|
let rt = tokio::runtime::Runtime::new()?;
|
|
match subcmd {
|
|
GateCommands::Set {
|
|
action,
|
|
target,
|
|
namespace,
|
|
params,
|
|
authorize,
|
|
block,
|
|
reason,
|
|
} => {
|
|
let mut pmap = HashMap::new();
|
|
for p in params {
|
|
if let Some((k, v)) = p.split_once('=') {
|
|
pmap.insert(k.to_string(), v.to_string());
|
|
}
|
|
}
|
|
let req = GateSetReq {
|
|
action,
|
|
target,
|
|
namespace,
|
|
params: pmap,
|
|
authorize: if authorize { Some(true) } else { None },
|
|
block: if block { Some(true) } else { None },
|
|
reason,
|
|
};
|
|
rt.block_on(async {
|
|
let client = reqwest::Client::new();
|
|
let res = client
|
|
.post(format!("http://127.0.0.1:{}/gate/set", port))
|
|
.json(&req)
|
|
.send()
|
|
.await;
|
|
match res {
|
|
Ok(r) if r.status().is_success() => println!("Gate updated successfully"),
|
|
Ok(r) => println!("Failed to update gate: {}", r.status()),
|
|
Err(e) => println!("Error connecting to server: {}", e),
|
|
}
|
|
});
|
|
}
|
|
GateCommands::Verify {
|
|
action,
|
|
target,
|
|
namespace,
|
|
params: _,
|
|
consume,
|
|
} => {
|
|
let mut url = format!(
|
|
"http://127.0.0.1:{}/gate/verify?action={}&target={}&consume={}",
|
|
port, action, target, consume
|
|
);
|
|
if let Some(ns) = namespace {
|
|
url.push_str(&format!("&namespace={}", ns));
|
|
}
|
|
rt.block_on(async {
|
|
let res = reqwest::get(&url).await;
|
|
match res {
|
|
Ok(r) if r.status().is_success() => std::process::exit(0),
|
|
Ok(r) if r.status() == reqwest::StatusCode::FORBIDDEN => {
|
|
let text = r.text().await.unwrap_or_default();
|
|
eprintln!("{}", text);
|
|
std::process::exit(1);
|
|
}
|
|
Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND => {
|
|
eprintln!("Action not yet authorized.");
|
|
std::process::exit(2);
|
|
}
|
|
Ok(r) => {
|
|
eprintln!("Unexpected status: {}", r.status());
|
|
std::process::exit(3);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error connecting to server: {}", e);
|
|
std::process::exit(4);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
let token = uuid::Uuid::new_v4().to_string();
|
|
std::fs::write(base.join("admin.token"), &token).unwrap_or_default();
|
|
|
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
|
|
rt.block_on(async {
|
|
let state = Arc::new(MemoryState::new(&base.to_string_lossy()));
|
|
|
|
match crate::vector_db::VectorDB::new("http://localhost:6334", "mcp_memory").await {
|
|
Ok(vdb) => {
|
|
tracing::info!("Successfully connected to Qdrant vector database");
|
|
*state.vector_db.write().await = Some(vdb);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Failed to initialize Qdrant vector database: {}. Vector search will fallback to manual embedding loop. (Is Qdrant running on localhost:6334?)", e);
|
|
}
|
|
}
|
|
|
|
if let Err(e) = run_server(state).await {
|
|
tracing::error!("Server error: {}", e);
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use clap::Parser;
|
|
|
|
#[test]
|
|
fn test_cli_parsing_default() {
|
|
let cli = Cli::try_parse_from(&["mcp-memory-server"]).unwrap();
|
|
assert!(cli.command.is_none());
|
|
assert!(!cli.exit);
|
|
assert!(!cli.restart);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cli_parsing_exit_and_target() {
|
|
let cli = Cli::try_parse_from(&["mcp-memory-server", "--exit", "--target", "http://localhost:3000"]).unwrap();
|
|
assert!(cli.exit);
|
|
assert_eq!(cli.target.as_deref(), Some("http://localhost:3000"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cli_parsing_gate_set() {
|
|
let cli = Cli::try_parse_from(&[
|
|
"mcp-memory-server",
|
|
"gate",
|
|
"set",
|
|
"--action",
|
|
"git_push",
|
|
"--target",
|
|
"master",
|
|
"--authorize",
|
|
"--reason",
|
|
"Approved by lead",
|
|
])
|
|
.unwrap();
|
|
|
|
if let Some(Commands::Gate { subcmd: GateCommands::Set { action, target, authorize, reason, .. } }) = cli.command {
|
|
assert_eq!(action, "git_push");
|
|
assert_eq!(target, "master");
|
|
assert!(authorize);
|
|
assert_eq!(reason.as_deref(), Some("Approved by lead"));
|
|
} else {
|
|
panic!("Expected Gate Set subcommand");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_cli_parsing_gate_verify() {
|
|
let cli = Cli::try_parse_from(&[
|
|
"mcp-memory-server",
|
|
"gate",
|
|
"verify",
|
|
"--action",
|
|
"deploy",
|
|
"--target",
|
|
"prod",
|
|
"--consume",
|
|
])
|
|
.unwrap();
|
|
|
|
if let Some(Commands::Gate { subcmd: GateCommands::Verify { action, target, consume, .. } }) = cli.command {
|
|
assert_eq!(action, "deploy");
|
|
assert_eq!(target, "prod");
|
|
assert!(consume);
|
|
} else {
|
|
panic!("Expected Gate Verify subcommand");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_init_logging_helper() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
unsafe {
|
|
std::env::set_var("MCP_MEMORY_STORE_DIR", temp_dir.path().to_str().unwrap());
|
|
}
|
|
let guard = init_logging("test_app");
|
|
assert!(guard.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_background_workers_one_tick() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap()));
|
|
|
|
// Test worker functions by spawning them briefly
|
|
let handle1 = tokio::spawn(ttl_sweeper_worker(state.clone()));
|
|
let handle2 = tokio::spawn(index_committer_worker(state.clone()));
|
|
let handle3 = tokio::spawn(condense_graph_worker(state.clone()));
|
|
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
|
|
handle1.abort();
|
|
handle2.abort();
|
|
handle3.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_server_graceful_shutdown() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap()));
|
|
|
|
// Bind to a free port to avoid conflicts
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
drop(listener);
|
|
|
|
unsafe {
|
|
std::env::set_var("MCP_PORT", port.to_string());
|
|
std::env::set_var("MCP_UDP_PORT1", (port + 1).to_string());
|
|
std::env::set_var("MCP_UDP_PORT2", (port + 2).to_string());
|
|
}
|
|
|
|
let server_handle = tokio::spawn(async move {
|
|
let _ = run_server(state).await;
|
|
});
|
|
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
|
|
let _ = reqwest::Client::new()
|
|
.get(format!("http://127.0.0.1:{}/ping", port))
|
|
.send()
|
|
.await;
|
|
|
|
server_handle.abort();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|