302 lines
13 KiB
Plaintext
302 lines
13 KiB
Plaintext
if let Some(idx) = gates.iter().position(|g| {
|
|
g.action == q.action
|
|
&& g.target == q.target
|
|
&& g.namespace == q.namespace
|
|
&& g.params == q.params
|
|
}) {
|
|
found = Some(gates[idx].clone());
|
|
if q.consume {
|
|
to_remove = Some(idx);
|
|
}
|
|
}
|
|
if let Some(idx) = to_remove {
|
|
gates.remove(idx);
|
|
}
|
|
});
|
|
|
|
match found {
|
|
Some(record) => {
|
|
if record.status == "authorized" {
|
|
(axum::http::StatusCode::OK, "Authorized").into_response()
|
|
} else {
|
|
let msg = if let Some(r) = record.reason {
|
|
format!("Action blocked. Reason: {}", r)
|
|
} else {
|
|
"Action blocked.".to_string()
|
|
};
|
|
(axum::http::StatusCode::FORBIDDEN, msg).into_response()
|
|
}
|
|
}
|
|
None => (
|
|
axum::http::StatusCode::NOT_FOUND,
|
|
"Action not yet authorized (no gate record found).",
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn gate_set_handler(
|
|
State(app_state): State<Arc<AppState>>,
|
|
Json(body): Json<GateSetReq>,
|
|
) -> axum::response::Response {
|
|
let status = if body.block.unwrap_or(false) {
|
|
"blocked".to_string()
|
|
} else if body.authorize.unwrap_or(false) {
|
|
"authorized".to_string()
|
|
} else {
|
|
"pending".to_string()
|
|
};
|
|
|
|
let record = GateRecord {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
action: body.action.clone(),
|
|
target: body.target.clone(),
|
|
namespace: body.namespace.clone(),
|
|
params: body.params.clone(),
|
|
status,
|
|
reason: body.reason.clone(),
|
|
timestamp: crate::handlers::utils::now_secs(),
|
|
};
|
|
app_state.handler.state.gates.modify(|gates| {
|
|
gates.retain(|g| !(g.action == record.action && g.target == record.target));
|
|
gates.push(record);
|
|
});
|
|
(axum::http::StatusCode::OK, "Gate state updated.").into_response()
|
|
}
|
|
|
|
async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>> {
|
|
state.rebuild_index().await;
|
|
tokio::spawn(index_committer_worker(Arc::clone(&state)));
|
|
let app_state = Arc::new(AppState {
|
|
handler: Arc::new(MemoryHandler::new(Arc::clone(&state))),
|
|
clients: RwLock::new(HashMap::new()),
|
|
next_id: AtomicUsize::new(1),
|
|
});
|
|
|
|
let app_state_clone = Arc::clone(&app_state);
|
|
let mut rx = state.activity_tx.subscribe();
|
|
tokio::spawn(async move {
|
|
while let Ok(msg) = rx.recv().await {
|
|
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());
|
|
}
|
|
}
|
|
});
|
|
|
|
let app = Router::new()
|
|
.route(
|
|
"/api/version",
|
|
get(|| async move {
|
|
axum::Json(serde_json::json!({
|
|
"version": env!("APP_VERSION"),
|
|
"git_hash": option_env!("GIT_HASH").unwrap_or("unknown")
|
|
}))
|
|
}),
|
|
)
|
|
.route("/ws", get(ws_handler))
|
|
.route("/health", get(health_handler))
|
|
.route("/nvim/telemetry", post(nvim_telemetry_handler))
|
|
.route("/gate/verify", get(gate_verify_handler))
|
|
.route("/gate/set", post(gate_set_handler))
|
|
.route(
|
|
"/shutdown",
|
|
post(
|
|
|headers: axum::http::HeaderMap, State(state): State<Arc<AppState>>| async move {
|
|
let token_path = state.handler.state.base_dir.join("admin.token");
|
|
let expected_token = tokio::fs::read_to_string(&token_path)
|
|
.await
|
|
.unwrap_or_default()
|
|
.trim()
|
|
.to_string();
|
|
|
|
let auth_header = headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|h| h.to_str().ok())
|
|
.unwrap_or_default();
|
|
|
|
if expected_token.is_empty() || auth_header != format!("Bearer {}", expected_token) {
|
|
return (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
|
}
|
|
|
|
std::thread::spawn(|| {
|
|
tracing::info!(
|
|
"Received shutdown request via /shutdown endpoint. Exiting process cleanly."
|
|
);
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
std::process::exit(0);
|
|
});
|
|
(axum::http::StatusCode::OK, "Shutting down...").into_response()
|
|
},
|
|
),
|
|
)
|
|
.route(
|
|
"/",
|
|
get(|| async move { axum::response::Html(include_str!("dashboard.html")) }),
|
|
)
|
|
.route(
|
|
"/api/graph",
|
|
get({
|
|
let state_clone = app_state.handler.state.clone();
|
|
move || async move {
|
|
let graph_json = state_clone.read_graph(|g| serde_json::to_string(g).unwrap_or_else(|_| "{}".to_string()));
|
|
([(axum::http::header::CONTENT_TYPE, "application/json")], graph_json)
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/tasks/{id}/complete",
|
|
post({
|
|
let state_clone = app_state.handler.state.clone();
|
|
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
|
|
state_clone.tasks.modify(|tasks| {
|
|
for t in tasks.iter_mut() {
|
|
if t.id == id {
|
|
t.status = "completed".to_string();
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
axum::Json(serde_json::json!({"status": "success"}))
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/tasks",
|
|
get({
|
|
let state_clone = app_state.handler.state.clone();
|
|
move || async move {
|
|
let tasks_json = state_clone.tasks.read_with(|t| serde_json::to_string(t).unwrap_or_else(|_| "[]".to_string()));
|
|
([(axum::http::header::CONTENT_TYPE, "application/json")], tasks_json)
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/sticky",
|
|
get({
|
|
let state_clone = app_state.handler.state.clone();
|
|
move || async move {
|
|
let sticky_json = state_clone.sticky.read_with(|s| serde_json::to_string(s).unwrap_or_else(|_| "[]".to_string()));
|
|
([(axum::http::header::CONTENT_TYPE, "application/json")], sticky_json)
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/search",
|
|
get({
|
|
let state_clone = app_state.handler.state.clone();
|
|
move |axum::extract::Query(params): axum::extract::Query<
|
|
std::collections::HashMap<String, String>,
|
|
>| async move {
|
|
if let Some(q) = params.get("q")
|
|
&& let Ok(idx) = state_clone.search_index.read()
|
|
&& let Ok(results) = idx.search(q, None) {
|
|
let mut formatted_results = Vec::new();
|
|
for (id, doc_type, title, body, score) in results {
|
|
formatted_results.push(serde_json::json!({
|
|
"id": id,
|
|
"type_name": doc_type,
|
|
"title": title,
|
|
"content": body,
|
|
"score": score
|
|
}));
|
|
}
|
|
return axum::Json(
|
|
serde_json::json!({ "results": formatted_results }),
|
|
);
|
|
}
|
|
axum::Json(serde_json::json!({ "results": [] }))
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/activity",
|
|
get({
|
|
let state_clone = app_state.handler.state.clone();
|
|
move || async move {
|
|
let activities_json = state_clone.recent_activities.read_with(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
|
|
([(axum::http::header::CONTENT_TYPE, "application/json")], activities_json)
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/stats",
|
|
get({
|
|
let state_clone = app_state.handler.state.clone();
|
|
move || async move {
|
|
let (entities, relations) = state_clone.read_graph(|g| (g.entities.len(), g.relations.len()));
|
|
let tasks = state_clone.tasks.read_with(|items| items.len());
|
|
let snippets = state_clone.snippets.read_with(|items| items.len());
|
|
let tech_debts = state_clone.tech_debts.read_with(|items| items.len());
|
|
let adrs = state_clone.adrs.read_with(|items| items.len());
|
|
|
|
let ledger = state_clone.ledger.read_with(|items| items.len());
|
|
let sticky = state_clone.sticky.read_with(|items| items.len());
|
|
let error_fixes = state_clone.error_fixes.read_with(|items| items.len());
|
|
let pinned_files = state_clone.pinned_files.read_with(|items| items.len());
|
|
let session_summaries = state_clone.session_summaries.read_with(|items| items.len());
|
|
let handoff_memos = state_clone.handoff_memos.read_with(|items| items.len());
|
|
let env_fingerprints = state_clone.env_fingerprints.read_with(|items| items.len());
|
|
let env_requirements = state_clone.env_requirements.read_with(|items| items.len());
|
|
let milestones = state_clone.milestones.read_with(|items| items.len());
|
|
let environments = state_clone.environments.read_with(|items| items.len());
|
|
let pr_checklists = state_clone.pr_checklists.read_with(|items| items.len());
|
|
let gates = state_clone.gates.read_with(|items| items.len());
|
|
let context_workspaces = state_clone.context_workspaces.read_with(|items| items.len());
|
|
|
|
axum::Json(serde_json::json!({
|
|
"entities": entities,
|
|
"relations": relations,
|
|
"tasks": tasks,
|
|
"snippets": snippets,
|
|
"tech_debts": tech_debts,
|
|
"adrs": adrs,
|
|
"ledger": ledger,
|
|
"sticky": sticky,
|
|
"error_fixes": error_fixes,
|
|
"pinned_files": pinned_files,
|
|
"session_summaries": session_summaries,
|
|
"handoff_memos": handoff_memos,
|
|
"env_fingerprints": env_fingerprints,
|
|
"env_requirements": env_requirements,
|
|
"milestones": milestones,
|
|
"environments": environments,
|
|
"pr_checklists": pr_checklists,
|
|
"gates": gates,
|
|
"context_workspaces": context_workspaces
|
|
}))
|
|
}
|
|
}),
|
|
)
|
|
.with_state(app_state);
|
|
|
|
tracing::info!("MCP Memory Server running on http://127.0.0.1:3000/sse");
|
|
let addr = std::env::var("MCP_PORT").unwrap_or_else(|_| "3000".to_string());
|
|
let addr: std::net::SocketAddr = format!("127.0.0.1:{}", addr)
|
|
.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()).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;
|
|
}
|