1use crate::attribution::{
14 CreatorInfo, CreatorSource, HTTP_DEFAULT_CLIENT, X_TRUSTY_CLIENT_CWD, X_TRUSTY_CLIENT_NAME,
15};
16use crate::hook_emit::HookEventPayload;
17use crate::{ActivityFilter, ActivitySource, AppState, DaemonEvent};
18use axum::{
19 body::Body,
20 extract::{Path as AxumPath, Query, State},
21 http::{header, HeaderMap, HeaderValue, Request, StatusCode},
22 response::{IntoResponse, Response},
23 routing::{delete, get, post},
24 Json, Router,
25};
26use rust_embed::RustEmbed;
27use serde::{Deserialize, Serialize};
28use serde_json::{json, Value};
29use trusty_common::memory_core::community::KnowledgeGap;
30use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
31use trusty_common::memory_core::retrieval::recall_with_default_embedder;
32use trusty_common::memory_core::store::kg::Triple;
33use uuid::Uuid;
34
35pub(crate) const HEALTH_PROBE_PALACE: &str = "__health_probe__";
50
51#[derive(RustEmbed)]
58#[folder = "$CARGO_MANIFEST_DIR/ui/dist/"]
63struct WebAssets;
64
65pub fn router() -> Router<AppState> {
71 let router = Router::new()
76 .route("/api/v1/status", get(status))
77 .route("/api/v1/config", get(config))
78 .route("/api/v1/palaces", get(list_palaces).post(create_palace))
79 .route(
80 "/api/v1/palaces/{id}",
81 get(get_palace_handler)
82 .delete(delete_palace_handler)
83 .patch(update_palace_handler),
84 )
85 .route(
86 "/api/v1/palaces/{id}/drawers",
87 get(list_drawers).post(create_drawer),
88 )
89 .route(
90 "/api/v1/palaces/{id}/drawers/{drawer_id}",
91 delete(delete_drawer),
92 )
93 .route(
99 "/api/v1/palaces/{id}/memories",
100 get(list_drawers).post(create_drawer),
101 )
102 .route(
103 "/api/v1/palaces/{id}/memories/{drawer_id}",
104 delete(delete_drawer),
105 )
106 .route("/api/v1/palaces/{id}/recall", get(recall_handler))
107 .route("/api/v1/recall", get(recall_all_handler))
108 .route("/api/v1/palaces/{id}/kg", get(kg_query).post(kg_assert))
109 .route("/api/v1/palaces/{id}/kg/subjects", get(kg_list_subjects))
110 .route(
111 "/api/v1/palaces/{id}/kg/subjects_with_counts",
112 get(kg_list_subjects_with_counts),
113 )
114 .route("/api/v1/palaces/{id}/kg/all", get(kg_list_all))
115 .route("/api/v1/palaces/{id}/kg/graph", get(kg_graph))
116 .route("/api/v1/palaces/{id}/kg/count", get(kg_count))
117 .route(
118 "/api/v1/palaces/{id}/kg/triples/{triple_id}",
119 delete(kg_delete_triple),
120 )
121 .route(
122 "/api/v1/palaces/{id}/dream/status",
123 get(palace_dream_status),
124 )
125 .route("/api/v1/dream/status", get(dream_status))
126 .route("/api/v1/dream/run", post(dream_run))
127 .route("/api/v1/kg/gaps", get(kg_gaps_handler))
128 .route("/api/v1/kg/prompt-context", get(prompt_context_handler))
129 .route("/api/v1/kg/aliases", post(add_alias_handler))
130 .route(
131 "/api/v1/kg/prompt-facts",
132 get(list_prompt_facts_handler).delete(remove_prompt_fact_handler),
133 )
134 .route("/api/v1/chat", post(crate::chat::chat_handler))
135 .route("/api/v1/chat/providers", get(crate::chat::list_providers))
136 .route(
137 "/api/v1/palaces/{id}/chat/sessions",
138 get(crate::chat::list_chat_sessions).post(crate::chat::create_chat_session),
139 )
140 .route(
141 "/api/v1/palaces/{id}/chat/sessions/{session_id}",
142 get(crate::chat::get_chat_session).delete(crate::chat::delete_chat_session),
143 )
144 .route(
146 "/api/v1/messages",
147 get(crate::chat::list_messages_handler).post(crate::chat::send_message_handler),
148 )
149 .route(
150 "/api/v1/messages/mark_read",
151 post(crate::chat::mark_message_read_handler),
152 )
153 .route("/health", get(health))
154 .route("/api/v1/logs/tail", get(logs_tail))
155 .route("/api/v1/activity", get(activity_handler))
156 .route("/api/v1/activity/hook", post(hook_activity_handler))
157 .route("/api/v1/admin/stop", post(admin_stop))
158 .route("/rpc", post(rpc_handler))
164 .fallback(static_handler);
165
166 trusty_common::server::with_standard_middleware(router)
167}
168
169#[derive(serde::Serialize)]
185struct HealthResponse {
186 status: String,
191 #[serde(skip_serializing_if = "Option::is_none")]
195 detail: Option<String>,
196 version: &'static str,
197 rss_mb: u64,
200 disk_bytes: u64,
204 cpu_pct: f32,
208 uptime_secs: u64,
210 #[serde(skip_serializing_if = "Option::is_none")]
216 addr: Option<String>,
217}
218
219async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
246 let (rss_mb, cpu_pct) = {
247 let mut metrics = state.sys_metrics.lock().await;
248 metrics.sample()
249 };
250 let disk_bytes = state.disk_bytes.load(std::sync::atomic::Ordering::Relaxed);
251 let uptime_secs = state.started_at.elapsed().as_secs();
252 let addr = state.bound_addr.get().map(|a| a.to_string());
253
254 let (status, detail) = match run_health_round_trip(&state).await {
255 Ok(()) => ("ok".to_string(), None),
256 Err(err) => {
257 tracing::warn!("/health round-trip degraded: {err}");
258 ("degraded".to_string(), Some(err.to_string()))
259 }
260 };
261
262 Json(HealthResponse {
263 status,
264 detail,
265 version: env!("CARGO_PKG_VERSION"),
266 rss_mb,
267 disk_bytes,
268 cpu_pct,
269 uptime_secs,
270 addr,
271 })
272}
273
274#[derive(Debug, thiserror::Error)]
286enum HealthProbeError {
287 #[error("open palace failed: {0}")]
288 OpenPalace(String),
289 #[error("provision health probe palace failed: {0}")]
290 EnsureProbePalace(String),
291 #[error("store failed: {0}")]
292 Store(String),
293 #[error("recall failed: {0}")]
294 Recall(String),
295 #[error("recall did not return the probe drawer (id={0})")]
296 ProbeMissing(Uuid),
297 #[error("delete probe drawer failed: {0}")]
298 Delete(String),
299}
300
301fn ensure_health_probe_palace(state: &AppState) -> Result<(), HealthProbeError> {
318 let id = PalaceId::new(HEALTH_PROBE_PALACE);
319
320 if state.registry.get(&id).is_some() {
322 return Ok(());
323 }
324
325 if state.registry.open_palace(&state.data_root, &id).is_ok() {
328 return Ok(());
329 }
330
331 let palace = Palace {
334 id: id.clone(),
335 name: HEALTH_PROBE_PALACE.to_string(),
336 description: Some(
337 "Internal health-probe palace (issue #185). Hidden from listings; \
338 holds short-lived round-trip drawers cleaned up on every probe."
339 .to_string(),
340 ),
341 created_at: chrono::Utc::now(),
342 data_dir: state.data_root.join(HEALTH_PROBE_PALACE),
343 };
344 state
345 .registry
346 .create_palace(&state.data_root, palace)
347 .map_err(|e| HealthProbeError::EnsureProbePalace(format!("{e:#}")))?;
348 Ok(())
349}
350
351async fn run_health_round_trip(state: &AppState) -> Result<(), HealthProbeError> {
372 ensure_health_probe_palace(state)?;
376 let probe_id = PalaceId::new(HEALTH_PROBE_PALACE);
377 let handle = state
378 .registry
379 .open_palace(&state.data_root, &probe_id)
380 .map_err(|e| HealthProbeError::OpenPalace(format!("{e:#}")))?;
381
382 run_health_round_trip_inner(handle, |handle, query| async move {
386 recall_with_default_embedder(&handle, &query, 5)
387 .await
388 .map_err(|e| HealthProbeError::Recall(format!("{e:#}")))
389 })
390 .await
391}
392
393async fn run_health_round_trip_inner<F, Fut>(
412 handle: std::sync::Arc<trusty_common::memory_core::PalaceHandle>,
413 recall: F,
414) -> Result<(), HealthProbeError>
415where
416 F: FnOnce(std::sync::Arc<trusty_common::memory_core::PalaceHandle>, String) -> Fut,
417 Fut: std::future::Future<
418 Output = Result<Vec<trusty_common::memory_core::retrieval::RecallResult>, HealthProbeError>,
419 >,
420{
421 let probe_token = Uuid::new_v4();
426 let probe_content = format!("__trusty_memory_healthcheck__ probe {probe_token}");
427
428 let drawer_id = handle
429 .remember(
430 probe_content.clone(),
431 RoomType::General,
432 vec!["healthcheck".to_string()],
433 0.0,
434 )
435 .await
436 .map_err(|e| HealthProbeError::Store(format!("{e:#}")))?;
437
438 let recall_result = recall(handle.clone(), probe_content).await;
439
440 let delete_result = handle.forget(drawer_id).await;
447
448 match recall_result {
449 Ok(hits) => {
450 if !hits.iter().any(|hit| hit.drawer.id == drawer_id) {
451 return Err(HealthProbeError::ProbeMissing(drawer_id));
452 }
453 }
454 Err(e) => return Err(e),
455 }
456
457 delete_result.map_err(|e| HealthProbeError::Delete(format!("{e:#}")))?;
458 Ok(())
459}
460
461const DEFAULT_LOGS_TAIL_N: usize = 100;
468
469const MAX_LOGS_TAIL_N: usize = trusty_common::log_buffer::DEFAULT_LOG_CAPACITY;
472
473fn default_logs_tail_n() -> usize {
474 DEFAULT_LOGS_TAIL_N
475}
476
477#[derive(serde::Deserialize)]
486struct LogsTailParams {
487 #[serde(default = "default_logs_tail_n")]
488 n: usize,
489}
490
491async fn logs_tail(
503 State(state): State<AppState>,
504 Query(params): Query<LogsTailParams>,
505) -> Json<Value> {
506 let n = params.n.clamp(1, MAX_LOGS_TAIL_N);
507 let lines = state.log_buffer.tail(n);
508 Json(serde_json::json!({
509 "lines": lines,
510 "total": state.log_buffer.len(),
511 }))
512}
513
514async fn admin_stop(State(_state): State<AppState>) -> Json<Value> {
525 tracing::warn!("admin_stop: shutdown requested via POST /api/v1/admin/stop");
526 tokio::spawn(async {
527 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
528 std::process::exit(0);
529 });
530 Json(serde_json::json!({ "ok": true, "message": "shutting down" }))
531}
532
533const ACTIVITY_DEFAULT_LIMIT: usize = 50;
540
541const ACTIVITY_MAX_LIMIT: usize = 500;
550
551#[derive(Deserialize, Debug, Default)]
560struct ActivityQuery {
561 #[serde(default)]
562 limit: Option<usize>,
563 #[serde(default)]
564 offset: Option<usize>,
565 #[serde(default)]
566 palace: Option<String>,
567 #[serde(default)]
568 source: Option<String>,
569 #[serde(default)]
570 since: Option<String>,
571 #[serde(default)]
572 until: Option<String>,
573}
574
575#[derive(Serialize, Debug)]
585struct ActivityRow {
586 id: u64,
587 timestamp: chrono::DateTime<chrono::Utc>,
588 source: &'static str,
589 #[serde(skip_serializing_if = "Option::is_none")]
590 palace_id: Option<String>,
591 event_type: String,
592 payload: Value,
593}
594
595async fn activity_handler(
609 State(state): State<AppState>,
610 Query(q): Query<ActivityQuery>,
611) -> Result<Json<Value>, ApiError> {
612 let limit = q
613 .limit
614 .unwrap_or(ACTIVITY_DEFAULT_LIMIT)
615 .clamp(1, ACTIVITY_MAX_LIMIT);
616 let offset = q.offset.unwrap_or(0);
617
618 let source = match q.source.as_deref() {
619 Some(s) => match ActivitySource::parse(s) {
620 Some(parsed) => Some(parsed),
621 None => {
622 return Err(ApiError::bad_request(format!(
623 "unknown source '{s}'; expected one of http, mcp, hook",
624 )))
625 }
626 },
627 None => None,
628 };
629
630 let since = parse_iso_or_bad_request(q.since.as_deref(), "since")?;
631 let until = parse_iso_or_bad_request(q.until.as_deref(), "until")?;
632
633 let filter = ActivityFilter {
634 palace_id: q.palace.filter(|s| !s.is_empty()),
635 source,
636 since,
637 until,
638 };
639
640 let entries = state
641 .activity_log
642 .list(&filter, limit, offset)
643 .map_err(|e| ApiError::internal(format!("activity list: {e:#}")))?;
644 let total = state
645 .activity_log
646 .count()
647 .map_err(|e| ApiError::internal(format!("activity count: {e:#}")))?;
648
649 let rows: Vec<ActivityRow> = entries
650 .into_iter()
651 .map(|e| {
652 let payload = serde_json::from_str::<Value>(&e.payload)
653 .unwrap_or_else(|_| Value::String(e.payload.clone()));
654 ActivityRow {
655 id: e.id,
656 timestamp: e.timestamp,
657 source: e.source.as_str(),
658 palace_id: e.palace_id,
659 event_type: e.event_type,
660 payload,
661 }
662 })
663 .collect();
664
665 Ok(Json(json!({
666 "entries": rows,
667 "total": total,
668 "limit": limit,
669 "offset": offset,
670 })))
671}
672
673async fn hook_activity_handler(
689 State(state): State<AppState>,
690 Json(payload): Json<HookEventPayload>,
691) -> Result<StatusCode, ApiError> {
692 state.emit(DaemonEvent::HookFired {
693 palace_id: payload.palace_id,
694 palace_name: payload.palace_name,
695 hook_type: payload.hook_type,
696 injection_kind: payload.injection_kind,
697 injection_length: payload.injection_length,
698 trigger_prompt_excerpt: payload.trigger_prompt_excerpt,
699 timestamp: chrono::Utc::now(),
700 duration_ms: payload.duration_ms,
701 source: ActivitySource::Hook,
702 });
703 Ok(StatusCode::NO_CONTENT)
704}
705
706async fn rpc_handler(
723 State(state): State<AppState>,
724 Json(req): Json<crate::transport::rpc::JsonRpcRequest>,
725) -> Json<crate::transport::rpc::JsonRpcResponse> {
726 let resp = crate::transport::rpc::dispatch(&state, req).await;
727 Json(resp)
728}
729
730pub(crate) fn creator_info_from_http(headers: &HeaderMap) -> CreatorInfo {
743 let client = headers
744 .get(X_TRUSTY_CLIENT_NAME)
745 .and_then(|v| v.to_str().ok())
746 .map(|s| s.trim())
747 .filter(|s| !s.is_empty())
748 .unwrap_or(HTTP_DEFAULT_CLIENT)
749 .to_string();
750 let cwd = headers
751 .get(X_TRUSTY_CLIENT_CWD)
752 .and_then(|v| v.to_str().ok())
753 .map(|s| s.to_string())
754 .filter(|s| !s.is_empty());
755 CreatorInfo {
756 client,
757 version: env!("CARGO_PKG_VERSION").to_string(),
758 source: CreatorSource::Http,
759 cwd,
760 }
761}
762
763fn parse_iso_or_bad_request(
774 s: Option<&str>,
775 field: &str,
776) -> Result<Option<chrono::DateTime<chrono::Utc>>, ApiError> {
777 match s {
778 None | Some("") => Ok(None),
779 Some(raw) => chrono::DateTime::parse_from_rfc3339(raw)
780 .map(|dt| Some(dt.with_timezone(&chrono::Utc)))
781 .map_err(|e| ApiError::bad_request(format!("invalid {field} (RFC 3339): {e}"))),
782 }
783}
784
785async fn static_handler(req: Request<Body>) -> Response {
797 let path = req.uri().path().trim_start_matches('/').to_string();
798
799 if path.starts_with("api/") {
800 return (StatusCode::NOT_FOUND, "not found").into_response();
801 }
802
803 serve_embedded(&path).unwrap_or_else(|| {
804 serve_embedded("index.html")
806 .unwrap_or_else(|| (StatusCode::NOT_FOUND, "ui assets missing").into_response())
807 })
808}
809
810fn serve_embedded(path: &str) -> Option<Response> {
811 let path = if path.is_empty() { "index.html" } else { path };
812 let asset = WebAssets::get(path)?;
813 let mime = mime_guess::from_path(path).first_or_octet_stream();
814 let body = Body::from(asset.data.into_owned());
815 let mut resp = Response::new(body);
816 resp.headers_mut().insert(
817 header::CONTENT_TYPE,
818 HeaderValue::from_str(mime.as_ref())
819 .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
820 );
821 Some(resp)
822}
823
824pub(crate) use crate::service::StatusPayload;
829
830async fn status(State(state): State<AppState>) -> Json<StatusPayload> {
831 Json(crate::service::MemoryService::new(state).status().await)
832}
833
834#[derive(Serialize)]
835struct ConfigPayload {
836 openrouter_configured: bool,
837 model: String,
838 data_root: String,
839}
840
841async fn config(State(state): State<AppState>) -> Json<ConfigPayload> {
842 let cfg = load_user_config().unwrap_or_default();
843 Json(ConfigPayload {
844 openrouter_configured: !cfg.openrouter_api_key.is_empty(),
845 model: cfg.openrouter_model,
846 data_root: state.data_root.display().to_string(),
847 })
848}
849
850pub(crate) use crate::service::load_user_config;
851#[allow(unused_imports)]
852pub(crate) use crate::service::LoadedUserConfig;
853
854pub(crate) use crate::service::{palace_info_from, CreatePalaceBody, PalaceInfo};
859
860async fn list_palaces(State(state): State<AppState>) -> Result<Json<Vec<PalaceInfo>>, ApiError> {
861 Ok(Json(
862 crate::service::MemoryService::new(state)
863 .list_palaces()
864 .await?,
865 ))
866}
867
868async fn create_palace(
869 State(state): State<AppState>,
870 Json(body): Json<CreatePalaceBody>,
871) -> Result<Json<Value>, ApiError> {
872 let id = crate::service::MemoryService::new(state)
873 .create_palace(body, ActivitySource::Http)
874 .await?;
875 Ok(Json(json!({ "id": id })))
876}
877
878async fn get_palace_handler(
879 State(state): State<AppState>,
880 AxumPath(id): AxumPath<String>,
881) -> Result<Json<PalaceInfo>, ApiError> {
882 Ok(Json(
883 crate::service::MemoryService::new(state)
884 .get_palace(&id)
885 .await?,
886 ))
887}
888
889#[derive(Deserialize, Default)]
898struct DeletePalaceQuery {
899 #[serde(default)]
900 force: Option<bool>,
901}
902
903async fn delete_palace_handler(
917 State(state): State<AppState>,
918 AxumPath(id): AxumPath<String>,
919 Query(q): Query<DeletePalaceQuery>,
920) -> Result<StatusCode, ApiError> {
921 crate::service::MemoryService::new(state)
922 .delete_palace(&id, q.force.unwrap_or(false))
923 .await?;
924 Ok(StatusCode::NO_CONTENT)
925}
926
927#[derive(Deserialize)]
938struct UpdatePalaceBody {
939 name: String,
940}
941
942async fn update_palace_handler(
956 State(state): State<AppState>,
957 AxumPath(id): AxumPath<String>,
958 Json(body): Json<UpdatePalaceBody>,
959) -> Result<Json<Value>, ApiError> {
960 let value = crate::service::MemoryService::new(state)
961 .update_palace_name_typed(&id, &body.name)
962 .await?;
963 Ok(Json(value))
964}
965
966pub(crate) use crate::service::{CreateDrawerBody, ListDrawersQuery};
971
972async fn list_drawers(
973 State(state): State<AppState>,
974 AxumPath(id): AxumPath<String>,
975 Query(q): Query<ListDrawersQuery>,
976) -> Result<Json<Value>, ApiError> {
977 Ok(Json(
978 crate::service::MemoryService::new(state)
979 .list_drawers(&id, q)
980 .await?,
981 ))
982}
983
984async fn create_drawer(
985 State(state): State<AppState>,
986 AxumPath(id): AxumPath<String>,
987 headers: HeaderMap,
988 Json(body): Json<CreateDrawerBody>,
989) -> Result<Json<Value>, ApiError> {
990 let creator = creator_info_from_http(&headers);
991 let drawer_id = crate::service::MemoryService::new(state)
992 .create_drawer(&id, body, creator, ActivitySource::Http)
993 .await?;
994 Ok(Json(json!({ "id": drawer_id })))
995}
996
997async fn delete_drawer(
998 State(state): State<AppState>,
999 AxumPath((id, drawer_id)): AxumPath<(String, String)>,
1000) -> Result<StatusCode, ApiError> {
1001 crate::service::MemoryService::new(state)
1002 .delete_drawer(&id, &drawer_id, ActivitySource::Http)
1003 .await?;
1004 Ok(StatusCode::NO_CONTENT)
1005}
1006
1007#[derive(Deserialize)]
1018struct RecallQuery {
1019 q: String,
1020 #[serde(default)]
1021 top_k: Option<usize>,
1022 #[serde(default)]
1023 deep: Option<bool>,
1024}
1025
1026async fn recall_handler(
1027 State(state): State<AppState>,
1028 AxumPath(id): AxumPath<String>,
1029 Query(q): Query<RecallQuery>,
1030) -> Result<Json<Value>, ApiError> {
1031 Ok(Json(
1032 crate::service::MemoryService::new(state)
1033 .recall(&id, &q.q, q.top_k.unwrap_or(10), q.deep.unwrap_or(false))
1034 .await?,
1035 ))
1036}
1037
1038#[allow(unused_imports)]
1039pub(crate) use crate::service::recall_entry_json;
1040
1041async fn recall_all_handler(
1055 State(state): State<AppState>,
1056 Query(q): Query<RecallQuery>,
1057) -> Result<Json<Value>, ApiError> {
1058 let value = crate::service::MemoryService::new(state)
1059 .recall_all(&q.q, q.top_k.unwrap_or(10), q.deep.unwrap_or(false))
1060 .await;
1061 if let Some(err) = value.get("error").and_then(|v| v.as_str()) {
1062 return Err(ApiError::internal(err.to_string()));
1063 }
1064 Ok(Json(value))
1065}
1066
1067#[derive(Deserialize)]
1072struct KgQueryParams {
1073 subject: String,
1074}
1075
1076async fn kg_query(
1077 State(state): State<AppState>,
1078 AxumPath(id): AxumPath<String>,
1079 Query(q): Query<KgQueryParams>,
1080) -> Result<Json<Vec<Triple>>, ApiError> {
1081 Ok(Json(
1082 crate::service::MemoryService::new(state)
1083 .kg_query(&id, &q.subject)
1084 .await?,
1085 ))
1086}
1087
1088pub(crate) use crate::service::KgAssertBody;
1089
1090async fn kg_assert(
1091 State(state): State<AppState>,
1092 AxumPath(id): AxumPath<String>,
1093 Json(body): Json<KgAssertBody>,
1094) -> Result<StatusCode, ApiError> {
1095 crate::service::MemoryService::new(state)
1096 .kg_assert(&id, body)
1097 .await?;
1098 Ok(StatusCode::NO_CONTENT)
1099}
1100
1101const DEFAULT_KG_LIST_LIMIT: usize = 50;
1106
1107const MAX_KG_LIST_LIMIT: usize = 200;
1112
1113fn default_kg_list_limit() -> usize {
1114 DEFAULT_KG_LIST_LIMIT
1115}
1116
1117#[derive(Deserialize)]
1126struct KgListSubjectsParams {
1127 #[serde(default = "default_kg_list_limit")]
1128 limit: usize,
1129}
1130
1131async fn kg_list_subjects(
1141 State(state): State<AppState>,
1142 AxumPath(id): AxumPath<String>,
1143 Query(q): Query<KgListSubjectsParams>,
1144) -> Result<Json<Vec<String>>, ApiError> {
1145 let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1146 Ok(Json(
1147 crate::service::MemoryService::new(state)
1148 .kg_list_subjects(&id, limit)
1149 .await?,
1150 ))
1151}
1152
1153async fn kg_list_subjects_with_counts(
1165 State(state): State<AppState>,
1166 AxumPath(id): AxumPath<String>,
1167 Query(q): Query<KgListSubjectsParams>,
1168) -> Result<Json<Vec<Value>>, ApiError> {
1169 let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1170 let rows = crate::service::MemoryService::new(state)
1171 .kg_list_subjects_with_counts(&id, limit)
1172 .await?;
1173 let out: Vec<Value> = rows
1174 .into_iter()
1175 .map(|(subject, count)| json!({ "subject": subject, "count": count }))
1176 .collect();
1177 Ok(Json(out))
1178}
1179
1180#[derive(Deserialize)]
1187struct KgListAllParams {
1188 #[serde(default = "default_kg_list_limit")]
1189 limit: usize,
1190 #[serde(default)]
1191 offset: usize,
1192}
1193
1194async fn kg_list_all(
1203 State(state): State<AppState>,
1204 AxumPath(id): AxumPath<String>,
1205 Query(q): Query<KgListAllParams>,
1206) -> Result<Json<Vec<Triple>>, ApiError> {
1207 let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1208 Ok(Json(
1209 crate::service::MemoryService::new(state)
1210 .kg_list_all(&id, limit, q.offset)
1211 .await?,
1212 ))
1213}
1214
1215async fn kg_count(
1223 State(state): State<AppState>,
1224 AxumPath(id): AxumPath<String>,
1225) -> Result<Json<Value>, ApiError> {
1226 let active = crate::service::MemoryService::new(state)
1227 .kg_count(&id)
1228 .await?;
1229 Ok(Json(json!({ "active": active })))
1230}
1231
1232const TRIPLE_ID_SEPARATOR: u8 = 0x00;
1242
1243#[allow(dead_code)]
1253pub(crate) fn encode_triple_id(subject: &str, predicate: &str) -> String {
1254 use base64::Engine as _;
1255 let mut buf = Vec::with_capacity(subject.len() + 1 + predicate.len());
1256 buf.extend_from_slice(subject.as_bytes());
1257 buf.push(TRIPLE_ID_SEPARATOR);
1258 buf.extend_from_slice(predicate.as_bytes());
1259 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf)
1260}
1261
1262pub(crate) fn decode_triple_id(id: &str) -> Option<(String, String)> {
1271 use base64::Engine as _;
1272 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
1273 .decode(id)
1274 .ok()?;
1275 let sep_pos = bytes.iter().position(|&b| b == TRIPLE_ID_SEPARATOR)?;
1276 let subject = String::from_utf8(bytes[..sep_pos].to_vec()).ok()?;
1277 let predicate = String::from_utf8(bytes[sep_pos + 1..].to_vec()).ok()?;
1278 Some((subject, predicate))
1279}
1280
1281async fn kg_delete_triple(
1299 State(state): State<AppState>,
1300 AxumPath((id, triple_id)): AxumPath<(String, String)>,
1301) -> Result<StatusCode, ApiError> {
1302 let (subject, predicate) = decode_triple_id(&triple_id).ok_or_else(|| {
1303 ApiError::not_found("invalid triple id — expected base64url(subject\\0predicate)")
1304 })?;
1305 let found = crate::service::MemoryService::new(state)
1306 .kg_retract_triple(&id, &subject, &predicate)
1307 .await?;
1308 if found {
1309 Ok(StatusCode::NO_CONTENT)
1310 } else {
1311 Err(ApiError::not_found(format!(
1312 "no active triple with subject={subject:?} predicate={predicate:?} in palace {id:?}"
1313 )))
1314 }
1315}
1316
1317pub(crate) use crate::service::KgGraphPayload;
1318
1319async fn kg_graph(
1320 State(state): State<AppState>,
1321 AxumPath(id): AxumPath<String>,
1322) -> Result<Json<KgGraphPayload>, ApiError> {
1323 Ok(Json(
1324 crate::service::MemoryService::new(state)
1325 .kg_graph(&id)
1326 .await?,
1327 ))
1328}
1329
1330pub(crate) use crate::service::DreamStatusPayload;
1335
1336async fn dream_status(State(state): State<AppState>) -> Json<DreamStatusPayload> {
1337 Json(
1338 crate::service::MemoryService::new(state)
1339 .dream_status_aggregate()
1340 .await,
1341 )
1342}
1343
1344async fn palace_dream_status(
1345 State(state): State<AppState>,
1346 AxumPath(id): AxumPath<String>,
1347) -> Result<Json<DreamStatusPayload>, ApiError> {
1348 Ok(Json(
1349 crate::service::MemoryService::new(state)
1350 .dream_status_for_palace(&id)
1351 .await?,
1352 ))
1353}
1354
1355async fn dream_run(State(state): State<AppState>) -> Result<Json<DreamStatusPayload>, ApiError> {
1356 Ok(Json(
1357 crate::service::MemoryService::new(state)
1358 .dream_run()
1359 .await?,
1360 ))
1361}
1362
1363#[derive(Serialize, Debug, Clone)]
1377pub struct KnowledgeGapResponse {
1378 pub entities: Vec<String>,
1379 pub internal_density: f32,
1380 pub external_bridges: usize,
1381 pub suggested_exploration: String,
1382}
1383
1384impl From<KnowledgeGap> for KnowledgeGapResponse {
1385 fn from(g: KnowledgeGap) -> Self {
1386 Self {
1387 entities: g.entities,
1388 internal_density: g.internal_density,
1389 external_bridges: g.external_bridges,
1390 suggested_exploration: g.suggested_exploration,
1391 }
1392 }
1393}
1394
1395#[derive(Deserialize)]
1396struct KgGapsQuery {
1397 #[serde(default)]
1398 palace: Option<String>,
1399}
1400
1401async fn kg_gaps_handler(
1416 State(state): State<AppState>,
1417 Query(q): Query<KgGapsQuery>,
1418) -> Result<Json<Vec<KnowledgeGapResponse>>, ApiError> {
1419 let palace_name = q
1420 .palace
1421 .clone()
1422 .or_else(|| state.default_palace.clone())
1423 .ok_or_else(|| {
1424 ApiError::bad_request("missing 'palace' query parameter (no default palace configured)")
1425 })?;
1426
1427 let _handle = open_handle(&state, &palace_name)?;
1431
1432 let pid = PalaceId::new(&palace_name);
1433 let gaps = state.registry.get_gaps(&pid).unwrap_or_default();
1434 let body: Vec<KnowledgeGapResponse> =
1435 gaps.into_iter().map(KnowledgeGapResponse::from).collect();
1436 Ok(Json(body))
1437}
1438
1439#[derive(Deserialize)]
1453struct PromptFactsQuery {
1454 #[serde(default)]
1459 #[allow(dead_code)]
1460 palace: Option<String>,
1461}
1462
1463#[derive(Deserialize)]
1472struct AddAliasRequest {
1473 short: String,
1474 full: String,
1475 #[serde(default)]
1476 palace: Option<String>,
1477}
1478
1479#[derive(Serialize)]
1487struct PromptFactRow {
1488 subject: String,
1489 predicate: String,
1490 object: String,
1491}
1492
1493#[derive(Deserialize)]
1504struct RemovePromptFactQuery {
1505 subject: String,
1506 predicate: String,
1507 #[serde(default)]
1508 #[allow(dead_code)]
1509 object: Option<String>,
1510 #[serde(default)]
1511 #[allow(dead_code)]
1512 palace: Option<String>,
1513}
1514
1515async fn prompt_context_handler(
1526 State(state): State<AppState>,
1527 Query(_q): Query<PromptFactsQuery>,
1528) -> Result<Response, ApiError> {
1529 let cache_snapshot = {
1530 let guard = state.prompt_context_cache.read().await;
1531 guard.clone()
1532 };
1533 let body = if cache_snapshot.formatted.is_empty() {
1534 "No prompt facts stored yet.".to_string()
1535 } else {
1536 cache_snapshot.formatted
1537 };
1538 let mut resp = body.into_response();
1539 resp.headers_mut().insert(
1540 header::CONTENT_TYPE,
1541 HeaderValue::from_static("text/plain; charset=utf-8"),
1542 );
1543 Ok(resp)
1544}
1545
1546async fn add_alias_handler(
1556 State(state): State<AppState>,
1557 Json(req): Json<AddAliasRequest>,
1558) -> Result<Json<Value>, ApiError> {
1559 if req.short.is_empty() || req.full.is_empty() {
1560 return Err(ApiError::bad_request("short and full are required"));
1561 }
1562 let palace_name = req
1563 .palace
1564 .clone()
1565 .or_else(|| state.default_palace.clone())
1566 .ok_or_else(|| ApiError::bad_request("missing 'palace' (no default palace configured)"))?;
1567 let handle = open_handle(&state, &palace_name)?;
1568 let triple = Triple {
1569 subject: req.short.clone(),
1570 predicate: "is_alias_for".to_string(),
1571 object: req.full.clone(),
1572 valid_from: chrono::Utc::now(),
1573 valid_to: None,
1574 confidence: 1.0,
1575 provenance: Some("add_alias_http".to_string()),
1576 };
1577 handle
1578 .kg
1579 .assert(triple)
1580 .await
1581 .map_err(|e| ApiError::internal(format!("kg.assert failed: {e:#}")))?;
1582 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
1583 tracing::warn!("rebuild_prompt_cache after HTTP add_alias failed: {e:#}");
1584 }
1585 Ok(Json(json!({
1586 "subject": req.short,
1587 "predicate": "is_alias_for",
1588 "object": req.full,
1589 "palace": palace_name,
1590 })))
1591}
1592
1593async fn list_prompt_facts_handler(
1602 State(state): State<AppState>,
1603 Query(_q): Query<PromptFactsQuery>,
1604) -> Result<Json<Vec<PromptFactRow>>, ApiError> {
1605 let triples = crate::prompt_facts::gather_hot_triples(&state)
1606 .await
1607 .map_err(|e| ApiError::internal(format!("gather_hot_triples: {e:#}")))?;
1608 let rows: Vec<PromptFactRow> = triples
1609 .into_iter()
1610 .map(|(subject, predicate, object)| PromptFactRow {
1611 subject,
1612 predicate,
1613 object,
1614 })
1615 .collect();
1616 Ok(Json(rows))
1617}
1618
1619async fn remove_prompt_fact_handler(
1630 State(state): State<AppState>,
1631 Query(q): Query<RemovePromptFactQuery>,
1632) -> Result<Json<Value>, ApiError> {
1633 if q.subject.is_empty() || q.predicate.is_empty() {
1634 return Err(ApiError::bad_request("subject and predicate are required"));
1635 }
1636 let mut closed_total: usize = 0;
1637 for palace_id in state.registry.list() {
1638 if let Some(handle) = state.registry.get(&palace_id) {
1639 match handle.kg.retract(&q.subject, &q.predicate).await {
1640 Ok(n) => closed_total += n,
1641 Err(e) => tracing::warn!(
1642 palace = %palace_id.as_str(),
1643 "HTTP retract failed: {e:#}",
1644 ),
1645 }
1646 }
1647 }
1648 if closed_total > 0 {
1649 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
1650 tracing::warn!("rebuild_prompt_cache after HTTP remove_prompt_fact failed: {e:#}");
1651 }
1652 Ok(Json(json!({"removed": true, "closed": closed_total})))
1653 } else {
1654 Ok(Json(json!({"removed": false, "reason": "not found"})))
1655 }
1656}
1657
1658#[allow(unused_imports)]
1659pub(crate) use crate::service::refresh_gaps_cache;
1660
1661pub(crate) fn open_handle(
1666 state: &AppState,
1667 id: &str,
1668) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>, ApiError> {
1669 state
1670 .registry
1671 .open_palace(&state.data_root, &PalaceId::new(id))
1672 .map_err(|e| ApiError::not_found(format!("palace not found: {id} ({e:#})")))
1673}
1674
1675pub(crate) struct ApiError {
1677 status: StatusCode,
1678 message: String,
1679}
1680
1681impl ApiError {
1682 pub(crate) fn bad_request(msg: impl Into<String>) -> Self {
1683 Self {
1684 status: StatusCode::BAD_REQUEST,
1685 message: msg.into(),
1686 }
1687 }
1688 pub(crate) fn not_found(msg: impl Into<String>) -> Self {
1689 Self {
1690 status: StatusCode::NOT_FOUND,
1691 message: msg.into(),
1692 }
1693 }
1694 #[allow(dead_code)]
1703 pub(crate) fn conflict(msg: impl Into<String>) -> Self {
1704 Self {
1705 status: StatusCode::CONFLICT,
1706 message: msg.into(),
1707 }
1708 }
1709 pub(crate) fn internal(msg: impl Into<String>) -> Self {
1710 Self {
1711 status: StatusCode::INTERNAL_SERVER_ERROR,
1712 message: msg.into(),
1713 }
1714 }
1715}
1716
1717impl IntoResponse for ApiError {
1718 fn into_response(self) -> Response {
1719 (self.status, Json(json!({ "error": self.message }))).into_response()
1720 }
1721}
1722
1723impl From<crate::service::ServiceError> for ApiError {
1724 fn from(e: crate::service::ServiceError) -> Self {
1725 match e {
1726 crate::service::ServiceError::BadRequest(m) => ApiError::bad_request(m),
1727 crate::service::ServiceError::NotFound(m) => ApiError::not_found(m),
1728 crate::service::ServiceError::Conflict(m) => ApiError::conflict(m),
1729 crate::service::ServiceError::Internal(m) => ApiError::internal(m),
1730 }
1731 }
1732}
1733
1734#[cfg(test)]
1735mod tests {
1736 use super::*;
1737 use crate::service::drawer_content_preview;
1741 use crate::service::DRAWER_PREVIEW_MAX_CHARS;
1742 use axum::body::to_bytes;
1743 use axum::http::Request;
1744 use tower::util::ServiceExt;
1745 use trusty_common::memory_core::palace::Palace;
1746 use trusty_common::memory_core::retrieval::RecallResult;
1747
1748 fn test_state() -> AppState {
1749 let tmp = tempfile::tempdir().expect("tempdir");
1750 let root = tmp.path().to_path_buf();
1751 std::mem::forget(tmp);
1752 AppState::new(root)
1753 }
1754
1755 #[test]
1756 fn drawer_preview_collapses_whitespace_and_truncates() {
1757 assert_eq!(drawer_content_preview("hello world"), "hello world");
1759
1760 assert_eq!(
1762 drawer_content_preview("first line\n\nsecond\tline third"),
1763 "first line second line third"
1764 );
1765
1766 assert_eq!(drawer_content_preview(" padded "), "padded");
1768
1769 assert_eq!(drawer_content_preview(""), "");
1771
1772 let long = "x".repeat(DRAWER_PREVIEW_MAX_CHARS + 50);
1774 let preview = drawer_content_preview(&long);
1775 assert_eq!(preview.chars().count(), DRAWER_PREVIEW_MAX_CHARS);
1776 assert!(preview.ends_with('…'));
1777
1778 let exact = "y".repeat(DRAWER_PREVIEW_MAX_CHARS);
1780 assert_eq!(drawer_content_preview(&exact), exact);
1781 }
1782
1783 #[tokio::test]
1794 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1795 async fn health_endpoint_returns_ok() {
1796 let state = test_state();
1797 let app = router().with_state(state);
1798 let resp = app
1799 .oneshot(
1800 Request::builder()
1801 .uri("/health")
1802 .body(Body::empty())
1803 .unwrap(),
1804 )
1805 .await
1806 .unwrap();
1807 assert_eq!(resp.status(), StatusCode::OK);
1808 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1809 let v: Value = serde_json::from_slice(&bytes).unwrap();
1810 assert_eq!(v["status"], "ok");
1811 assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
1812 }
1813
1814 #[tokio::test]
1826 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1827 async fn health_endpoint_includes_resource_fields() {
1828 let state = test_state();
1829 let app = router().with_state(state);
1830 let resp = app
1831 .oneshot(
1832 Request::builder()
1833 .uri("/health")
1834 .body(Body::empty())
1835 .unwrap(),
1836 )
1837 .await
1838 .unwrap();
1839 assert_eq!(resp.status(), StatusCode::OK);
1840 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1841 let v: Value = serde_json::from_slice(&bytes).unwrap();
1842 let rss_mb = v["rss_mb"].as_u64().expect("rss_mb is u64");
1844 assert!(rss_mb < 1024 * 1024, "rss_mb unit must be MB");
1845 let cpu = v["cpu_pct"].as_f64().expect("cpu_pct is a number");
1847 assert!(cpu >= 0.0, "cpu_pct must be non-negative");
1848 assert_eq!(v["disk_bytes"].as_u64(), Some(0));
1850 assert!(v["uptime_secs"].is_u64(), "uptime_secs must be present");
1852 }
1853
1854 #[tokio::test]
1870 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1871 async fn health_endpoint_round_trip_on_fresh_install_is_ok() {
1872 let state = test_state();
1873 let app = router().with_state(state);
1874 let resp = app
1875 .oneshot(
1876 Request::builder()
1877 .uri("/health")
1878 .body(Body::empty())
1879 .unwrap(),
1880 )
1881 .await
1882 .unwrap();
1883 assert_eq!(resp.status(), StatusCode::OK);
1884 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1885 let v: Value = serde_json::from_slice(&bytes).unwrap();
1886 assert_eq!(v["status"], "ok");
1887 assert!(
1888 v.get("detail").is_none() || v["detail"].is_null(),
1889 "fresh-install health must not carry a degraded detail (got {v:?})"
1890 );
1891 }
1892
1893 #[tokio::test]
1909 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1910 async fn health_endpoint_round_trip_with_palace_is_ok() {
1911 let state = test_state();
1912 let palace = trusty_common::memory_core::Palace {
1913 id: PalaceId::new("health-probe-palace"),
1914 name: "health-probe-palace".to_string(),
1915 description: None,
1916 created_at: chrono::Utc::now(),
1917 data_dir: state.data_root.join("health-probe-palace"),
1918 };
1919 state
1920 .registry
1921 .create_palace(&state.data_root, palace)
1922 .expect("create_palace");
1923
1924 let app = router().with_state(state);
1925 let resp = app
1926 .oneshot(
1927 Request::builder()
1928 .uri("/health")
1929 .body(Body::empty())
1930 .unwrap(),
1931 )
1932 .await
1933 .unwrap();
1934 assert_eq!(resp.status(), StatusCode::OK);
1935 let bytes = to_bytes(resp.into_body(), 2048).await.unwrap();
1936 let v: Value = serde_json::from_slice(&bytes).unwrap();
1937 assert_eq!(
1938 v["status"], "ok",
1939 "round-trip should succeed against a fresh palace; got {v:?}"
1940 );
1941 assert!(
1942 v.get("detail").is_none() || v["detail"].is_null(),
1943 "successful round-trip must not carry a detail field (got {v:?})"
1944 );
1945 }
1946
1947 #[tokio::test]
1960 async fn health_probe_palace_is_invisible() {
1961 let state = test_state();
1962 ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
1963
1964 assert!(
1966 state.data_root.join(HEALTH_PROBE_PALACE).exists(),
1967 "probe palace directory should be persisted on disk"
1968 );
1969
1970 let service = crate::service::MemoryService::new(state);
1971 let listed = service.list_palaces().await.expect("list_palaces");
1972 assert!(
1973 listed.iter().all(|p| !p.id.starts_with("__")),
1974 "no `__`-prefixed palace may appear in the user-facing list; got {:?}",
1975 listed.iter().map(|p| &p.id).collect::<Vec<_>>()
1976 );
1977 assert!(
1978 !listed.iter().any(|p| p.id == HEALTH_PROBE_PALACE),
1979 "the dedicated `__health_probe__` palace must be invisible; got {:?}",
1980 listed.iter().map(|p| &p.id).collect::<Vec<_>>()
1981 );
1982 }
1983
1984 #[tokio::test]
1999 async fn health_probe_cleans_up_on_success() {
2000 use trusty_common::memory_core::Drawer;
2001
2002 let state = test_state();
2003 ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2004 let handle = state
2005 .registry
2006 .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2007 .expect("open probe palace");
2008
2009 let result = run_health_round_trip_inner(handle.clone(), move |h, _query| async move {
2010 let drawers = h.drawers.read();
2013 let last = drawers
2014 .last()
2015 .cloned()
2016 .unwrap_or_else(|| Drawer::new(Uuid::new_v4(), "stub"));
2017 drop(drawers);
2018 Ok(vec![RecallResult {
2019 drawer: last,
2020 score: 1.0,
2021 layer: 1,
2022 }])
2023 })
2024 .await;
2025 assert!(
2026 result.is_ok(),
2027 "successful round-trip should return Ok; got {result:?}"
2028 );
2029
2030 let drawer_count = handle.drawers.read().len();
2031 assert_eq!(
2032 drawer_count, 0,
2033 "probe palace must have zero drawers after a successful round-trip (got {drawer_count})"
2034 );
2035 }
2036
2037 #[tokio::test]
2051 async fn health_probe_cleans_up_on_recall_miss() {
2052 let state = test_state();
2053 ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2054 let handle = state
2055 .registry
2056 .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2057 .expect("open probe palace");
2058
2059 let result = run_health_round_trip_inner(handle.clone(), |_h, _q| async move {
2060 Ok(Vec::new())
2062 })
2063 .await;
2064 assert!(
2065 matches!(result, Err(HealthProbeError::ProbeMissing(_))),
2066 "recall miss must surface as ProbeMissing; got {result:?}"
2067 );
2068
2069 let drawer_count = handle.drawers.read().len();
2070 assert_eq!(
2071 drawer_count, 0,
2072 "probe palace must be empty after a recall miss (got {drawer_count})"
2073 );
2074 }
2075
2076 #[tokio::test]
2089 async fn health_probe_cleans_up_on_recall_error() {
2090 let state = test_state();
2091 ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2092 let handle = state
2093 .registry
2094 .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2095 .expect("open probe palace");
2096
2097 let result = run_health_round_trip_inner(handle.clone(), |_h, _q| async move {
2098 Err(HealthProbeError::Recall("simulated failure".to_string()))
2099 })
2100 .await;
2101 assert!(
2102 matches!(result, Err(HealthProbeError::Recall(_))),
2103 "recall error must surface as Recall; got {result:?}"
2104 );
2105
2106 let drawer_count = handle.drawers.read().len();
2107 assert_eq!(
2108 drawer_count, 0,
2109 "probe palace must be empty after a recall error (got {drawer_count})"
2110 );
2111 }
2112
2113 #[test]
2126 fn recall_entry_json_hoists_drawer_fields() {
2127 use trusty_common::memory_core::Drawer;
2128
2129 let room = Uuid::new_v4();
2130 let mut drawer = Drawer::new(room, "the answer is 42");
2131 drawer.tags = vec!["source:kuzu".to_string()];
2132 drawer.importance = 0.7;
2133
2134 let entry = recall_entry_json(RecallResult {
2135 drawer,
2136 score: 0.699,
2137 layer: 1,
2138 });
2139
2140 assert_eq!(
2142 entry.get("content").and_then(|v| v.as_str()),
2143 Some("the answer is 42"),
2144 "content must be at the top level, got {entry:?}"
2145 );
2146 assert!(
2147 entry.get("drawer").is_none(),
2148 "the legacy `drawer` wrapper must not be present, got {entry:?}"
2149 );
2150 assert_eq!(
2152 entry["importance"].as_f64().map(|f| (f * 10.0).round()),
2153 Some(7.0)
2154 );
2155 assert_eq!(
2156 entry["tags"][0].as_str(),
2157 Some("source:kuzu"),
2158 "tags must be hoisted, got {entry:?}"
2159 );
2160 assert_eq!(entry["layer"].as_u64(), Some(1));
2162 assert!(
2163 entry["score"]
2164 .as_f64()
2165 .is_some_and(|s| (s - 0.699).abs() < 1e-6),
2166 "score must be preserved, got {entry:?}"
2167 );
2168 }
2169
2170 #[tokio::test]
2179 async fn logs_tail_returns_recent_lines() {
2180 let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2181 buffer.push("line one".to_string());
2182 buffer.push("line two".to_string());
2183 buffer.push("line three".to_string());
2184 let state = test_state().with_log_buffer(buffer);
2185 let app = router().with_state(state);
2186 let resp = app
2187 .oneshot(
2188 Request::builder()
2189 .uri("/api/v1/logs/tail?n=2")
2190 .body(Body::empty())
2191 .unwrap(),
2192 )
2193 .await
2194 .unwrap();
2195 assert_eq!(resp.status(), StatusCode::OK);
2196 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2197 let v: Value = serde_json::from_slice(&bytes).unwrap();
2198 let lines = v["lines"].as_array().expect("lines array");
2199 assert_eq!(lines.len(), 2, "n=2 must return two lines");
2200 assert_eq!(lines[0].as_str(), Some("line two"));
2201 assert_eq!(lines[1].as_str(), Some("line three"));
2202 assert_eq!(v["total"].as_u64(), Some(3));
2203 }
2204
2205 #[tokio::test]
2214 async fn logs_tail_clamps_n() {
2215 let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2216 for i in 0..5 {
2217 buffer.push(format!("l{i}"));
2218 }
2219 let state = test_state().with_log_buffer(buffer);
2220 let app = router().with_state(state);
2221
2222 let resp = app
2224 .clone()
2225 .oneshot(
2226 Request::builder()
2227 .uri("/api/v1/logs/tail?n=0")
2228 .body(Body::empty())
2229 .unwrap(),
2230 )
2231 .await
2232 .unwrap();
2233 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2234 let v: Value = serde_json::from_slice(&bytes).unwrap();
2235 assert_eq!(v["lines"].as_array().expect("lines").len(), 1);
2236
2237 let resp = app
2239 .oneshot(
2240 Request::builder()
2241 .uri("/api/v1/logs/tail?n=999999")
2242 .body(Body::empty())
2243 .unwrap(),
2244 )
2245 .await
2246 .unwrap();
2247 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2248 let v: Value = serde_json::from_slice(&bytes).unwrap();
2249 assert_eq!(v["lines"].as_array().expect("lines").len(), 5);
2250 }
2251
2252 #[tokio::test]
2263 async fn admin_stop_returns_ok() {
2264 let state = test_state();
2265 let Json(body) = admin_stop(State(state)).await;
2266 assert_eq!(body["ok"], Value::Bool(true));
2267 assert_eq!(body["message"].as_str(), Some("shutting down"));
2268 }
2269
2270 #[tokio::test]
2271 async fn status_endpoint_returns_payload() {
2272 let state = test_state();
2273 let app = router().with_state(state);
2274 let resp = app
2275 .oneshot(
2276 Request::builder()
2277 .uri("/api/v1/status")
2278 .body(Body::empty())
2279 .unwrap(),
2280 )
2281 .await
2282 .unwrap();
2283 assert_eq!(resp.status(), StatusCode::OK);
2284 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2285 let v: Value = serde_json::from_slice(&bytes).unwrap();
2286 assert!(v["version"].is_string());
2287 assert_eq!(v["palace_count"], 0);
2288 }
2289
2290 #[tokio::test]
2291 async fn unknown_api_returns_404() {
2292 let state = test_state();
2293 let app = router().with_state(state);
2294 let resp = app
2295 .oneshot(
2296 Request::builder()
2297 .uri("/api/v1/does-not-exist")
2298 .body(Body::empty())
2299 .unwrap(),
2300 )
2301 .await
2302 .unwrap();
2303 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2304 }
2305
2306 #[tokio::test]
2317 async fn memories_alias_routes_to_drawers() {
2318 let state = test_state();
2319 let palace = Palace {
2320 id: PalaceId::new("alias-test"),
2321 name: "alias-test".to_string(),
2322 description: None,
2323 created_at: chrono::Utc::now(),
2324 data_dir: state.data_root.join("alias-test"),
2325 };
2326 state
2327 .registry
2328 .create_palace(&state.data_root, palace)
2329 .expect("create_palace");
2330
2331 let app = router().with_state(state);
2332 let resp = app
2333 .oneshot(
2334 Request::builder()
2335 .uri("/api/v1/palaces/alias-test/memories")
2336 .body(Body::empty())
2337 .unwrap(),
2338 )
2339 .await
2340 .unwrap();
2341 assert_eq!(
2342 resp.status(),
2343 StatusCode::OK,
2344 "the /memories alias must resolve to list_drawers, not 404"
2345 );
2346 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2347 let v: Value = serde_json::from_slice(&bytes).unwrap();
2348 assert!(
2349 v.is_array(),
2350 "the alias must return the list-drawers array shape, got {v:?}"
2351 );
2352 }
2353
2354 #[tokio::test]
2368 async fn http_create_drawer_runs_auto_kg_extraction() {
2369 let state = test_state();
2370 let palace = Palace {
2371 id: PalaceId::new("kgauto-http"),
2372 name: "kgauto-http".to_string(),
2373 description: None,
2374 created_at: chrono::Utc::now(),
2375 data_dir: state.data_root.join("kgauto-http"),
2376 };
2377 state
2378 .registry
2379 .create_palace(&state.data_root, palace)
2380 .expect("create_palace");
2381
2382 let app = router().with_state(state.clone());
2383 let body = json!({
2387 "content": "trusty-memory is a Rust crate that ships an MCP server. \
2388 It tracks #mcp and #rust topics with care.",
2389 "room": "Backend",
2390 "tags": ["backend", "kg"],
2391 "importance": 0.5,
2392 })
2393 .to_string();
2394 let resp = app
2395 .clone()
2396 .oneshot(
2397 Request::builder()
2398 .method("POST")
2399 .uri("/api/v1/palaces/kgauto-http/drawers")
2400 .header("content-type", "application/json")
2401 .body(Body::from(body))
2402 .unwrap(),
2403 )
2404 .await
2405 .unwrap();
2406 assert_eq!(
2407 resp.status(),
2408 StatusCode::OK,
2409 "create_drawer must return 200 OK"
2410 );
2411
2412 let resp = app
2417 .oneshot(
2418 Request::builder()
2419 .uri("/api/v1/palaces/kgauto-http/kg/graph")
2420 .body(Body::empty())
2421 .unwrap(),
2422 )
2423 .await
2424 .unwrap();
2425 assert_eq!(resp.status(), StatusCode::OK);
2426 let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
2427 let v: Value = serde_json::from_slice(&bytes).unwrap();
2428 let triples = v["triples"].as_array().expect("triples array");
2429 assert!(
2430 !triples.is_empty(),
2431 "HTTP-origin drawer must populate the KG; got empty graph"
2432 );
2433 let auto: Vec<&Value> = triples
2434 .iter()
2435 .filter(|t| t["provenance"].as_str() == Some(crate::kg_extract::AUTO_PROVENANCE))
2436 .collect();
2437 assert!(
2438 !auto.is_empty(),
2439 "expected at least one auto-extracted triple in HTTP-populated KG; got: {triples:?}"
2440 );
2441 assert!(
2446 auto.iter()
2447 .any(|t| t["subject"].as_str() == Some("tag:backend")),
2448 "expected `tag:backend` auto-extracted edge, got: {auto:?}"
2449 );
2450 assert!(
2452 auto.iter()
2453 .any(|t| t["predicate"].as_str() == Some("mentioned-in")),
2454 "expected at least one #hashtag mention triple, got: {auto:?}"
2455 );
2456 }
2457
2458 #[tokio::test]
2459 async fn create_then_list_palace() {
2460 let state = test_state();
2461 let app = router().with_state(state.clone());
2462 let body = json!({"name": "web-test", "description": "from test"}).to_string();
2463 let resp = app
2464 .clone()
2465 .oneshot(
2466 Request::builder()
2467 .method("POST")
2468 .uri("/api/v1/palaces")
2469 .header("content-type", "application/json")
2470 .body(Body::from(body))
2471 .unwrap(),
2472 )
2473 .await
2474 .unwrap();
2475 assert_eq!(resp.status(), StatusCode::OK);
2476
2477 let resp = app
2478 .oneshot(
2479 Request::builder()
2480 .uri("/api/v1/palaces")
2481 .body(Body::empty())
2482 .unwrap(),
2483 )
2484 .await
2485 .unwrap();
2486 assert_eq!(resp.status(), StatusCode::OK);
2487 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2488 let v: Value = serde_json::from_slice(&bytes).unwrap();
2489 let arr = v.as_array().expect("array");
2490 assert!(arr.iter().any(|p| p["id"] == "web-test"));
2491 }
2492
2493 #[tokio::test]
2501 async fn delete_palace_removes_dir_when_empty() {
2502 let state = test_state();
2503 let app = router().with_state(state.clone());
2504 let body = json!({"name": "to-delete"}).to_string();
2505 let resp = app
2506 .clone()
2507 .oneshot(
2508 Request::builder()
2509 .method("POST")
2510 .uri("/api/v1/palaces")
2511 .header("content-type", "application/json")
2512 .body(Body::from(body))
2513 .unwrap(),
2514 )
2515 .await
2516 .unwrap();
2517 assert_eq!(resp.status(), StatusCode::OK);
2518
2519 let resp = app
2520 .clone()
2521 .oneshot(
2522 Request::builder()
2523 .method("DELETE")
2524 .uri("/api/v1/palaces/to-delete")
2525 .body(Body::empty())
2526 .unwrap(),
2527 )
2528 .await
2529 .unwrap();
2530 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2531
2532 let resp = app
2534 .oneshot(
2535 Request::builder()
2536 .uri("/api/v1/palaces/to-delete")
2537 .body(Body::empty())
2538 .unwrap(),
2539 )
2540 .await
2541 .unwrap();
2542 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2543
2544 let palace_dir = state.data_root.join("to-delete");
2546 assert!(
2547 !palace_dir.exists(),
2548 "palace dir should be removed: {}",
2549 palace_dir.display()
2550 );
2551 }
2552
2553 #[tokio::test]
2561 async fn delete_palace_refuses_when_drawers_present() {
2562 let state = test_state();
2563 let app = router().with_state(state.clone());
2564 let resp = app
2566 .clone()
2567 .oneshot(
2568 Request::builder()
2569 .method("POST")
2570 .uri("/api/v1/palaces")
2571 .header("content-type", "application/json")
2572 .body(Body::from(json!({"name": "keep-me"}).to_string()))
2573 .unwrap(),
2574 )
2575 .await
2576 .unwrap();
2577 assert_eq!(resp.status(), StatusCode::OK);
2578 let resp = app
2580 .clone()
2581 .oneshot(
2582 Request::builder()
2583 .method("POST")
2584 .uri("/api/v1/palaces/keep-me/drawers")
2585 .header("content-type", "application/json")
2586 .body(Body::from(
2587 json!({
2588 "content": "Important fact that should not be deleted accidentally.",
2589 "tags": [],
2590 })
2591 .to_string(),
2592 ))
2593 .unwrap(),
2594 )
2595 .await
2596 .unwrap();
2597 assert_eq!(resp.status(), StatusCode::OK);
2598
2599 let resp = app
2600 .clone()
2601 .oneshot(
2602 Request::builder()
2603 .method("DELETE")
2604 .uri("/api/v1/palaces/keep-me")
2605 .body(Body::empty())
2606 .unwrap(),
2607 )
2608 .await
2609 .unwrap();
2610 assert_eq!(resp.status(), StatusCode::CONFLICT);
2611
2612 let resp = app
2614 .oneshot(
2615 Request::builder()
2616 .uri("/api/v1/palaces/keep-me")
2617 .body(Body::empty())
2618 .unwrap(),
2619 )
2620 .await
2621 .unwrap();
2622 assert_eq!(resp.status(), StatusCode::OK);
2623 }
2624
2625 #[tokio::test]
2632 async fn delete_palace_force_removes_populated_palace() {
2633 let state = test_state();
2634 let app = router().with_state(state.clone());
2635 let resp = app
2636 .clone()
2637 .oneshot(
2638 Request::builder()
2639 .method("POST")
2640 .uri("/api/v1/palaces")
2641 .header("content-type", "application/json")
2642 .body(Body::from(json!({"name": "force-delete"}).to_string()))
2643 .unwrap(),
2644 )
2645 .await
2646 .unwrap();
2647 assert_eq!(resp.status(), StatusCode::OK);
2648 let resp = app
2649 .clone()
2650 .oneshot(
2651 Request::builder()
2652 .method("POST")
2653 .uri("/api/v1/palaces/force-delete/drawers")
2654 .header("content-type", "application/json")
2655 .body(Body::from(
2656 json!({"content": "Sacrificial drawer for the force-delete path.", "tags": []}).to_string(),
2657 ))
2658 .unwrap(),
2659 )
2660 .await
2661 .unwrap();
2662 assert_eq!(resp.status(), StatusCode::OK);
2663
2664 let resp = app
2665 .clone()
2666 .oneshot(
2667 Request::builder()
2668 .method("DELETE")
2669 .uri("/api/v1/palaces/force-delete?force=true")
2670 .body(Body::empty())
2671 .unwrap(),
2672 )
2673 .await
2674 .unwrap();
2675 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2676
2677 let resp = app
2678 .oneshot(
2679 Request::builder()
2680 .uri("/api/v1/palaces/force-delete")
2681 .body(Body::empty())
2682 .unwrap(),
2683 )
2684 .await
2685 .unwrap();
2686 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2687 }
2688
2689 #[tokio::test]
2695 async fn delete_palace_returns_not_found_for_missing_id() {
2696 let state = test_state();
2697 let app = router().with_state(state);
2698 let resp = app
2699 .oneshot(
2700 Request::builder()
2701 .method("DELETE")
2702 .uri("/api/v1/palaces/never-existed")
2703 .body(Body::empty())
2704 .unwrap(),
2705 )
2706 .await
2707 .unwrap();
2708 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2709 }
2710
2711 #[tokio::test]
2720 async fn update_palace_name_renames_palace() {
2721 let state = test_state();
2722 let app = router().with_state(state);
2723 let resp = app
2724 .clone()
2725 .oneshot(
2726 Request::builder()
2727 .method("POST")
2728 .uri("/api/v1/palaces")
2729 .header("content-type", "application/json")
2730 .body(Body::from(json!({"name": "rename-me"}).to_string()))
2731 .unwrap(),
2732 )
2733 .await
2734 .unwrap();
2735 assert_eq!(resp.status(), StatusCode::OK);
2736
2737 let resp = app
2738 .clone()
2739 .oneshot(
2740 Request::builder()
2741 .method("PATCH")
2742 .uri("/api/v1/palaces/rename-me")
2743 .header("content-type", "application/json")
2744 .body(Body::from(json!({"name": "New Display Name"}).to_string()))
2745 .unwrap(),
2746 )
2747 .await
2748 .unwrap();
2749 assert_eq!(resp.status(), StatusCode::OK);
2750 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2751 let v: Value = serde_json::from_slice(&bytes).unwrap();
2752 assert_eq!(v["id"].as_str(), Some("rename-me"));
2753 assert_eq!(v["name"].as_str(), Some("New Display Name"));
2754
2755 let resp = app
2756 .oneshot(
2757 Request::builder()
2758 .uri("/api/v1/palaces/rename-me")
2759 .body(Body::empty())
2760 .unwrap(),
2761 )
2762 .await
2763 .unwrap();
2764 assert_eq!(resp.status(), StatusCode::OK);
2765 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2766 let v: Value = serde_json::from_slice(&bytes).unwrap();
2767 assert_eq!(v["id"].as_str(), Some("rename-me"));
2768 assert_eq!(v["name"].as_str(), Some("New Display Name"));
2769 }
2770
2771 #[tokio::test]
2777 async fn update_palace_name_rejects_empty_name() {
2778 let state = test_state();
2779 let app = router().with_state(state);
2780 let resp = app
2781 .clone()
2782 .oneshot(
2783 Request::builder()
2784 .method("POST")
2785 .uri("/api/v1/palaces")
2786 .header("content-type", "application/json")
2787 .body(Body::from(json!({"name": "keep-name"}).to_string()))
2788 .unwrap(),
2789 )
2790 .await
2791 .unwrap();
2792 assert_eq!(resp.status(), StatusCode::OK);
2793
2794 let resp = app
2795 .oneshot(
2796 Request::builder()
2797 .method("PATCH")
2798 .uri("/api/v1/palaces/keep-name")
2799 .header("content-type", "application/json")
2800 .body(Body::from(json!({"name": " "}).to_string()))
2801 .unwrap(),
2802 )
2803 .await
2804 .unwrap();
2805 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2806 }
2807
2808 #[tokio::test]
2814 async fn update_palace_name_returns_not_found_for_missing_id() {
2815 let state = test_state();
2816 let app = router().with_state(state);
2817 let resp = app
2818 .oneshot(
2819 Request::builder()
2820 .method("PATCH")
2821 .uri("/api/v1/palaces/no-such-palace")
2822 .header("content-type", "application/json")
2823 .body(Body::from(json!({"name": "irrelevant"}).to_string()))
2824 .unwrap(),
2825 )
2826 .await
2827 .unwrap();
2828 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2829 }
2830
2831 #[tokio::test]
2840 async fn palace_list_includes_graph_counts() {
2841 let state = test_state();
2842 let app = router().with_state(state.clone());
2843 let body = json!({"name": "graph-counts", "description": null}).to_string();
2844 let resp = app
2845 .clone()
2846 .oneshot(
2847 Request::builder()
2848 .method("POST")
2849 .uri("/api/v1/palaces")
2850 .header("content-type", "application/json")
2851 .body(Body::from(body))
2852 .unwrap(),
2853 )
2854 .await
2855 .unwrap();
2856 assert_eq!(resp.status(), StatusCode::OK);
2857
2858 let resp = app
2859 .oneshot(
2860 Request::builder()
2861 .uri("/api/v1/palaces")
2862 .body(Body::empty())
2863 .unwrap(),
2864 )
2865 .await
2866 .unwrap();
2867 assert_eq!(resp.status(), StatusCode::OK);
2868 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2869 let v: Value = serde_json::from_slice(&bytes).unwrap();
2870 let arr = v.as_array().expect("array");
2871 let row = arr
2872 .iter()
2873 .find(|p| p["id"] == "graph-counts")
2874 .expect("created palace must appear in list");
2875 assert_eq!(row["node_count"].as_u64(), Some(0));
2876 assert_eq!(row["edge_count"].as_u64(), Some(0));
2877 assert_eq!(row["community_count"].as_u64(), Some(0));
2878 assert_eq!(row["is_compacting"].as_bool(), Some(false));
2879 }
2880
2881 #[tokio::test]
2888 async fn status_includes_total_counters() {
2889 let state = test_state();
2890 let app = router().with_state(state);
2891 let resp = app
2892 .oneshot(
2893 Request::builder()
2894 .uri("/api/v1/status")
2895 .body(Body::empty())
2896 .unwrap(),
2897 )
2898 .await
2899 .unwrap();
2900 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2901 let v: Value = serde_json::from_slice(&bytes).unwrap();
2902 assert_eq!(v["total_drawers"], 0);
2903 assert_eq!(v["total_vectors"], 0);
2904 assert_eq!(v["total_kg_triples"], 0);
2905 }
2906
2907 #[tokio::test]
2914 async fn dream_status_empty_returns_nulls() {
2915 let state = test_state();
2916 let app = router().with_state(state);
2917 let resp = app
2918 .oneshot(
2919 Request::builder()
2920 .uri("/api/v1/dream/status")
2921 .body(Body::empty())
2922 .unwrap(),
2923 )
2924 .await
2925 .unwrap();
2926 assert_eq!(resp.status(), StatusCode::OK);
2927 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2928 let v: Value = serde_json::from_slice(&bytes).unwrap();
2929 assert!(v["last_run_at"].is_null());
2930 assert_eq!(v["merged"], 0);
2931 assert_eq!(v["pruned"], 0);
2932 }
2933
2934 #[tokio::test]
2941 async fn providers_endpoint_returns_payload() {
2942 let state = test_state();
2943 let app = router().with_state(state);
2944 let resp = app
2945 .oneshot(
2946 Request::builder()
2947 .uri("/api/v1/chat/providers")
2948 .body(Body::empty())
2949 .unwrap(),
2950 )
2951 .await
2952 .unwrap();
2953 assert_eq!(resp.status(), StatusCode::OK);
2954 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2955 let v: Value = serde_json::from_slice(&bytes).unwrap();
2956 let arr = v["providers"].as_array().expect("providers array");
2957 assert_eq!(arr.len(), 2);
2958 let names: Vec<&str> = arr.iter().filter_map(|p| p["name"].as_str()).collect();
2959 assert!(names.contains(&"ollama"));
2960 assert!(names.contains(&"openrouter"));
2961 assert!(v.get("active").is_some());
2963 }
2964
2965 #[tokio::test]
2972 async fn chat_session_crud_round_trip() {
2973 let state = test_state();
2974 let palace = trusty_common::memory_core::Palace {
2976 id: PalaceId::new("sess-test"),
2977 name: "sess-test".to_string(),
2978 description: None,
2979 created_at: chrono::Utc::now(),
2980 data_dir: state.data_root.join("sess-test"),
2981 };
2982 state
2983 .registry
2984 .create_palace(&state.data_root, palace)
2985 .expect("create_palace");
2986 let app = router().with_state(state);
2987
2988 let resp = app
2990 .clone()
2991 .oneshot(
2992 Request::builder()
2993 .method("POST")
2994 .uri("/api/v1/palaces/sess-test/chat/sessions")
2995 .header("content-type", "application/json")
2996 .body(Body::from(json!({"title":"first chat"}).to_string()))
2997 .unwrap(),
2998 )
2999 .await
3000 .unwrap();
3001 assert_eq!(resp.status(), StatusCode::OK);
3002 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3003 let v: Value = serde_json::from_slice(&bytes).unwrap();
3004 let sid = v["id"].as_str().expect("session id").to_string();
3005
3006 let resp = app
3008 .clone()
3009 .oneshot(
3010 Request::builder()
3011 .uri("/api/v1/palaces/sess-test/chat/sessions")
3012 .body(Body::empty())
3013 .unwrap(),
3014 )
3015 .await
3016 .unwrap();
3017 assert_eq!(resp.status(), StatusCode::OK);
3018 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3019 let v: Value = serde_json::from_slice(&bytes).unwrap();
3020 let arr = v.as_array().expect("array");
3021 assert!(arr.iter().any(|s| s["id"] == sid));
3022
3023 let resp = app
3025 .clone()
3026 .oneshot(
3027 Request::builder()
3028 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3029 .body(Body::empty())
3030 .unwrap(),
3031 )
3032 .await
3033 .unwrap();
3034 assert_eq!(resp.status(), StatusCode::OK);
3035
3036 let resp = app
3038 .clone()
3039 .oneshot(
3040 Request::builder()
3041 .method("DELETE")
3042 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3043 .body(Body::empty())
3044 .unwrap(),
3045 )
3046 .await
3047 .unwrap();
3048 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
3049
3050 let resp = app
3052 .oneshot(
3053 Request::builder()
3054 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3055 .body(Body::empty())
3056 .unwrap(),
3057 )
3058 .await
3059 .unwrap();
3060 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3061 }
3062
3063 #[tokio::test]
3075 async fn messages_endpoint_round_trip() {
3076 let state = test_state();
3077 let palace = trusty_common::memory_core::Palace {
3078 id: PalaceId::new("msg-test"),
3079 name: "msg-test".to_string(),
3080 description: None,
3081 created_at: chrono::Utc::now(),
3082 data_dir: state.data_root.join("msg-test"),
3083 };
3084 state
3085 .registry
3086 .create_palace(&state.data_root, palace)
3087 .expect("create_palace");
3088 let app = router().with_state(state);
3089
3090 let resp = app
3092 .clone()
3093 .oneshot(
3094 Request::builder()
3095 .method("POST")
3096 .uri("/api/v1/messages")
3097 .header("content-type", "application/json")
3098 .body(Body::from(
3099 json!({
3100 "to_palace": "msg-test",
3101 "from_palace": "sender-palace",
3102 "purpose": "task",
3103 "content": "please refresh schema"
3104 })
3105 .to_string(),
3106 ))
3107 .unwrap(),
3108 )
3109 .await
3110 .unwrap();
3111 assert_eq!(resp.status(), StatusCode::OK);
3112 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3113 let send_resp: Value = serde_json::from_slice(&bytes).unwrap();
3114 assert_eq!(send_resp["status"], "sent");
3115 let drawer_id = send_resp["drawer_id"]
3116 .as_str()
3117 .expect("drawer_id")
3118 .to_string();
3119
3120 let resp = app
3122 .clone()
3123 .oneshot(
3124 Request::builder()
3125 .uri("/api/v1/messages?palace=msg-test&unread_only=true")
3126 .body(Body::empty())
3127 .unwrap(),
3128 )
3129 .await
3130 .unwrap();
3131 assert_eq!(resp.status(), StatusCode::OK);
3132 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
3133 let list: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3134 assert_eq!(list.len(), 1);
3135 assert_eq!(list[0]["id"], drawer_id);
3136 assert_eq!(list[0]["from_palace"], "sender-palace");
3137 assert_eq!(list[0]["to_palace"], "msg-test");
3138 assert_eq!(list[0]["purpose"], "task");
3139 assert_eq!(list[0]["content"], "please refresh schema");
3140 assert_eq!(list[0]["read"], false);
3141 assert!(list[0]["formatted"]
3142 .as_str()
3143 .unwrap()
3144 .contains("sender-palace"));
3145
3146 let resp = app
3148 .clone()
3149 .oneshot(
3150 Request::builder()
3151 .method("POST")
3152 .uri("/api/v1/messages/mark_read")
3153 .header("content-type", "application/json")
3154 .body(Body::from(
3155 json!({"palace": "msg-test", "drawer_id": drawer_id}).to_string(),
3156 ))
3157 .unwrap(),
3158 )
3159 .await
3160 .unwrap();
3161 assert_eq!(resp.status(), StatusCode::OK);
3162 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
3163 let mark: Value = serde_json::from_slice(&bytes).unwrap();
3164 assert_eq!(mark["flipped"], true);
3165
3166 let resp = app
3168 .clone()
3169 .oneshot(
3170 Request::builder()
3171 .uri("/api/v1/messages?palace=msg-test&unread_only=true")
3172 .body(Body::empty())
3173 .unwrap(),
3174 )
3175 .await
3176 .unwrap();
3177 assert_eq!(resp.status(), StatusCode::OK);
3178 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3179 let list: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3180 assert!(list.is_empty(), "inbox cleared after mark_read");
3181
3182 let resp = app
3184 .oneshot(
3185 Request::builder()
3186 .uri("/api/v1/messages?palace=msg-test&unread_only=false")
3187 .body(Body::empty())
3188 .unwrap(),
3189 )
3190 .await
3191 .unwrap();
3192 assert_eq!(resp.status(), StatusCode::OK);
3193 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
3194 let history: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3195 assert_eq!(history.len(), 1);
3196 assert_eq!(history[0]["read"], true);
3197 }
3198
3199 #[test]
3206 fn all_tools_returns_expected_set() {
3207 let tools = crate::chat::all_tools();
3208 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
3209 assert_eq!(
3210 names,
3211 vec![
3212 "list_palaces",
3213 "get_palace",
3214 "recall_memories",
3215 "list_drawers",
3216 "kg_query",
3217 "get_config",
3218 "get_status",
3219 "get_dream_status",
3220 "get_palace_dream_status",
3221 "create_memory",
3222 "kg_assert",
3223 "memory_recall_all",
3224 ]
3225 );
3226 for t in &tools {
3229 assert_eq!(
3230 t.parameters["type"], "object",
3231 "tool {} schema type",
3232 t.name
3233 );
3234 assert!(
3235 t.parameters["required"].is_array(),
3236 "tool {} required not array",
3237 t.name
3238 );
3239 }
3240 }
3241
3242 #[tokio::test]
3249 async fn execute_tool_dispatches_known_tools() {
3250 let state = test_state();
3251 let result = crate::chat::execute_tool("list_palaces", "{}", &state).await;
3252 assert!(
3253 result.is_array(),
3254 "list_palaces should be array, got {result}"
3255 );
3256 assert_eq!(result.as_array().unwrap().len(), 0);
3257
3258 let unknown = crate::chat::execute_tool("not_a_tool", "{}", &state).await;
3259 assert!(
3260 unknown["error"]
3261 .as_str()
3262 .unwrap_or("")
3263 .contains("unknown tool"),
3264 "expected unknown-tool error, got {unknown}"
3265 );
3266
3267 let missing = crate::chat::execute_tool("get_palace", "{}", &state).await;
3268 assert!(
3269 missing["error"]
3270 .as_str()
3271 .unwrap_or("")
3272 .contains("palace_id"),
3273 "expected missing-arg error, got {missing}"
3274 );
3275 }
3276
3277 #[tokio::test]
3286 async fn sse_broadcast_emits_palace_created() {
3287 let state = test_state();
3288 let mut rx = state.events.subscribe();
3289 let app = router().with_state(state.clone());
3290 let body = json!({"name": "sse-test"}).to_string();
3291 let resp = app
3292 .oneshot(
3293 Request::builder()
3294 .method("POST")
3295 .uri("/api/v1/palaces")
3296 .header("content-type", "application/json")
3297 .body(Body::from(body))
3298 .unwrap(),
3299 )
3300 .await
3301 .unwrap();
3302 assert_eq!(resp.status(), StatusCode::OK);
3303 let event = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
3305 .await
3306 .expect("event received within timeout")
3307 .expect("event channel still open");
3308 match event {
3309 DaemonEvent::PalaceCreated { id, name, source } => {
3310 assert_eq!(id, "sse-test");
3311 assert_eq!(name, "sse-test");
3312 assert_eq!(source, ActivitySource::Http);
3313 }
3314 other => panic!("expected PalaceCreated, got {other:?}"),
3315 }
3316 }
3317
3318 #[tokio::test]
3325 async fn sse_endpoint_emits_connected_frame() {
3326 use axum::routing::get;
3327 let state = test_state();
3328 let app = router()
3329 .route("/sse", get(crate::sse_handler))
3330 .with_state(state);
3331 let resp = app
3332 .oneshot(Request::builder().uri("/sse").body(Body::empty()).unwrap())
3333 .await
3334 .unwrap();
3335 assert_eq!(resp.status(), StatusCode::OK);
3336 assert_eq!(
3337 resp.headers()
3338 .get(header::CONTENT_TYPE)
3339 .and_then(|v| v.to_str().ok()),
3340 Some("text/event-stream")
3341 );
3342 let body = resp.into_body();
3345 let bytes =
3346 tokio::time::timeout(std::time::Duration::from_millis(500), to_bytes(body, 4096))
3347 .await
3348 .ok()
3349 .and_then(|r| r.ok())
3350 .unwrap_or_default();
3351 let text = String::from_utf8_lossy(&bytes);
3352 assert!(
3353 text.contains("\"type\":\"connected\""),
3354 "expected connected frame, got: {text}"
3355 );
3356 }
3357
3358 #[tokio::test]
3368 async fn dream_status_aggregates_across_palaces() {
3369 use trusty_common::memory_core::dream::{DreamStats, PersistedDreamStats};
3370
3371 let state = test_state();
3372 for (id, stats, ts) in [
3376 (
3377 "palace-a",
3378 DreamStats {
3379 merged: 1,
3380 pruned: 2,
3381 compacted: 3,
3382 closets_updated: 4,
3383 duration_ms: 100,
3384 ..DreamStats::default()
3385 },
3386 chrono::Utc::now() - chrono::Duration::seconds(60),
3387 ),
3388 (
3389 "palace-b",
3390 DreamStats {
3391 merged: 10,
3392 pruned: 20,
3393 compacted: 30,
3394 closets_updated: 40,
3395 duration_ms: 200,
3396 ..DreamStats::default()
3397 },
3398 chrono::Utc::now(),
3399 ),
3400 ] {
3401 let palace = trusty_common::memory_core::Palace {
3402 id: PalaceId::new(id),
3403 name: id.to_string(),
3404 description: None,
3405 created_at: chrono::Utc::now(),
3406 data_dir: state.data_root.join(id),
3407 };
3408 state
3409 .registry
3410 .create_palace(&state.data_root, palace)
3411 .expect("create palace");
3412 let persisted = PersistedDreamStats {
3413 last_run_at: ts,
3414 stats,
3415 };
3416 persisted
3417 .save(&state.data_root.join(id))
3418 .expect("save dream stats");
3419 }
3420
3421 let later = chrono::Utc::now();
3422 let app = router().with_state(state);
3423 let resp = app
3424 .oneshot(
3425 Request::builder()
3426 .uri("/api/v1/dream/status")
3427 .body(Body::empty())
3428 .unwrap(),
3429 )
3430 .await
3431 .unwrap();
3432 assert_eq!(resp.status(), StatusCode::OK);
3433 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3434 let v: Value = serde_json::from_slice(&bytes).unwrap();
3435
3436 assert_eq!(v["merged"], 11);
3438 assert_eq!(v["pruned"], 22);
3439 assert_eq!(v["compacted"], 33);
3440 assert_eq!(v["closets_updated"], 44);
3441 assert_eq!(v["duration_ms"], 300);
3442
3443 let last = v["last_run_at"].as_str().expect("last_run_at is string");
3445 let parsed: chrono::DateTime<chrono::Utc> = last
3446 .parse()
3447 .expect("last_run_at parses as RFC3339 timestamp");
3448 assert!(
3449 parsed <= later,
3450 "last_run_at ({parsed}) should not exceed wall clock ({later})"
3451 );
3452 let cutoff = chrono::Utc::now() - chrono::Duration::seconds(30);
3454 assert!(
3455 parsed >= cutoff,
3456 "expected the newer (palace-b) timestamp; got {parsed}"
3457 );
3458 }
3459
3460 #[tokio::test]
3472 async fn dream_run_aggregates_stats() {
3473 let state = test_state();
3474 let palace = trusty_common::memory_core::Palace {
3475 id: PalaceId::new("dream-run-test"),
3476 name: "dream-run-test".to_string(),
3477 description: None,
3478 created_at: chrono::Utc::now(),
3479 data_dir: state.data_root.join("dream-run-test"),
3480 };
3481 state
3482 .registry
3483 .create_palace(&state.data_root, palace)
3484 .expect("create palace");
3485
3486 let app = router().with_state(state);
3487 let resp = app
3488 .oneshot(
3489 Request::builder()
3490 .method("POST")
3491 .uri("/api/v1/dream/run")
3492 .body(Body::empty())
3493 .unwrap(),
3494 )
3495 .await
3496 .unwrap();
3497 assert_eq!(resp.status(), StatusCode::OK);
3498 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3499 let v: Value = serde_json::from_slice(&bytes).unwrap();
3500
3501 for key in [
3504 "merged",
3505 "pruned",
3506 "compacted",
3507 "closets_updated",
3508 "duration_ms",
3509 ] {
3510 assert!(
3511 v.get(key).is_some(),
3512 "missing key {key} in dream_run payload: {v}"
3513 );
3514 assert!(
3515 v[key].is_u64() || v[key].is_i64(),
3516 "{key} should be integer, got {}",
3517 v[key]
3518 );
3519 }
3520 assert!(
3521 v["last_run_at"].is_string(),
3522 "last_run_at must be set by dream_run; got {v}"
3523 );
3524 }
3525
3526 #[tokio::test]
3533 async fn kg_gaps_endpoint_returns_empty_when_uncached() {
3534 let state = test_state();
3535 let palace = trusty_common::memory_core::Palace {
3536 id: PalaceId::new("gaps-empty"),
3537 name: "gaps-empty".to_string(),
3538 description: None,
3539 created_at: chrono::Utc::now(),
3540 data_dir: state.data_root.join("gaps-empty"),
3541 };
3542 state
3543 .registry
3544 .create_palace(&state.data_root, palace)
3545 .expect("create palace");
3546
3547 let app = router().with_state(state);
3548 let resp = app
3549 .oneshot(
3550 Request::builder()
3551 .uri("/api/v1/kg/gaps?palace=gaps-empty")
3552 .body(Body::empty())
3553 .unwrap(),
3554 )
3555 .await
3556 .unwrap();
3557 assert_eq!(resp.status(), StatusCode::OK);
3558 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3559 let v: Value = serde_json::from_slice(&bytes).unwrap();
3560 assert_eq!(v.as_array().expect("array").len(), 0);
3561 }
3562
3563 #[tokio::test]
3570 async fn kg_gaps_endpoint_returns_cached_gaps() {
3571 use trusty_common::memory_core::community::KnowledgeGap;
3572
3573 let state = test_state();
3574 let palace = trusty_common::memory_core::Palace {
3575 id: PalaceId::new("gaps-seed"),
3576 name: "gaps-seed".to_string(),
3577 description: None,
3578 created_at: chrono::Utc::now(),
3579 data_dir: state.data_root.join("gaps-seed"),
3580 };
3581 state
3582 .registry
3583 .create_palace(&state.data_root, palace)
3584 .expect("create palace");
3585
3586 state.registry.set_gaps(
3587 PalaceId::new("gaps-seed"),
3588 vec![KnowledgeGap {
3589 entities: vec!["foo".to_string(), "bar".to_string(), "baz".to_string()],
3590 internal_density: 0.15,
3591 external_bridges: 2,
3592 suggested_exploration: "Explore connections between foo and related concepts"
3593 .to_string(),
3594 }],
3595 );
3596
3597 let app = router().with_state(state);
3598 let resp = app
3599 .oneshot(
3600 Request::builder()
3601 .uri("/api/v1/kg/gaps?palace=gaps-seed")
3602 .body(Body::empty())
3603 .unwrap(),
3604 )
3605 .await
3606 .unwrap();
3607 assert_eq!(resp.status(), StatusCode::OK);
3608 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3609 let v: Value = serde_json::from_slice(&bytes).unwrap();
3610 let arr = v.as_array().expect("array");
3611 assert_eq!(arr.len(), 1);
3612 assert_eq!(arr[0]["entities"].as_array().unwrap().len(), 3);
3613 assert_eq!(arr[0]["external_bridges"], 2);
3614 assert!(arr[0]["suggested_exploration"]
3615 .as_str()
3616 .unwrap()
3617 .contains("foo"));
3618 }
3619
3620 #[tokio::test]
3627 async fn kg_list_subjects_returns_distinct() {
3628 let state = test_state();
3629 let app = router().with_state(state.clone());
3630
3631 let resp = app
3633 .clone()
3634 .oneshot(
3635 Request::builder()
3636 .method("POST")
3637 .uri("/api/v1/palaces")
3638 .header("content-type", "application/json")
3639 .body(Body::from(json!({"name": "kg-list"}).to_string()))
3640 .unwrap(),
3641 )
3642 .await
3643 .unwrap();
3644 assert_eq!(resp.status(), StatusCode::OK);
3645
3646 for subj in ["alpha", "beta"] {
3648 let body = json!({
3649 "subject": subj,
3650 "predicate": "is",
3651 "object": "thing",
3652 })
3653 .to_string();
3654 let r = app
3655 .clone()
3656 .oneshot(
3657 Request::builder()
3658 .method("POST")
3659 .uri("/api/v1/palaces/kg-list/kg")
3660 .header("content-type", "application/json")
3661 .body(Body::from(body))
3662 .unwrap(),
3663 )
3664 .await
3665 .unwrap();
3666 assert_eq!(r.status(), StatusCode::NO_CONTENT);
3667 }
3668
3669 let resp = app
3670 .oneshot(
3671 Request::builder()
3672 .uri("/api/v1/palaces/kg-list/kg/subjects?limit=10")
3673 .body(Body::empty())
3674 .unwrap(),
3675 )
3676 .await
3677 .unwrap();
3678 assert_eq!(resp.status(), StatusCode::OK);
3679 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3680 let v: Value = serde_json::from_slice(&bytes).unwrap();
3681 let arr = v.as_array().expect("subjects must be array");
3682 let subjects: Vec<String> = arr
3683 .iter()
3684 .filter_map(|x| x.as_str().map(String::from))
3685 .collect();
3686 assert_eq!(subjects, vec!["alpha".to_string(), "beta".to_string()]);
3687 }
3688
3689 #[tokio::test]
3696 async fn kg_list_all_returns_paginated_triples() {
3697 let state = test_state();
3698 let app = router().with_state(state.clone());
3699
3700 let resp = app
3701 .clone()
3702 .oneshot(
3703 Request::builder()
3704 .method("POST")
3705 .uri("/api/v1/palaces")
3706 .header("content-type", "application/json")
3707 .body(Body::from(json!({"name": "kg-all"}).to_string()))
3708 .unwrap(),
3709 )
3710 .await
3711 .unwrap();
3712 assert_eq!(resp.status(), StatusCode::OK);
3713
3714 let body = json!({
3715 "subject": "alpha",
3716 "predicate": "is",
3717 "object": "thing",
3718 })
3719 .to_string();
3720 let r = app
3721 .clone()
3722 .oneshot(
3723 Request::builder()
3724 .method("POST")
3725 .uri("/api/v1/palaces/kg-all/kg")
3726 .header("content-type", "application/json")
3727 .body(Body::from(body))
3728 .unwrap(),
3729 )
3730 .await
3731 .unwrap();
3732 assert_eq!(r.status(), StatusCode::NO_CONTENT);
3733
3734 let resp = app
3735 .oneshot(
3736 Request::builder()
3737 .uri("/api/v1/palaces/kg-all/kg/all?limit=10&offset=0")
3738 .body(Body::empty())
3739 .unwrap(),
3740 )
3741 .await
3742 .unwrap();
3743 assert_eq!(resp.status(), StatusCode::OK);
3744 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3745 let v: Value = serde_json::from_slice(&bytes).unwrap();
3746 let arr = v.as_array().expect("triples must be array");
3747 assert_eq!(arr.len(), 1);
3748 assert_eq!(arr[0]["subject"], "alpha");
3749 assert_eq!(arr[0]["predicate"], "is");
3750 assert_eq!(arr[0]["object"], "thing");
3751 }
3752
3753 #[tokio::test]
3762 async fn kg_graph_returns_active_triples() {
3763 let state = test_state();
3764 let app = router().with_state(state.clone());
3765
3766 let resp = app
3767 .clone()
3768 .oneshot(
3769 Request::builder()
3770 .method("POST")
3771 .uri("/api/v1/palaces")
3772 .header("content-type", "application/json")
3773 .body(Body::from(json!({"name": "kg-graph"}).to_string()))
3774 .unwrap(),
3775 )
3776 .await
3777 .unwrap();
3778 assert_eq!(resp.status(), StatusCode::OK);
3779
3780 let body = json!({
3781 "subject": "alpha",
3782 "predicate": "is",
3783 "object": "thing",
3784 })
3785 .to_string();
3786 let r = app
3787 .clone()
3788 .oneshot(
3789 Request::builder()
3790 .method("POST")
3791 .uri("/api/v1/palaces/kg-graph/kg")
3792 .header("content-type", "application/json")
3793 .body(Body::from(body))
3794 .unwrap(),
3795 )
3796 .await
3797 .unwrap();
3798 assert_eq!(r.status(), StatusCode::NO_CONTENT);
3799
3800 let resp = app
3801 .oneshot(
3802 Request::builder()
3803 .uri("/api/v1/palaces/kg-graph/kg/graph")
3804 .body(Body::empty())
3805 .unwrap(),
3806 )
3807 .await
3808 .unwrap();
3809 assert_eq!(resp.status(), StatusCode::OK);
3810 let bytes = to_bytes(resp.into_body(), 16_384).await.unwrap();
3811 let v: Value = serde_json::from_slice(&bytes).unwrap();
3812 let triples = v["triples"].as_array().expect("triples array");
3813 assert!(triples
3814 .iter()
3815 .any(|t| t["subject"] == "alpha" && t["predicate"] == "is" && t["object"] == "thing"));
3816 assert!(v["node_count"].as_u64().is_some());
3817 assert!(v["edge_count"].as_u64().is_some());
3818 assert!(v["community_count"].as_u64().is_some());
3819 }
3820
3821 #[tokio::test]
3833 async fn kg_graph_meets_perf_budget_for_500_triples() {
3834 let state = test_state();
3835 let app = router().with_state(state.clone());
3836
3837 let resp = app
3838 .clone()
3839 .oneshot(
3840 Request::builder()
3841 .method("POST")
3842 .uri("/api/v1/palaces")
3843 .header("content-type", "application/json")
3844 .body(Body::from(json!({"name": "kg-perf"}).to_string()))
3845 .unwrap(),
3846 )
3847 .await
3848 .unwrap();
3849 assert_eq!(resp.status(), StatusCode::OK);
3850
3851 let pid = trusty_common::memory_core::palace::PalaceId::new("kg-perf");
3852 let handle = state
3853 .registry
3854 .open_palace(&state.data_root, &pid)
3855 .expect("open palace");
3856 let now = chrono::Utc::now();
3857 for s in 0..10 {
3858 for o in 0..50 {
3859 handle
3860 .kg
3861 .assert(Triple {
3862 subject: format!("s{s}"),
3863 predicate: format!("p{o}"),
3864 object: format!("o{o}"),
3865 valid_from: now,
3866 valid_to: None,
3867 confidence: 1.0,
3868 provenance: Some("perf-test".to_string()),
3869 })
3870 .await
3871 .expect("kg.assert");
3872 }
3873 }
3874
3875 let started = std::time::Instant::now();
3876 let resp = app
3877 .oneshot(
3878 Request::builder()
3879 .uri("/api/v1/palaces/kg-perf/kg/graph")
3880 .body(Body::empty())
3881 .unwrap(),
3882 )
3883 .await
3884 .unwrap();
3885 let elapsed = started.elapsed();
3886 assert_eq!(resp.status(), StatusCode::OK);
3887 let bytes = to_bytes(resp.into_body(), 1_000_000).await.unwrap();
3888 let v: Value = serde_json::from_slice(&bytes).unwrap();
3889 let n = v["triples"].as_array().map(|a| a.len()).unwrap_or(0);
3890 assert_eq!(n, 500, "expected 500 triples in payload");
3891 assert!(
3892 elapsed.as_secs_f64() < 10.0,
3893 "graph endpoint should serve 500 triples in well under 10s; took {elapsed:?}"
3894 );
3895 eprintln!(
3896 "[perf] kg_graph endpoint served 500 triples in {:.3}ms",
3897 elapsed.as_secs_f64() * 1000.0
3898 );
3899 }
3900
3901 #[tokio::test]
3905 async fn prompt_context_endpoint_returns_formatted_block() {
3906 let state = test_state();
3907
3908 let app = router().with_state(state.clone());
3910 let resp = app
3911 .oneshot(
3912 Request::builder()
3913 .uri("/api/v1/kg/prompt-context")
3914 .body(Body::empty())
3915 .unwrap(),
3916 )
3917 .await
3918 .unwrap();
3919 assert_eq!(resp.status(), StatusCode::OK);
3920 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3921 let text = String::from_utf8(bytes.to_vec()).unwrap();
3922 assert_eq!(text, "No prompt facts stored yet.");
3923
3924 {
3926 let mut guard = state.prompt_context_cache.write().await;
3927 let triples = vec![(
3928 "tga".to_string(),
3929 "is_alias_for".to_string(),
3930 "trusty-git-analytics".to_string(),
3931 )];
3932 let formatted = crate::prompt_facts::build_prompt_context(&triples);
3933 *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
3934 }
3935 let app = router().with_state(state);
3936 let resp = app
3937 .oneshot(
3938 Request::builder()
3939 .uri("/api/v1/kg/prompt-context")
3940 .body(Body::empty())
3941 .unwrap(),
3942 )
3943 .await
3944 .unwrap();
3945 assert_eq!(resp.status(), StatusCode::OK);
3946 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3947 let text = String::from_utf8(bytes.to_vec()).unwrap();
3948 assert!(text.contains("tga → trusty-git-analytics"), "got: {text}");
3949 }
3950
3951 #[tokio::test]
3955 async fn add_alias_endpoint_asserts_triple_and_refreshes_cache() {
3956 let tmp = tempfile::tempdir().expect("tempdir");
3957 let root = tmp.path().to_path_buf();
3958 std::mem::forget(tmp);
3959 let state = AppState::new(root).with_default_palace(Some("aliases".to_string()));
3960 let palace = trusty_common::memory_core::Palace {
3961 id: PalaceId::new("aliases"),
3962 name: "aliases".to_string(),
3963 description: None,
3964 created_at: chrono::Utc::now(),
3965 data_dir: state.data_root.join("aliases"),
3966 };
3967 state
3968 .registry
3969 .create_palace(&state.data_root, palace)
3970 .expect("create palace");
3971
3972 let body = json!({"short": "tm", "full": "trusty-memory"});
3973 let app = router().with_state(state.clone());
3974 let resp = app
3975 .oneshot(
3976 Request::builder()
3977 .method("POST")
3978 .uri("/api/v1/kg/aliases")
3979 .header("content-type", "application/json")
3980 .body(Body::from(serde_json::to_vec(&body).unwrap()))
3981 .unwrap(),
3982 )
3983 .await
3984 .unwrap();
3985 assert_eq!(resp.status(), StatusCode::OK);
3986 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3987 let v: Value = serde_json::from_slice(&bytes).unwrap();
3988 assert_eq!(v["subject"], "tm");
3989 assert_eq!(v["object"], "trusty-memory");
3990
3991 let guard = state.prompt_context_cache.read().await;
3993 assert!(
3994 guard.formatted.contains("tm → trusty-memory"),
3995 "cache missing alias; got: {}",
3996 guard.formatted
3997 );
3998 }
3999
4000 #[tokio::test]
4004 async fn list_prompt_facts_endpoint_returns_hot_triples() {
4005 let tmp = tempfile::tempdir().expect("tempdir");
4006 let root = tmp.path().to_path_buf();
4007 std::mem::forget(tmp);
4008 let state = AppState::new(root).with_default_palace(Some("listfacts".to_string()));
4009 let palace = trusty_common::memory_core::Palace {
4010 id: PalaceId::new("listfacts"),
4011 name: "listfacts".to_string(),
4012 description: None,
4013 created_at: chrono::Utc::now(),
4014 data_dir: state.data_root.join("listfacts"),
4015 };
4016 let handle = state
4017 .registry
4018 .create_palace(&state.data_root, palace)
4019 .expect("create palace");
4020
4021 handle
4024 .kg
4025 .assert(Triple {
4026 subject: "ts".to_string(),
4027 predicate: "is_alias_for".to_string(),
4028 object: "trusty-search".to_string(),
4029 valid_from: chrono::Utc::now(),
4030 valid_to: None,
4031 confidence: 1.0,
4032 provenance: None,
4033 })
4034 .await
4035 .expect("assert alias");
4036 handle
4037 .kg
4038 .assert(Triple {
4039 subject: "alice".to_string(),
4040 predicate: "works_at".to_string(),
4041 object: "Acme".to_string(),
4042 valid_from: chrono::Utc::now(),
4043 valid_to: None,
4044 confidence: 1.0,
4045 provenance: None,
4046 })
4047 .await
4048 .expect("assert works_at");
4049
4050 let app = router().with_state(state);
4051 let resp = app
4052 .oneshot(
4053 Request::builder()
4054 .uri("/api/v1/kg/prompt-facts")
4055 .body(Body::empty())
4056 .unwrap(),
4057 )
4058 .await
4059 .unwrap();
4060 assert_eq!(resp.status(), StatusCode::OK);
4061 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4062 let v: Value = serde_json::from_slice(&bytes).unwrap();
4063 let arr = v.as_array().expect("array");
4064 assert!(
4065 arr.iter().any(|r| r["subject"] == "ts"
4066 && r["predicate"] == "is_alias_for"
4067 && r["object"] == "trusty-search"),
4068 "missing ts alias; got {arr:?}"
4069 );
4070 assert!(
4072 !arr.iter().any(|r| r["predicate"] == "works_at"),
4073 "non-hot triple leaked into prompt facts: {arr:?}"
4074 );
4075 }
4076
4077 #[tokio::test]
4080 async fn remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache() {
4081 let tmp = tempfile::tempdir().expect("tempdir");
4082 let root = tmp.path().to_path_buf();
4083 std::mem::forget(tmp);
4084 let state = AppState::new(root).with_default_palace(Some("rmfacts".to_string()));
4085 let palace = trusty_common::memory_core::Palace {
4086 id: PalaceId::new("rmfacts"),
4087 name: "rmfacts".to_string(),
4088 description: None,
4089 created_at: chrono::Utc::now(),
4090 data_dir: state.data_root.join("rmfacts"),
4091 };
4092 let handle = state
4093 .registry
4094 .create_palace(&state.data_root, palace)
4095 .expect("create palace");
4096
4097 handle
4098 .kg
4099 .assert(Triple {
4100 subject: "ta".to_string(),
4101 predicate: "is_alias_for".to_string(),
4102 object: "trusty-analyze".to_string(),
4103 valid_from: chrono::Utc::now(),
4104 valid_to: None,
4105 confidence: 1.0,
4106 provenance: None,
4107 })
4108 .await
4109 .expect("assert alias");
4110 crate::prompt_facts::rebuild_prompt_cache(&state)
4112 .await
4113 .expect("rebuild prompt cache");
4114
4115 let app = router().with_state(state.clone());
4116 let resp = app
4117 .oneshot(
4118 Request::builder()
4119 .method("DELETE")
4120 .uri("/api/v1/kg/prompt-facts?subject=ta&predicate=is_alias_for")
4121 .body(Body::empty())
4122 .unwrap(),
4123 )
4124 .await
4125 .unwrap();
4126 assert_eq!(resp.status(), StatusCode::OK);
4127 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4128 let v: Value = serde_json::from_slice(&bytes).unwrap();
4129 assert_eq!(v["removed"], true);
4130 assert!(v["closed"].as_u64().unwrap_or(0) >= 1);
4131
4132 {
4134 let guard = state.prompt_context_cache.read().await;
4135 assert!(
4136 !guard.formatted.contains("ta → trusty-analyze"),
4137 "alias still in cache after delete: {}",
4138 guard.formatted
4139 );
4140 }
4141
4142 let app = router().with_state(state);
4144 let resp = app
4145 .oneshot(
4146 Request::builder()
4147 .method("DELETE")
4148 .uri("/api/v1/kg/prompt-facts?subject=nope&predicate=is_alias_for")
4149 .body(Body::empty())
4150 .unwrap(),
4151 )
4152 .await
4153 .unwrap();
4154 assert_eq!(resp.status(), StatusCode::OK);
4155 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4156 let v: Value = serde_json::from_slice(&bytes).unwrap();
4157 assert_eq!(v["removed"], false);
4158 }
4159
4160 #[tokio::test]
4161 async fn serves_index_html_fallback() {
4162 let state = test_state();
4163 let app = router().with_state(state);
4164 let resp = app
4165 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4166 .await
4167 .unwrap();
4168 assert!(
4170 resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
4171 "got {}",
4172 resp.status()
4173 );
4174 }
4175
4176 #[tokio::test]
4187 async fn activity_endpoint_lists_recent_emits() {
4188 let state = test_state();
4189 state.emit(DaemonEvent::PalaceCreated {
4191 id: "alpha".into(),
4192 name: "alpha".into(),
4193 source: ActivitySource::Http,
4194 });
4195 state.emit(DaemonEvent::DrawerAdded {
4196 palace_id: "alpha".into(),
4197 palace_name: "alpha".into(),
4198 drawer_count: 1,
4199 timestamp: chrono::Utc::now(),
4200 content_preview: "hello".into(),
4201 source: ActivitySource::Mcp,
4202 });
4203 state.emit(DaemonEvent::DrawerAdded {
4204 palace_id: "beta".into(),
4205 palace_name: "beta".into(),
4206 drawer_count: 1,
4207 timestamp: chrono::Utc::now(),
4208 content_preview: "hi there".into(),
4209 source: ActivitySource::Http,
4210 });
4211 state.emit(DaemonEvent::DrawerDeleted {
4212 palace_id: "alpha".into(),
4213 drawer_count: 0,
4214 source: ActivitySource::Http,
4215 });
4216 state.flush_activity_writes().await;
4220
4221 let app = router().with_state(state);
4222 let resp = app
4223 .oneshot(
4224 Request::builder()
4225 .uri("/api/v1/activity?limit=10")
4226 .body(Body::empty())
4227 .unwrap(),
4228 )
4229 .await
4230 .unwrap();
4231 assert_eq!(resp.status(), StatusCode::OK);
4232 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4233 let v: Value = serde_json::from_slice(&bytes).unwrap();
4234 assert_eq!(v["limit"], 10);
4235 assert_eq!(v["offset"], 0);
4236 assert_eq!(v["total"], 4);
4237 let entries = v["entries"].as_array().expect("entries array");
4238 assert_eq!(entries.len(), 4);
4239 assert_eq!(entries[0]["event_type"], "drawer_deleted");
4241 assert_eq!(entries[3]["event_type"], "palace_created");
4242 let sources: Vec<&str> = entries
4244 .iter()
4245 .filter_map(|e| e["source"].as_str())
4246 .collect();
4247 assert!(sources.contains(&"http"));
4248 assert!(sources.contains(&"mcp"));
4249 assert!(entries[0]["payload"].is_object());
4251 }
4252
4253 #[tokio::test]
4259 async fn activity_endpoint_clamps_limit() {
4260 let state = test_state();
4261 let app = router().with_state(state);
4262 let resp = app
4263 .oneshot(
4264 Request::builder()
4265 .uri("/api/v1/activity?limit=10000")
4266 .body(Body::empty())
4267 .unwrap(),
4268 )
4269 .await
4270 .unwrap();
4271 assert_eq!(resp.status(), StatusCode::OK);
4272 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4273 let v: Value = serde_json::from_slice(&bytes).unwrap();
4274 assert_eq!(v["limit"], ACTIVITY_MAX_LIMIT);
4275 }
4276
4277 #[tokio::test]
4284 async fn activity_endpoint_filters_by_source_and_palace() {
4285 let state = test_state();
4286 state.emit(DaemonEvent::DrawerAdded {
4287 palace_id: "alpha".into(),
4288 palace_name: "alpha".into(),
4289 drawer_count: 1,
4290 timestamp: chrono::Utc::now(),
4291 content_preview: "".into(),
4292 source: ActivitySource::Mcp,
4293 });
4294 state.emit(DaemonEvent::DrawerAdded {
4295 palace_id: "alpha".into(),
4296 palace_name: "alpha".into(),
4297 drawer_count: 2,
4298 timestamp: chrono::Utc::now(),
4299 content_preview: "".into(),
4300 source: ActivitySource::Http,
4301 });
4302 state.emit(DaemonEvent::DrawerAdded {
4303 palace_id: "beta".into(),
4304 palace_name: "beta".into(),
4305 drawer_count: 1,
4306 timestamp: chrono::Utc::now(),
4307 content_preview: "".into(),
4308 source: ActivitySource::Mcp,
4309 });
4310 state.flush_activity_writes().await;
4312
4313 let app = router().with_state(state);
4314 let resp = app
4315 .oneshot(
4316 Request::builder()
4317 .uri("/api/v1/activity?palace=alpha&source=mcp&limit=50")
4318 .body(Body::empty())
4319 .unwrap(),
4320 )
4321 .await
4322 .unwrap();
4323 assert_eq!(resp.status(), StatusCode::OK);
4324 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4325 let v: Value = serde_json::from_slice(&bytes).unwrap();
4326 let entries = v["entries"].as_array().unwrap();
4327 assert_eq!(entries.len(), 1, "filter should leave one row, got {v}");
4328 assert_eq!(entries[0]["palace_id"], "alpha");
4329 assert_eq!(entries[0]["source"], "mcp");
4330 }
4331
4332 #[tokio::test]
4335 async fn activity_endpoint_rejects_unknown_source() {
4336 let state = test_state();
4337 let app = router().with_state(state);
4338 let resp = app
4339 .oneshot(
4340 Request::builder()
4341 .uri("/api/v1/activity?source=nope")
4342 .body(Body::empty())
4343 .unwrap(),
4344 )
4345 .await
4346 .unwrap();
4347 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
4348 }
4349
4350 #[tokio::test]
4358 async fn mcp_memory_remember_emits_drawer_added_with_mcp_source() {
4359 use crate::tools::dispatch_tool;
4360 let state = test_state();
4361 let mut rx = state.events.subscribe();
4362 let _ = dispatch_tool(&state, "palace_create", json!({"name": "p1"}))
4365 .await
4366 .expect("palace_create");
4367 let first = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
4369 .await
4370 .expect("first event")
4371 .expect("channel open");
4372 assert!(
4373 matches!(first, DaemonEvent::PalaceCreated { ref source, .. } if *source == ActivitySource::Mcp)
4374 );
4375
4376 let _ = dispatch_tool(
4377 &state,
4378 "memory_remember",
4379 json!({
4380 "palace": "p1",
4381 "text": "the quick brown fox jumps over the lazy dog and more"
4382 }),
4383 )
4384 .await
4385 .expect("memory_remember");
4386
4387 let next = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
4389 .await
4390 .expect("drawer_added event")
4391 .expect("channel open");
4392 match next {
4393 DaemonEvent::DrawerAdded {
4394 source, palace_id, ..
4395 } => {
4396 assert_eq!(source, ActivitySource::Mcp);
4397 assert_eq!(palace_id, "p1");
4398 }
4399 other => panic!("expected DrawerAdded, got {other:?}"),
4400 }
4401
4402 state.flush_activity_writes().await;
4407 let app = router().with_state(state);
4408 let resp = app
4409 .oneshot(
4410 Request::builder()
4411 .uri("/api/v1/activity?source=mcp&limit=10")
4412 .body(Body::empty())
4413 .unwrap(),
4414 )
4415 .await
4416 .unwrap();
4417 assert_eq!(resp.status(), StatusCode::OK);
4418 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4419 let v: Value = serde_json::from_slice(&bytes).unwrap();
4420 let entries = v["entries"].as_array().unwrap();
4421 let event_types: std::collections::HashSet<&str> = entries
4422 .iter()
4423 .filter_map(|e| e["event_type"].as_str())
4424 .collect();
4425 assert!(event_types.contains("drawer_added"));
4426 assert!(event_types.contains("palace_created"));
4427 }
4428
4429 #[tokio::test]
4444 async fn hook_fired_activity_emit_smoke() {
4445 let state = test_state();
4446 let app = router().with_state(state.clone());
4447
4448 let payload = serde_json::json!({
4449 "palace_id": "alpha",
4450 "palace_name": "alpha",
4451 "hook_type": "UserPromptSubmit",
4452 "injection_kind": "prompt-context",
4453 "injection_length": 256,
4454 "trigger_prompt_excerpt": "test prompt",
4455 "duration_ms": 12,
4456 });
4457 let resp = app
4458 .oneshot(
4459 Request::builder()
4460 .method("POST")
4461 .uri("/api/v1/activity/hook")
4462 .header("content-type", "application/json")
4463 .body(Body::from(payload.to_string()))
4464 .unwrap(),
4465 )
4466 .await
4467 .unwrap();
4468 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
4469 state.flush_activity_writes().await;
4473
4474 let app = router().with_state(state);
4476 let resp = app
4477 .oneshot(
4478 Request::builder()
4479 .uri("/api/v1/activity?source=hook&limit=10")
4480 .body(Body::empty())
4481 .unwrap(),
4482 )
4483 .await
4484 .unwrap();
4485 assert_eq!(resp.status(), StatusCode::OK);
4486 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4487 let v: Value = serde_json::from_slice(&bytes).unwrap();
4488 let entries = v["entries"].as_array().expect("entries array");
4489 assert!(
4490 !entries.is_empty(),
4491 "expected at least one hook activity row, got {entries:?}"
4492 );
4493 let first = &entries[0];
4494 assert_eq!(first["source"], "hook");
4495 assert_eq!(first["event_type"], "hook_fired");
4496 assert_eq!(first["palace_id"], "alpha");
4497 let body = &first["payload"];
4498 assert_eq!(body["hook_type"], "UserPromptSubmit");
4499 assert_eq!(body["injection_kind"], "prompt-context");
4500 }
4501
4502 #[tokio::test]
4512 async fn drawer_creator_attribution_http_default() {
4513 let tmp = tempfile::tempdir().expect("tempdir");
4514 let root = tmp.path().to_path_buf();
4515 std::mem::forget(tmp);
4516 let state = AppState::new(root);
4517 let palace = trusty_common::memory_core::Palace {
4518 id: PalaceId::new("cred-default"),
4519 name: "cred-default".to_string(),
4520 description: None,
4521 created_at: chrono::Utc::now(),
4522 data_dir: state.data_root.join("cred-default"),
4523 };
4524 state
4525 .registry
4526 .create_palace(&state.data_root, palace)
4527 .expect("create palace");
4528
4529 let app = router().with_state(state.clone());
4530 let body = serde_json::json!({
4531 "content": "hello world from anonymous client",
4532 "tags": ["user-tag"],
4533 });
4534 let resp = app
4535 .oneshot(
4536 Request::builder()
4537 .method("POST")
4538 .uri("/api/v1/palaces/cred-default/drawers")
4539 .header("content-type", "application/json")
4540 .body(Body::from(body.to_string()))
4541 .unwrap(),
4542 )
4543 .await
4544 .unwrap();
4545 assert_eq!(resp.status(), StatusCode::OK);
4546
4547 let app = router().with_state(state);
4549 let resp = app
4550 .oneshot(
4551 Request::builder()
4552 .uri("/api/v1/palaces/cred-default/drawers?limit=10")
4553 .body(Body::empty())
4554 .unwrap(),
4555 )
4556 .await
4557 .unwrap();
4558 assert_eq!(resp.status(), StatusCode::OK);
4559 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4560 let v: Value = serde_json::from_slice(&bytes).unwrap();
4561 let drawers = v.as_array().expect("drawers array");
4562 assert_eq!(drawers.len(), 1, "expected one drawer, got {drawers:?}");
4563 let tags: Vec<&str> = drawers[0]["tags"]
4564 .as_array()
4565 .expect("tags array")
4566 .iter()
4567 .filter_map(|t| t.as_str())
4568 .collect();
4569 assert!(
4570 tags.contains(&"user-tag"),
4571 "user-supplied tag must survive; got {tags:?}"
4572 );
4573 assert!(
4574 tags.contains(&"creator:client=unknown-http-client"),
4575 "expected default client tag; got {tags:?}"
4576 );
4577 assert!(
4578 tags.contains(&"creator:source=http"),
4579 "expected http source tag; got {tags:?}"
4580 );
4581 assert!(
4582 tags.iter().any(|t| t.starts_with("creator:version=")),
4583 "expected creator:version tag; got {tags:?}"
4584 );
4585 }
4586
4587 #[tokio::test]
4595 async fn drawer_creator_attribution_http_header() {
4596 let tmp = tempfile::tempdir().expect("tempdir");
4597 let root = tmp.path().to_path_buf();
4598 std::mem::forget(tmp);
4599 let state = AppState::new(root);
4600 let palace = trusty_common::memory_core::Palace {
4601 id: PalaceId::new("cred-header"),
4602 name: "cred-header".to_string(),
4603 description: None,
4604 created_at: chrono::Utc::now(),
4605 data_dir: state.data_root.join("cred-header"),
4606 };
4607 state
4608 .registry
4609 .create_palace(&state.data_root, palace)
4610 .expect("create palace");
4611
4612 let app = router().with_state(state.clone());
4613 let body = serde_json::json!({
4614 "content": "this is enough content to pass the signal/noise filter applied by remember",
4615 "tags": [],
4616 });
4617 let resp = app
4618 .oneshot(
4619 Request::builder()
4620 .method("POST")
4621 .uri("/api/v1/palaces/cred-header/drawers")
4622 .header("content-type", "application/json")
4623 .header("x-trusty-client-name", "qa-curl")
4624 .header("x-trusty-client-cwd", "/tmp/qa")
4625 .body(Body::from(body.to_string()))
4626 .unwrap(),
4627 )
4628 .await
4629 .unwrap();
4630 assert_eq!(resp.status(), StatusCode::OK);
4631
4632 let app = router().with_state(state);
4633 let resp = app
4634 .oneshot(
4635 Request::builder()
4636 .uri("/api/v1/palaces/cred-header/drawers?limit=10")
4637 .body(Body::empty())
4638 .unwrap(),
4639 )
4640 .await
4641 .unwrap();
4642 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4643 let v: Value = serde_json::from_slice(&bytes).unwrap();
4644 let tags: Vec<&str> = v[0]["tags"]
4645 .as_array()
4646 .expect("tags")
4647 .iter()
4648 .filter_map(|t| t.as_str())
4649 .collect();
4650 assert!(
4651 tags.contains(&"creator:client=qa-curl"),
4652 "expected custom client tag; got {tags:?}"
4653 );
4654 assert!(
4655 tags.contains(&"creator:cwd=/tmp/qa"),
4656 "expected cwd tag from header; got {tags:?}"
4657 );
4658 }
4659
4660 #[tokio::test]
4669 async fn drawer_creator_attribution_mcp_default() {
4670 let tmp = tempfile::tempdir().expect("tempdir");
4671 let root = tmp.path().to_path_buf();
4672 std::mem::forget(tmp);
4673 let state = AppState::new(root);
4674 let palace = trusty_common::memory_core::Palace {
4675 id: PalaceId::new("cred-mcp"),
4676 name: "cred-mcp".to_string(),
4677 description: None,
4678 created_at: chrono::Utc::now(),
4679 data_dir: state.data_root.join("cred-mcp"),
4680 };
4681 state
4682 .registry
4683 .create_palace(&state.data_root, palace)
4684 .expect("create palace");
4685
4686 let _ = crate::tools::dispatch_tool(
4687 &state,
4688 "memory_remember",
4689 json!({
4690 "palace": "cred-mcp",
4691 "text": "remember a sentence with enough tokens to pass filters please",
4692 "room": "General",
4693 "tags": ["from-test"],
4694 }),
4695 )
4696 .await
4697 .expect("memory_remember dispatch");
4698
4699 let app = router().with_state(state);
4700 let resp = app
4701 .oneshot(
4702 Request::builder()
4703 .uri("/api/v1/palaces/cred-mcp/drawers?limit=10")
4704 .body(Body::empty())
4705 .unwrap(),
4706 )
4707 .await
4708 .unwrap();
4709 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4710 let v: Value = serde_json::from_slice(&bytes).unwrap();
4711 let drawers = v.as_array().expect("drawers array");
4712 assert!(!drawers.is_empty(), "expected at least one drawer");
4713 let tags: Vec<&str> = drawers[0]["tags"]
4714 .as_array()
4715 .expect("tags array")
4716 .iter()
4717 .filter_map(|t| t.as_str())
4718 .collect();
4719 assert!(
4720 tags.contains(&"creator:client=trusty-memory-mcp"),
4721 "expected MCP client tag; got {tags:?}"
4722 );
4723 assert!(
4724 tags.contains(&"creator:source=mcp"),
4725 "expected MCP source tag; got {tags:?}"
4726 );
4727 }
4728
4729 #[tokio::test]
4740 async fn hook_emit_failure_isolated() {
4741 let _guard = crate::commands::env_test_lock().lock().await;
4742 let tmp = tempfile::tempdir().expect("tempdir");
4743 unsafe {
4745 std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
4746 }
4747 let res = crate::commands::prompt_context::handle_prompt_context().await;
4748 unsafe {
4749 std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
4750 }
4751 assert!(
4752 res.is_ok(),
4753 "hook must complete even when daemon emit fails; got {res:?}"
4754 );
4755 }
4756
4757 #[test]
4765 fn decode_triple_id_round_trips() {
4766 let cases = [
4767 ("drawer:some-uuid", "has_tag"),
4768 ("entity:alice", "works_at"),
4769 ("entity:project/foo", "depends_on"),
4770 ("subject", ""),
4772 ("path/to/node", "rel:type:sub"),
4774 ];
4775 for (subject, predicate) in cases {
4776 let encoded = encode_triple_id(subject, predicate);
4777 assert!(
4779 !encoded.contains('+') && !encoded.contains('/') && !encoded.contains('='),
4780 "encoded triple id {encoded:?} is not URL-safe"
4781 );
4782 let (s, p) = decode_triple_id(&encoded)
4783 .unwrap_or_else(|| panic!("decode_triple_id failed for {encoded:?}"));
4784 assert_eq!(s, subject, "subject mismatch for ({subject}, {predicate})");
4785 assert_eq!(
4786 p, predicate,
4787 "predicate mismatch for ({subject}, {predicate})"
4788 );
4789 }
4790 }
4791
4792 #[test]
4796 fn decode_triple_id_returns_none_for_invalid_input() {
4797 assert!(decode_triple_id("not!!valid%%base64").is_none());
4798 use base64::Engine as _;
4800 let no_sep = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"no-separator");
4801 assert!(decode_triple_id(&no_sep).is_none());
4802 }
4803}