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 unsafe {
1760 std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
1761 }
1762 AppState::new(root)
1763 }
1764
1765 #[test]
1766 fn drawer_preview_collapses_whitespace_and_truncates() {
1767 assert_eq!(drawer_content_preview("hello world"), "hello world");
1769
1770 assert_eq!(
1772 drawer_content_preview("first line\n\nsecond\tline third"),
1773 "first line second line third"
1774 );
1775
1776 assert_eq!(drawer_content_preview(" padded "), "padded");
1778
1779 assert_eq!(drawer_content_preview(""), "");
1781
1782 let long = "x".repeat(DRAWER_PREVIEW_MAX_CHARS + 50);
1784 let preview = drawer_content_preview(&long);
1785 assert_eq!(preview.chars().count(), DRAWER_PREVIEW_MAX_CHARS);
1786 assert!(preview.ends_with('…'));
1787
1788 let exact = "y".repeat(DRAWER_PREVIEW_MAX_CHARS);
1790 assert_eq!(drawer_content_preview(&exact), exact);
1791 }
1792
1793 #[tokio::test]
1804 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1805 async fn health_endpoint_returns_ok() {
1806 let state = test_state();
1807 let app = router().with_state(state);
1808 let resp = app
1809 .oneshot(
1810 Request::builder()
1811 .uri("/health")
1812 .body(Body::empty())
1813 .unwrap(),
1814 )
1815 .await
1816 .unwrap();
1817 assert_eq!(resp.status(), StatusCode::OK);
1818 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1819 let v: Value = serde_json::from_slice(&bytes).unwrap();
1820 assert_eq!(v["status"], "ok");
1821 assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
1822 }
1823
1824 #[tokio::test]
1836 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1837 async fn health_endpoint_includes_resource_fields() {
1838 let state = test_state();
1839 let app = router().with_state(state);
1840 let resp = app
1841 .oneshot(
1842 Request::builder()
1843 .uri("/health")
1844 .body(Body::empty())
1845 .unwrap(),
1846 )
1847 .await
1848 .unwrap();
1849 assert_eq!(resp.status(), StatusCode::OK);
1850 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1851 let v: Value = serde_json::from_slice(&bytes).unwrap();
1852 let rss_mb = v["rss_mb"].as_u64().expect("rss_mb is u64");
1854 assert!(rss_mb < 1024 * 1024, "rss_mb unit must be MB");
1855 let cpu = v["cpu_pct"].as_f64().expect("cpu_pct is a number");
1857 assert!(cpu >= 0.0, "cpu_pct must be non-negative");
1858 assert_eq!(v["disk_bytes"].as_u64(), Some(0));
1860 assert!(v["uptime_secs"].is_u64(), "uptime_secs must be present");
1862 }
1863
1864 #[tokio::test]
1880 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1881 async fn health_endpoint_round_trip_on_fresh_install_is_ok() {
1882 let state = test_state();
1883 let app = router().with_state(state);
1884 let resp = app
1885 .oneshot(
1886 Request::builder()
1887 .uri("/health")
1888 .body(Body::empty())
1889 .unwrap(),
1890 )
1891 .await
1892 .unwrap();
1893 assert_eq!(resp.status(), StatusCode::OK);
1894 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1895 let v: Value = serde_json::from_slice(&bytes).unwrap();
1896 assert_eq!(v["status"], "ok");
1897 assert!(
1898 v.get("detail").is_none() || v["detail"].is_null(),
1899 "fresh-install health must not carry a degraded detail (got {v:?})"
1900 );
1901 }
1902
1903 #[tokio::test]
1919 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1920 async fn health_endpoint_round_trip_with_palace_is_ok() {
1921 let state = test_state();
1922 let palace = trusty_common::memory_core::Palace {
1923 id: PalaceId::new("health-probe-palace"),
1924 name: "health-probe-palace".to_string(),
1925 description: None,
1926 created_at: chrono::Utc::now(),
1927 data_dir: state.data_root.join("health-probe-palace"),
1928 };
1929 state
1930 .registry
1931 .create_palace(&state.data_root, palace)
1932 .expect("create_palace");
1933
1934 let app = router().with_state(state);
1935 let resp = app
1936 .oneshot(
1937 Request::builder()
1938 .uri("/health")
1939 .body(Body::empty())
1940 .unwrap(),
1941 )
1942 .await
1943 .unwrap();
1944 assert_eq!(resp.status(), StatusCode::OK);
1945 let bytes = to_bytes(resp.into_body(), 2048).await.unwrap();
1946 let v: Value = serde_json::from_slice(&bytes).unwrap();
1947 assert_eq!(
1948 v["status"], "ok",
1949 "round-trip should succeed against a fresh palace; got {v:?}"
1950 );
1951 assert!(
1952 v.get("detail").is_none() || v["detail"].is_null(),
1953 "successful round-trip must not carry a detail field (got {v:?})"
1954 );
1955 }
1956
1957 #[tokio::test]
1970 async fn health_probe_palace_is_invisible() {
1971 let state = test_state();
1972 ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
1973
1974 assert!(
1976 state.data_root.join(HEALTH_PROBE_PALACE).exists(),
1977 "probe palace directory should be persisted on disk"
1978 );
1979
1980 let service = crate::service::MemoryService::new(state);
1981 let listed = service.list_palaces().await.expect("list_palaces");
1982 assert!(
1983 listed.iter().all(|p| !p.id.starts_with("__")),
1984 "no `__`-prefixed palace may appear in the user-facing list; got {:?}",
1985 listed.iter().map(|p| &p.id).collect::<Vec<_>>()
1986 );
1987 assert!(
1988 !listed.iter().any(|p| p.id == HEALTH_PROBE_PALACE),
1989 "the dedicated `__health_probe__` palace must be invisible; got {:?}",
1990 listed.iter().map(|p| &p.id).collect::<Vec<_>>()
1991 );
1992 }
1993
1994 #[tokio::test]
2009 async fn health_probe_cleans_up_on_success() {
2010 use trusty_common::memory_core::Drawer;
2011
2012 let state = test_state();
2013 ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2014 let handle = state
2015 .registry
2016 .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2017 .expect("open probe palace");
2018
2019 let result = run_health_round_trip_inner(handle.clone(), move |h, _query| async move {
2020 let drawers = h.drawers.read();
2023 let last = drawers
2024 .last()
2025 .cloned()
2026 .unwrap_or_else(|| Drawer::new(Uuid::new_v4(), "stub"));
2027 drop(drawers);
2028 Ok(vec![RecallResult {
2029 drawer: last,
2030 score: 1.0,
2031 layer: 1,
2032 }])
2033 })
2034 .await;
2035 assert!(
2036 result.is_ok(),
2037 "successful round-trip should return Ok; got {result:?}"
2038 );
2039
2040 let drawer_count = handle.drawers.read().len();
2041 assert_eq!(
2042 drawer_count, 0,
2043 "probe palace must have zero drawers after a successful round-trip (got {drawer_count})"
2044 );
2045 }
2046
2047 #[tokio::test]
2061 async fn health_probe_cleans_up_on_recall_miss() {
2062 let state = test_state();
2063 ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2064 let handle = state
2065 .registry
2066 .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2067 .expect("open probe palace");
2068
2069 let result = run_health_round_trip_inner(handle.clone(), |_h, _q| async move {
2070 Ok(Vec::new())
2072 })
2073 .await;
2074 assert!(
2075 matches!(result, Err(HealthProbeError::ProbeMissing(_))),
2076 "recall miss must surface as ProbeMissing; got {result:?}"
2077 );
2078
2079 let drawer_count = handle.drawers.read().len();
2080 assert_eq!(
2081 drawer_count, 0,
2082 "probe palace must be empty after a recall miss (got {drawer_count})"
2083 );
2084 }
2085
2086 #[tokio::test]
2099 async fn health_probe_cleans_up_on_recall_error() {
2100 let state = test_state();
2101 ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2102 let handle = state
2103 .registry
2104 .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2105 .expect("open probe palace");
2106
2107 let result = run_health_round_trip_inner(handle.clone(), |_h, _q| async move {
2108 Err(HealthProbeError::Recall("simulated failure".to_string()))
2109 })
2110 .await;
2111 assert!(
2112 matches!(result, Err(HealthProbeError::Recall(_))),
2113 "recall error must surface as Recall; got {result:?}"
2114 );
2115
2116 let drawer_count = handle.drawers.read().len();
2117 assert_eq!(
2118 drawer_count, 0,
2119 "probe palace must be empty after a recall error (got {drawer_count})"
2120 );
2121 }
2122
2123 #[test]
2136 fn recall_entry_json_hoists_drawer_fields() {
2137 use trusty_common::memory_core::Drawer;
2138
2139 let room = Uuid::new_v4();
2140 let mut drawer = Drawer::new(room, "the answer is 42");
2141 drawer.tags = vec!["source:kuzu".to_string()];
2142 drawer.importance = 0.7;
2143
2144 let entry = recall_entry_json(RecallResult {
2145 drawer,
2146 score: 0.699,
2147 layer: 1,
2148 });
2149
2150 assert_eq!(
2152 entry.get("content").and_then(|v| v.as_str()),
2153 Some("the answer is 42"),
2154 "content must be at the top level, got {entry:?}"
2155 );
2156 assert!(
2157 entry.get("drawer").is_none(),
2158 "the legacy `drawer` wrapper must not be present, got {entry:?}"
2159 );
2160 assert_eq!(
2162 entry["importance"].as_f64().map(|f| (f * 10.0).round()),
2163 Some(7.0)
2164 );
2165 assert_eq!(
2166 entry["tags"][0].as_str(),
2167 Some("source:kuzu"),
2168 "tags must be hoisted, got {entry:?}"
2169 );
2170 assert_eq!(entry["layer"].as_u64(), Some(1));
2172 assert!(
2173 entry["score"]
2174 .as_f64()
2175 .is_some_and(|s| (s - 0.699).abs() < 1e-6),
2176 "score must be preserved, got {entry:?}"
2177 );
2178 }
2179
2180 #[tokio::test]
2189 async fn logs_tail_returns_recent_lines() {
2190 let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2191 buffer.push("line one".to_string());
2192 buffer.push("line two".to_string());
2193 buffer.push("line three".to_string());
2194 let state = test_state().with_log_buffer(buffer);
2195 let app = router().with_state(state);
2196 let resp = app
2197 .oneshot(
2198 Request::builder()
2199 .uri("/api/v1/logs/tail?n=2")
2200 .body(Body::empty())
2201 .unwrap(),
2202 )
2203 .await
2204 .unwrap();
2205 assert_eq!(resp.status(), StatusCode::OK);
2206 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2207 let v: Value = serde_json::from_slice(&bytes).unwrap();
2208 let lines = v["lines"].as_array().expect("lines array");
2209 assert_eq!(lines.len(), 2, "n=2 must return two lines");
2210 assert_eq!(lines[0].as_str(), Some("line two"));
2211 assert_eq!(lines[1].as_str(), Some("line three"));
2212 assert_eq!(v["total"].as_u64(), Some(3));
2213 }
2214
2215 #[tokio::test]
2224 async fn logs_tail_clamps_n() {
2225 let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2226 for i in 0..5 {
2227 buffer.push(format!("l{i}"));
2228 }
2229 let state = test_state().with_log_buffer(buffer);
2230 let app = router().with_state(state);
2231
2232 let resp = app
2234 .clone()
2235 .oneshot(
2236 Request::builder()
2237 .uri("/api/v1/logs/tail?n=0")
2238 .body(Body::empty())
2239 .unwrap(),
2240 )
2241 .await
2242 .unwrap();
2243 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2244 let v: Value = serde_json::from_slice(&bytes).unwrap();
2245 assert_eq!(v["lines"].as_array().expect("lines").len(), 1);
2246
2247 let resp = app
2249 .oneshot(
2250 Request::builder()
2251 .uri("/api/v1/logs/tail?n=999999")
2252 .body(Body::empty())
2253 .unwrap(),
2254 )
2255 .await
2256 .unwrap();
2257 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2258 let v: Value = serde_json::from_slice(&bytes).unwrap();
2259 assert_eq!(v["lines"].as_array().expect("lines").len(), 5);
2260 }
2261
2262 #[tokio::test]
2273 async fn admin_stop_returns_ok() {
2274 let state = test_state();
2275 let Json(body) = admin_stop(State(state)).await;
2276 assert_eq!(body["ok"], Value::Bool(true));
2277 assert_eq!(body["message"].as_str(), Some("shutting down"));
2278 }
2279
2280 #[tokio::test]
2281 async fn status_endpoint_returns_payload() {
2282 let state = test_state();
2283 let app = router().with_state(state);
2284 let resp = app
2285 .oneshot(
2286 Request::builder()
2287 .uri("/api/v1/status")
2288 .body(Body::empty())
2289 .unwrap(),
2290 )
2291 .await
2292 .unwrap();
2293 assert_eq!(resp.status(), StatusCode::OK);
2294 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2295 let v: Value = serde_json::from_slice(&bytes).unwrap();
2296 assert!(v["version"].is_string());
2297 assert_eq!(v["palace_count"], 0);
2298 }
2299
2300 #[tokio::test]
2301 async fn unknown_api_returns_404() {
2302 let state = test_state();
2303 let app = router().with_state(state);
2304 let resp = app
2305 .oneshot(
2306 Request::builder()
2307 .uri("/api/v1/does-not-exist")
2308 .body(Body::empty())
2309 .unwrap(),
2310 )
2311 .await
2312 .unwrap();
2313 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2314 }
2315
2316 #[tokio::test]
2327 async fn memories_alias_routes_to_drawers() {
2328 let state = test_state();
2329 let palace = Palace {
2330 id: PalaceId::new("alias-test"),
2331 name: "alias-test".to_string(),
2332 description: None,
2333 created_at: chrono::Utc::now(),
2334 data_dir: state.data_root.join("alias-test"),
2335 };
2336 state
2337 .registry
2338 .create_palace(&state.data_root, palace)
2339 .expect("create_palace");
2340
2341 let app = router().with_state(state);
2342 let resp = app
2343 .oneshot(
2344 Request::builder()
2345 .uri("/api/v1/palaces/alias-test/memories")
2346 .body(Body::empty())
2347 .unwrap(),
2348 )
2349 .await
2350 .unwrap();
2351 assert_eq!(
2352 resp.status(),
2353 StatusCode::OK,
2354 "the /memories alias must resolve to list_drawers, not 404"
2355 );
2356 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2357 let v: Value = serde_json::from_slice(&bytes).unwrap();
2358 assert!(
2359 v.is_array(),
2360 "the alias must return the list-drawers array shape, got {v:?}"
2361 );
2362 }
2363
2364 #[tokio::test]
2378 async fn http_create_drawer_runs_auto_kg_extraction() {
2379 let state = test_state();
2380 let palace = Palace {
2381 id: PalaceId::new("kgauto-http"),
2382 name: "kgauto-http".to_string(),
2383 description: None,
2384 created_at: chrono::Utc::now(),
2385 data_dir: state.data_root.join("kgauto-http"),
2386 };
2387 state
2388 .registry
2389 .create_palace(&state.data_root, palace)
2390 .expect("create_palace");
2391
2392 let app = router().with_state(state.clone());
2393 let body = json!({
2397 "content": "trusty-memory is a Rust crate that ships an MCP server. \
2398 It tracks #mcp and #rust topics with care.",
2399 "room": "Backend",
2400 "tags": ["backend", "kg"],
2401 "importance": 0.5,
2402 })
2403 .to_string();
2404 let resp = app
2405 .clone()
2406 .oneshot(
2407 Request::builder()
2408 .method("POST")
2409 .uri("/api/v1/palaces/kgauto-http/drawers")
2410 .header("content-type", "application/json")
2411 .body(Body::from(body))
2412 .unwrap(),
2413 )
2414 .await
2415 .unwrap();
2416 assert_eq!(
2417 resp.status(),
2418 StatusCode::OK,
2419 "create_drawer must return 200 OK"
2420 );
2421
2422 let resp = app
2427 .oneshot(
2428 Request::builder()
2429 .uri("/api/v1/palaces/kgauto-http/kg/graph")
2430 .body(Body::empty())
2431 .unwrap(),
2432 )
2433 .await
2434 .unwrap();
2435 assert_eq!(resp.status(), StatusCode::OK);
2436 let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
2437 let v: Value = serde_json::from_slice(&bytes).unwrap();
2438 let triples = v["triples"].as_array().expect("triples array");
2439 assert!(
2440 !triples.is_empty(),
2441 "HTTP-origin drawer must populate the KG; got empty graph"
2442 );
2443 let auto: Vec<&Value> = triples
2444 .iter()
2445 .filter(|t| t["provenance"].as_str() == Some(crate::kg_extract::AUTO_PROVENANCE))
2446 .collect();
2447 assert!(
2448 !auto.is_empty(),
2449 "expected at least one auto-extracted triple in HTTP-populated KG; got: {triples:?}"
2450 );
2451 assert!(
2456 auto.iter()
2457 .any(|t| t["subject"].as_str() == Some("tag:backend")),
2458 "expected `tag:backend` auto-extracted edge, got: {auto:?}"
2459 );
2460 assert!(
2462 auto.iter()
2463 .any(|t| t["predicate"].as_str() == Some("mentioned-in")),
2464 "expected at least one #hashtag mention triple, got: {auto:?}"
2465 );
2466 }
2467
2468 #[tokio::test]
2469 async fn create_then_list_palace() {
2470 let state = test_state();
2471 let app = router().with_state(state.clone());
2472 let body = json!({"name": "web-test", "description": "from test"}).to_string();
2473 let resp = app
2474 .clone()
2475 .oneshot(
2476 Request::builder()
2477 .method("POST")
2478 .uri("/api/v1/palaces")
2479 .header("content-type", "application/json")
2480 .body(Body::from(body))
2481 .unwrap(),
2482 )
2483 .await
2484 .unwrap();
2485 assert_eq!(resp.status(), StatusCode::OK);
2486
2487 let resp = app
2488 .oneshot(
2489 Request::builder()
2490 .uri("/api/v1/palaces")
2491 .body(Body::empty())
2492 .unwrap(),
2493 )
2494 .await
2495 .unwrap();
2496 assert_eq!(resp.status(), StatusCode::OK);
2497 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2498 let v: Value = serde_json::from_slice(&bytes).unwrap();
2499 let arr = v.as_array().expect("array");
2500 assert!(arr.iter().any(|p| p["id"] == "web-test"));
2501 }
2502
2503 #[tokio::test]
2511 async fn delete_palace_removes_dir_when_empty() {
2512 let state = test_state();
2513 let app = router().with_state(state.clone());
2514 let body = json!({"name": "to-delete"}).to_string();
2515 let resp = app
2516 .clone()
2517 .oneshot(
2518 Request::builder()
2519 .method("POST")
2520 .uri("/api/v1/palaces")
2521 .header("content-type", "application/json")
2522 .body(Body::from(body))
2523 .unwrap(),
2524 )
2525 .await
2526 .unwrap();
2527 assert_eq!(resp.status(), StatusCode::OK);
2528
2529 let resp = app
2530 .clone()
2531 .oneshot(
2532 Request::builder()
2533 .method("DELETE")
2534 .uri("/api/v1/palaces/to-delete")
2535 .body(Body::empty())
2536 .unwrap(),
2537 )
2538 .await
2539 .unwrap();
2540 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2541
2542 let resp = app
2544 .oneshot(
2545 Request::builder()
2546 .uri("/api/v1/palaces/to-delete")
2547 .body(Body::empty())
2548 .unwrap(),
2549 )
2550 .await
2551 .unwrap();
2552 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2553
2554 let palace_dir = state.data_root.join("to-delete");
2556 assert!(
2557 !palace_dir.exists(),
2558 "palace dir should be removed: {}",
2559 palace_dir.display()
2560 );
2561 }
2562
2563 #[tokio::test]
2571 async fn delete_palace_refuses_when_drawers_present() {
2572 let state = test_state();
2573 let app = router().with_state(state.clone());
2574 let resp = app
2576 .clone()
2577 .oneshot(
2578 Request::builder()
2579 .method("POST")
2580 .uri("/api/v1/palaces")
2581 .header("content-type", "application/json")
2582 .body(Body::from(json!({"name": "keep-me"}).to_string()))
2583 .unwrap(),
2584 )
2585 .await
2586 .unwrap();
2587 assert_eq!(resp.status(), StatusCode::OK);
2588 let resp = app
2590 .clone()
2591 .oneshot(
2592 Request::builder()
2593 .method("POST")
2594 .uri("/api/v1/palaces/keep-me/drawers")
2595 .header("content-type", "application/json")
2596 .body(Body::from(
2597 json!({
2598 "content": "Important fact that should not be deleted accidentally.",
2599 "tags": [],
2600 })
2601 .to_string(),
2602 ))
2603 .unwrap(),
2604 )
2605 .await
2606 .unwrap();
2607 assert_eq!(resp.status(), StatusCode::OK);
2608
2609 let resp = app
2610 .clone()
2611 .oneshot(
2612 Request::builder()
2613 .method("DELETE")
2614 .uri("/api/v1/palaces/keep-me")
2615 .body(Body::empty())
2616 .unwrap(),
2617 )
2618 .await
2619 .unwrap();
2620 assert_eq!(resp.status(), StatusCode::CONFLICT);
2621
2622 let resp = app
2624 .oneshot(
2625 Request::builder()
2626 .uri("/api/v1/palaces/keep-me")
2627 .body(Body::empty())
2628 .unwrap(),
2629 )
2630 .await
2631 .unwrap();
2632 assert_eq!(resp.status(), StatusCode::OK);
2633 }
2634
2635 #[tokio::test]
2642 async fn delete_palace_force_removes_populated_palace() {
2643 let state = test_state();
2644 let app = router().with_state(state.clone());
2645 let resp = app
2646 .clone()
2647 .oneshot(
2648 Request::builder()
2649 .method("POST")
2650 .uri("/api/v1/palaces")
2651 .header("content-type", "application/json")
2652 .body(Body::from(json!({"name": "force-delete"}).to_string()))
2653 .unwrap(),
2654 )
2655 .await
2656 .unwrap();
2657 assert_eq!(resp.status(), StatusCode::OK);
2658 let resp = app
2659 .clone()
2660 .oneshot(
2661 Request::builder()
2662 .method("POST")
2663 .uri("/api/v1/palaces/force-delete/drawers")
2664 .header("content-type", "application/json")
2665 .body(Body::from(
2666 json!({"content": "Sacrificial drawer for the force-delete path.", "tags": []}).to_string(),
2667 ))
2668 .unwrap(),
2669 )
2670 .await
2671 .unwrap();
2672 assert_eq!(resp.status(), StatusCode::OK);
2673
2674 let resp = app
2675 .clone()
2676 .oneshot(
2677 Request::builder()
2678 .method("DELETE")
2679 .uri("/api/v1/palaces/force-delete?force=true")
2680 .body(Body::empty())
2681 .unwrap(),
2682 )
2683 .await
2684 .unwrap();
2685 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2686
2687 let resp = app
2688 .oneshot(
2689 Request::builder()
2690 .uri("/api/v1/palaces/force-delete")
2691 .body(Body::empty())
2692 .unwrap(),
2693 )
2694 .await
2695 .unwrap();
2696 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2697 }
2698
2699 #[tokio::test]
2705 async fn delete_palace_returns_not_found_for_missing_id() {
2706 let state = test_state();
2707 let app = router().with_state(state);
2708 let resp = app
2709 .oneshot(
2710 Request::builder()
2711 .method("DELETE")
2712 .uri("/api/v1/palaces/never-existed")
2713 .body(Body::empty())
2714 .unwrap(),
2715 )
2716 .await
2717 .unwrap();
2718 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2719 }
2720
2721 #[tokio::test]
2730 async fn update_palace_name_renames_palace() {
2731 let state = test_state();
2732 let app = router().with_state(state);
2733 let resp = app
2734 .clone()
2735 .oneshot(
2736 Request::builder()
2737 .method("POST")
2738 .uri("/api/v1/palaces")
2739 .header("content-type", "application/json")
2740 .body(Body::from(json!({"name": "rename-me"}).to_string()))
2741 .unwrap(),
2742 )
2743 .await
2744 .unwrap();
2745 assert_eq!(resp.status(), StatusCode::OK);
2746
2747 let resp = app
2748 .clone()
2749 .oneshot(
2750 Request::builder()
2751 .method("PATCH")
2752 .uri("/api/v1/palaces/rename-me")
2753 .header("content-type", "application/json")
2754 .body(Body::from(json!({"name": "New Display Name"}).to_string()))
2755 .unwrap(),
2756 )
2757 .await
2758 .unwrap();
2759 assert_eq!(resp.status(), StatusCode::OK);
2760 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2761 let v: Value = serde_json::from_slice(&bytes).unwrap();
2762 assert_eq!(v["id"].as_str(), Some("rename-me"));
2763 assert_eq!(v["name"].as_str(), Some("New Display Name"));
2764
2765 let resp = app
2766 .oneshot(
2767 Request::builder()
2768 .uri("/api/v1/palaces/rename-me")
2769 .body(Body::empty())
2770 .unwrap(),
2771 )
2772 .await
2773 .unwrap();
2774 assert_eq!(resp.status(), StatusCode::OK);
2775 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2776 let v: Value = serde_json::from_slice(&bytes).unwrap();
2777 assert_eq!(v["id"].as_str(), Some("rename-me"));
2778 assert_eq!(v["name"].as_str(), Some("New Display Name"));
2779 }
2780
2781 #[tokio::test]
2787 async fn update_palace_name_rejects_empty_name() {
2788 let state = test_state();
2789 let app = router().with_state(state);
2790 let resp = app
2791 .clone()
2792 .oneshot(
2793 Request::builder()
2794 .method("POST")
2795 .uri("/api/v1/palaces")
2796 .header("content-type", "application/json")
2797 .body(Body::from(json!({"name": "keep-name"}).to_string()))
2798 .unwrap(),
2799 )
2800 .await
2801 .unwrap();
2802 assert_eq!(resp.status(), StatusCode::OK);
2803
2804 let resp = app
2805 .oneshot(
2806 Request::builder()
2807 .method("PATCH")
2808 .uri("/api/v1/palaces/keep-name")
2809 .header("content-type", "application/json")
2810 .body(Body::from(json!({"name": " "}).to_string()))
2811 .unwrap(),
2812 )
2813 .await
2814 .unwrap();
2815 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2816 }
2817
2818 #[tokio::test]
2824 async fn update_palace_name_returns_not_found_for_missing_id() {
2825 let state = test_state();
2826 let app = router().with_state(state);
2827 let resp = app
2828 .oneshot(
2829 Request::builder()
2830 .method("PATCH")
2831 .uri("/api/v1/palaces/no-such-palace")
2832 .header("content-type", "application/json")
2833 .body(Body::from(json!({"name": "irrelevant"}).to_string()))
2834 .unwrap(),
2835 )
2836 .await
2837 .unwrap();
2838 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2839 }
2840
2841 #[tokio::test]
2850 async fn palace_list_includes_graph_counts() {
2851 let state = test_state();
2852 let app = router().with_state(state.clone());
2853 let body = json!({"name": "graph-counts", "description": null}).to_string();
2854 let resp = app
2855 .clone()
2856 .oneshot(
2857 Request::builder()
2858 .method("POST")
2859 .uri("/api/v1/palaces")
2860 .header("content-type", "application/json")
2861 .body(Body::from(body))
2862 .unwrap(),
2863 )
2864 .await
2865 .unwrap();
2866 assert_eq!(resp.status(), StatusCode::OK);
2867
2868 let resp = app
2869 .oneshot(
2870 Request::builder()
2871 .uri("/api/v1/palaces")
2872 .body(Body::empty())
2873 .unwrap(),
2874 )
2875 .await
2876 .unwrap();
2877 assert_eq!(resp.status(), StatusCode::OK);
2878 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2879 let v: Value = serde_json::from_slice(&bytes).unwrap();
2880 let arr = v.as_array().expect("array");
2881 let row = arr
2882 .iter()
2883 .find(|p| p["id"] == "graph-counts")
2884 .expect("created palace must appear in list");
2885 assert_eq!(row["node_count"].as_u64(), Some(0));
2886 assert_eq!(row["edge_count"].as_u64(), Some(0));
2887 assert_eq!(row["community_count"].as_u64(), Some(0));
2888 assert_eq!(row["is_compacting"].as_bool(), Some(false));
2889 }
2890
2891 #[tokio::test]
2898 async fn status_includes_total_counters() {
2899 let state = test_state();
2900 let app = router().with_state(state);
2901 let resp = app
2902 .oneshot(
2903 Request::builder()
2904 .uri("/api/v1/status")
2905 .body(Body::empty())
2906 .unwrap(),
2907 )
2908 .await
2909 .unwrap();
2910 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2911 let v: Value = serde_json::from_slice(&bytes).unwrap();
2912 assert_eq!(v["total_drawers"], 0);
2913 assert_eq!(v["total_vectors"], 0);
2914 assert_eq!(v["total_kg_triples"], 0);
2915 }
2916
2917 #[tokio::test]
2924 async fn dream_status_empty_returns_nulls() {
2925 let state = test_state();
2926 let app = router().with_state(state);
2927 let resp = app
2928 .oneshot(
2929 Request::builder()
2930 .uri("/api/v1/dream/status")
2931 .body(Body::empty())
2932 .unwrap(),
2933 )
2934 .await
2935 .unwrap();
2936 assert_eq!(resp.status(), StatusCode::OK);
2937 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2938 let v: Value = serde_json::from_slice(&bytes).unwrap();
2939 assert!(v["last_run_at"].is_null());
2940 assert_eq!(v["merged"], 0);
2941 assert_eq!(v["pruned"], 0);
2942 }
2943
2944 #[tokio::test]
2951 async fn providers_endpoint_returns_payload() {
2952 let state = test_state();
2953 let app = router().with_state(state);
2954 let resp = app
2955 .oneshot(
2956 Request::builder()
2957 .uri("/api/v1/chat/providers")
2958 .body(Body::empty())
2959 .unwrap(),
2960 )
2961 .await
2962 .unwrap();
2963 assert_eq!(resp.status(), StatusCode::OK);
2964 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2965 let v: Value = serde_json::from_slice(&bytes).unwrap();
2966 let arr = v["providers"].as_array().expect("providers array");
2967 assert_eq!(arr.len(), 2);
2968 let names: Vec<&str> = arr.iter().filter_map(|p| p["name"].as_str()).collect();
2969 assert!(names.contains(&"ollama"));
2970 assert!(names.contains(&"openrouter"));
2971 assert!(v.get("active").is_some());
2973 }
2974
2975 #[tokio::test]
2982 async fn chat_session_crud_round_trip() {
2983 let state = test_state();
2984 let palace = trusty_common::memory_core::Palace {
2986 id: PalaceId::new("sess-test"),
2987 name: "sess-test".to_string(),
2988 description: None,
2989 created_at: chrono::Utc::now(),
2990 data_dir: state.data_root.join("sess-test"),
2991 };
2992 state
2993 .registry
2994 .create_palace(&state.data_root, palace)
2995 .expect("create_palace");
2996 let app = router().with_state(state);
2997
2998 let resp = app
3000 .clone()
3001 .oneshot(
3002 Request::builder()
3003 .method("POST")
3004 .uri("/api/v1/palaces/sess-test/chat/sessions")
3005 .header("content-type", "application/json")
3006 .body(Body::from(json!({"title":"first chat"}).to_string()))
3007 .unwrap(),
3008 )
3009 .await
3010 .unwrap();
3011 assert_eq!(resp.status(), StatusCode::OK);
3012 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3013 let v: Value = serde_json::from_slice(&bytes).unwrap();
3014 let sid = v["id"].as_str().expect("session id").to_string();
3015
3016 let resp = app
3018 .clone()
3019 .oneshot(
3020 Request::builder()
3021 .uri("/api/v1/palaces/sess-test/chat/sessions")
3022 .body(Body::empty())
3023 .unwrap(),
3024 )
3025 .await
3026 .unwrap();
3027 assert_eq!(resp.status(), StatusCode::OK);
3028 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3029 let v: Value = serde_json::from_slice(&bytes).unwrap();
3030 let arr = v.as_array().expect("array");
3031 assert!(arr.iter().any(|s| s["id"] == sid));
3032
3033 let resp = app
3035 .clone()
3036 .oneshot(
3037 Request::builder()
3038 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3039 .body(Body::empty())
3040 .unwrap(),
3041 )
3042 .await
3043 .unwrap();
3044 assert_eq!(resp.status(), StatusCode::OK);
3045
3046 let resp = app
3048 .clone()
3049 .oneshot(
3050 Request::builder()
3051 .method("DELETE")
3052 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3053 .body(Body::empty())
3054 .unwrap(),
3055 )
3056 .await
3057 .unwrap();
3058 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
3059
3060 let resp = app
3062 .oneshot(
3063 Request::builder()
3064 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3065 .body(Body::empty())
3066 .unwrap(),
3067 )
3068 .await
3069 .unwrap();
3070 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3071 }
3072
3073 #[tokio::test]
3085 async fn messages_endpoint_round_trip() {
3086 let state = test_state();
3087 let palace = trusty_common::memory_core::Palace {
3088 id: PalaceId::new("msg-test"),
3089 name: "msg-test".to_string(),
3090 description: None,
3091 created_at: chrono::Utc::now(),
3092 data_dir: state.data_root.join("msg-test"),
3093 };
3094 state
3095 .registry
3096 .create_palace(&state.data_root, palace)
3097 .expect("create_palace");
3098 let app = router().with_state(state);
3099
3100 let resp = app
3102 .clone()
3103 .oneshot(
3104 Request::builder()
3105 .method("POST")
3106 .uri("/api/v1/messages")
3107 .header("content-type", "application/json")
3108 .body(Body::from(
3109 json!({
3110 "to_palace": "msg-test",
3111 "from_palace": "sender-palace",
3112 "purpose": "task",
3113 "content": "please refresh schema"
3114 })
3115 .to_string(),
3116 ))
3117 .unwrap(),
3118 )
3119 .await
3120 .unwrap();
3121 assert_eq!(resp.status(), StatusCode::OK);
3122 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3123 let send_resp: Value = serde_json::from_slice(&bytes).unwrap();
3124 assert_eq!(send_resp["status"], "sent");
3125 let drawer_id = send_resp["drawer_id"]
3126 .as_str()
3127 .expect("drawer_id")
3128 .to_string();
3129
3130 let resp = app
3132 .clone()
3133 .oneshot(
3134 Request::builder()
3135 .uri("/api/v1/messages?palace=msg-test&unread_only=true")
3136 .body(Body::empty())
3137 .unwrap(),
3138 )
3139 .await
3140 .unwrap();
3141 assert_eq!(resp.status(), StatusCode::OK);
3142 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
3143 let list: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3144 assert_eq!(list.len(), 1);
3145 assert_eq!(list[0]["id"], drawer_id);
3146 assert_eq!(list[0]["from_palace"], "sender-palace");
3147 assert_eq!(list[0]["to_palace"], "msg-test");
3148 assert_eq!(list[0]["purpose"], "task");
3149 assert_eq!(list[0]["content"], "please refresh schema");
3150 assert_eq!(list[0]["read"], false);
3151 assert!(list[0]["formatted"]
3152 .as_str()
3153 .unwrap()
3154 .contains("sender-palace"));
3155
3156 let resp = app
3158 .clone()
3159 .oneshot(
3160 Request::builder()
3161 .method("POST")
3162 .uri("/api/v1/messages/mark_read")
3163 .header("content-type", "application/json")
3164 .body(Body::from(
3165 json!({"palace": "msg-test", "drawer_id": drawer_id}).to_string(),
3166 ))
3167 .unwrap(),
3168 )
3169 .await
3170 .unwrap();
3171 assert_eq!(resp.status(), StatusCode::OK);
3172 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
3173 let mark: Value = serde_json::from_slice(&bytes).unwrap();
3174 assert_eq!(mark["flipped"], true);
3175
3176 let resp = app
3178 .clone()
3179 .oneshot(
3180 Request::builder()
3181 .uri("/api/v1/messages?palace=msg-test&unread_only=true")
3182 .body(Body::empty())
3183 .unwrap(),
3184 )
3185 .await
3186 .unwrap();
3187 assert_eq!(resp.status(), StatusCode::OK);
3188 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3189 let list: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3190 assert!(list.is_empty(), "inbox cleared after mark_read");
3191
3192 let resp = app
3194 .oneshot(
3195 Request::builder()
3196 .uri("/api/v1/messages?palace=msg-test&unread_only=false")
3197 .body(Body::empty())
3198 .unwrap(),
3199 )
3200 .await
3201 .unwrap();
3202 assert_eq!(resp.status(), StatusCode::OK);
3203 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
3204 let history: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3205 assert_eq!(history.len(), 1);
3206 assert_eq!(history[0]["read"], true);
3207 }
3208
3209 #[test]
3216 fn all_tools_returns_expected_set() {
3217 let tools = crate::chat::all_tools();
3218 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
3219 assert_eq!(
3220 names,
3221 vec![
3222 "list_palaces",
3223 "get_palace",
3224 "recall_memories",
3225 "list_drawers",
3226 "kg_query",
3227 "get_config",
3228 "get_status",
3229 "get_dream_status",
3230 "get_palace_dream_status",
3231 "create_memory",
3232 "kg_assert",
3233 "memory_recall_all",
3234 ]
3235 );
3236 for t in &tools {
3239 assert_eq!(
3240 t.parameters["type"], "object",
3241 "tool {} schema type",
3242 t.name
3243 );
3244 assert!(
3245 t.parameters["required"].is_array(),
3246 "tool {} required not array",
3247 t.name
3248 );
3249 }
3250 }
3251
3252 #[tokio::test]
3259 async fn execute_tool_dispatches_known_tools() {
3260 let state = test_state();
3261 let result = crate::chat::execute_tool("list_palaces", "{}", &state).await;
3262 assert!(
3263 result.is_array(),
3264 "list_palaces should be array, got {result}"
3265 );
3266 assert_eq!(result.as_array().unwrap().len(), 0);
3267
3268 let unknown = crate::chat::execute_tool("not_a_tool", "{}", &state).await;
3269 assert!(
3270 unknown["error"]
3271 .as_str()
3272 .unwrap_or("")
3273 .contains("unknown tool"),
3274 "expected unknown-tool error, got {unknown}"
3275 );
3276
3277 let missing = crate::chat::execute_tool("get_palace", "{}", &state).await;
3278 assert!(
3279 missing["error"]
3280 .as_str()
3281 .unwrap_or("")
3282 .contains("palace_id"),
3283 "expected missing-arg error, got {missing}"
3284 );
3285 }
3286
3287 #[tokio::test]
3296 async fn sse_broadcast_emits_palace_created() {
3297 let state = test_state();
3298 let mut rx = state.events.subscribe();
3299 let app = router().with_state(state.clone());
3300 let body = json!({"name": "sse-test"}).to_string();
3301 let resp = app
3302 .oneshot(
3303 Request::builder()
3304 .method("POST")
3305 .uri("/api/v1/palaces")
3306 .header("content-type", "application/json")
3307 .body(Body::from(body))
3308 .unwrap(),
3309 )
3310 .await
3311 .unwrap();
3312 assert_eq!(resp.status(), StatusCode::OK);
3313 let event = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
3315 .await
3316 .expect("event received within timeout")
3317 .expect("event channel still open");
3318 match event {
3319 DaemonEvent::PalaceCreated { id, name, source } => {
3320 assert_eq!(id, "sse-test");
3321 assert_eq!(name, "sse-test");
3322 assert_eq!(source, ActivitySource::Http);
3323 }
3324 other => panic!("expected PalaceCreated, got {other:?}"),
3325 }
3326 }
3327
3328 #[tokio::test]
3335 async fn sse_endpoint_emits_connected_frame() {
3336 use axum::routing::get;
3337 let state = test_state();
3338 let app = router()
3339 .route("/sse", get(crate::sse_handler))
3340 .with_state(state);
3341 let resp = app
3342 .oneshot(Request::builder().uri("/sse").body(Body::empty()).unwrap())
3343 .await
3344 .unwrap();
3345 assert_eq!(resp.status(), StatusCode::OK);
3346 assert_eq!(
3347 resp.headers()
3348 .get(header::CONTENT_TYPE)
3349 .and_then(|v| v.to_str().ok()),
3350 Some("text/event-stream")
3351 );
3352 let body = resp.into_body();
3355 let bytes =
3356 tokio::time::timeout(std::time::Duration::from_millis(500), to_bytes(body, 4096))
3357 .await
3358 .ok()
3359 .and_then(|r| r.ok())
3360 .unwrap_or_default();
3361 let text = String::from_utf8_lossy(&bytes);
3362 assert!(
3363 text.contains("\"type\":\"connected\""),
3364 "expected connected frame, got: {text}"
3365 );
3366 }
3367
3368 #[tokio::test]
3378 async fn dream_status_aggregates_across_palaces() {
3379 use trusty_common::memory_core::dream::{DreamStats, PersistedDreamStats};
3380
3381 let state = test_state();
3382 for (id, stats, ts) in [
3386 (
3387 "palace-a",
3388 DreamStats {
3389 merged: 1,
3390 pruned: 2,
3391 compacted: 3,
3392 closets_updated: 4,
3393 duration_ms: 100,
3394 ..DreamStats::default()
3395 },
3396 chrono::Utc::now() - chrono::Duration::seconds(60),
3397 ),
3398 (
3399 "palace-b",
3400 DreamStats {
3401 merged: 10,
3402 pruned: 20,
3403 compacted: 30,
3404 closets_updated: 40,
3405 duration_ms: 200,
3406 ..DreamStats::default()
3407 },
3408 chrono::Utc::now(),
3409 ),
3410 ] {
3411 let palace = trusty_common::memory_core::Palace {
3412 id: PalaceId::new(id),
3413 name: id.to_string(),
3414 description: None,
3415 created_at: chrono::Utc::now(),
3416 data_dir: state.data_root.join(id),
3417 };
3418 state
3419 .registry
3420 .create_palace(&state.data_root, palace)
3421 .expect("create palace");
3422 let persisted = PersistedDreamStats {
3423 last_run_at: ts,
3424 stats,
3425 };
3426 persisted
3427 .save(&state.data_root.join(id))
3428 .expect("save dream stats");
3429 }
3430
3431 let later = chrono::Utc::now();
3432 let app = router().with_state(state);
3433 let resp = app
3434 .oneshot(
3435 Request::builder()
3436 .uri("/api/v1/dream/status")
3437 .body(Body::empty())
3438 .unwrap(),
3439 )
3440 .await
3441 .unwrap();
3442 assert_eq!(resp.status(), StatusCode::OK);
3443 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3444 let v: Value = serde_json::from_slice(&bytes).unwrap();
3445
3446 assert_eq!(v["merged"], 11);
3448 assert_eq!(v["pruned"], 22);
3449 assert_eq!(v["compacted"], 33);
3450 assert_eq!(v["closets_updated"], 44);
3451 assert_eq!(v["duration_ms"], 300);
3452
3453 let last = v["last_run_at"].as_str().expect("last_run_at is string");
3455 let parsed: chrono::DateTime<chrono::Utc> = last
3456 .parse()
3457 .expect("last_run_at parses as RFC3339 timestamp");
3458 assert!(
3459 parsed <= later,
3460 "last_run_at ({parsed}) should not exceed wall clock ({later})"
3461 );
3462 let cutoff = chrono::Utc::now() - chrono::Duration::seconds(30);
3464 assert!(
3465 parsed >= cutoff,
3466 "expected the newer (palace-b) timestamp; got {parsed}"
3467 );
3468 }
3469
3470 #[tokio::test]
3482 async fn dream_run_aggregates_stats() {
3483 let state = test_state();
3484 let palace = trusty_common::memory_core::Palace {
3485 id: PalaceId::new("dream-run-test"),
3486 name: "dream-run-test".to_string(),
3487 description: None,
3488 created_at: chrono::Utc::now(),
3489 data_dir: state.data_root.join("dream-run-test"),
3490 };
3491 state
3492 .registry
3493 .create_palace(&state.data_root, palace)
3494 .expect("create palace");
3495
3496 let app = router().with_state(state);
3497 let resp = app
3498 .oneshot(
3499 Request::builder()
3500 .method("POST")
3501 .uri("/api/v1/dream/run")
3502 .body(Body::empty())
3503 .unwrap(),
3504 )
3505 .await
3506 .unwrap();
3507 assert_eq!(resp.status(), StatusCode::OK);
3508 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3509 let v: Value = serde_json::from_slice(&bytes).unwrap();
3510
3511 for key in [
3514 "merged",
3515 "pruned",
3516 "compacted",
3517 "closets_updated",
3518 "duration_ms",
3519 ] {
3520 assert!(
3521 v.get(key).is_some(),
3522 "missing key {key} in dream_run payload: {v}"
3523 );
3524 assert!(
3525 v[key].is_u64() || v[key].is_i64(),
3526 "{key} should be integer, got {}",
3527 v[key]
3528 );
3529 }
3530 assert!(
3531 v["last_run_at"].is_string(),
3532 "last_run_at must be set by dream_run; got {v}"
3533 );
3534 }
3535
3536 #[tokio::test]
3543 async fn kg_gaps_endpoint_returns_empty_when_uncached() {
3544 let state = test_state();
3545 let palace = trusty_common::memory_core::Palace {
3546 id: PalaceId::new("gaps-empty"),
3547 name: "gaps-empty".to_string(),
3548 description: None,
3549 created_at: chrono::Utc::now(),
3550 data_dir: state.data_root.join("gaps-empty"),
3551 };
3552 state
3553 .registry
3554 .create_palace(&state.data_root, palace)
3555 .expect("create palace");
3556
3557 let app = router().with_state(state);
3558 let resp = app
3559 .oneshot(
3560 Request::builder()
3561 .uri("/api/v1/kg/gaps?palace=gaps-empty")
3562 .body(Body::empty())
3563 .unwrap(),
3564 )
3565 .await
3566 .unwrap();
3567 assert_eq!(resp.status(), StatusCode::OK);
3568 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3569 let v: Value = serde_json::from_slice(&bytes).unwrap();
3570 assert_eq!(v.as_array().expect("array").len(), 0);
3571 }
3572
3573 #[tokio::test]
3580 async fn kg_gaps_endpoint_returns_cached_gaps() {
3581 use trusty_common::memory_core::community::KnowledgeGap;
3582
3583 let state = test_state();
3584 let palace = trusty_common::memory_core::Palace {
3585 id: PalaceId::new("gaps-seed"),
3586 name: "gaps-seed".to_string(),
3587 description: None,
3588 created_at: chrono::Utc::now(),
3589 data_dir: state.data_root.join("gaps-seed"),
3590 };
3591 state
3592 .registry
3593 .create_palace(&state.data_root, palace)
3594 .expect("create palace");
3595
3596 state.registry.set_gaps(
3597 PalaceId::new("gaps-seed"),
3598 vec![KnowledgeGap {
3599 entities: vec!["foo".to_string(), "bar".to_string(), "baz".to_string()],
3600 internal_density: 0.15,
3601 external_bridges: 2,
3602 suggested_exploration: "Explore connections between foo and related concepts"
3603 .to_string(),
3604 }],
3605 );
3606
3607 let app = router().with_state(state);
3608 let resp = app
3609 .oneshot(
3610 Request::builder()
3611 .uri("/api/v1/kg/gaps?palace=gaps-seed")
3612 .body(Body::empty())
3613 .unwrap(),
3614 )
3615 .await
3616 .unwrap();
3617 assert_eq!(resp.status(), StatusCode::OK);
3618 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3619 let v: Value = serde_json::from_slice(&bytes).unwrap();
3620 let arr = v.as_array().expect("array");
3621 assert_eq!(arr.len(), 1);
3622 assert_eq!(arr[0]["entities"].as_array().unwrap().len(), 3);
3623 assert_eq!(arr[0]["external_bridges"], 2);
3624 assert!(arr[0]["suggested_exploration"]
3625 .as_str()
3626 .unwrap()
3627 .contains("foo"));
3628 }
3629
3630 #[tokio::test]
3637 async fn kg_list_subjects_returns_distinct() {
3638 let state = test_state();
3639 let app = router().with_state(state.clone());
3640
3641 let resp = app
3643 .clone()
3644 .oneshot(
3645 Request::builder()
3646 .method("POST")
3647 .uri("/api/v1/palaces")
3648 .header("content-type", "application/json")
3649 .body(Body::from(json!({"name": "kg-list"}).to_string()))
3650 .unwrap(),
3651 )
3652 .await
3653 .unwrap();
3654 assert_eq!(resp.status(), StatusCode::OK);
3655
3656 for subj in ["alpha", "beta"] {
3658 let body = json!({
3659 "subject": subj,
3660 "predicate": "is",
3661 "object": "thing",
3662 })
3663 .to_string();
3664 let r = app
3665 .clone()
3666 .oneshot(
3667 Request::builder()
3668 .method("POST")
3669 .uri("/api/v1/palaces/kg-list/kg")
3670 .header("content-type", "application/json")
3671 .body(Body::from(body))
3672 .unwrap(),
3673 )
3674 .await
3675 .unwrap();
3676 assert_eq!(r.status(), StatusCode::NO_CONTENT);
3677 }
3678
3679 let resp = app
3680 .oneshot(
3681 Request::builder()
3682 .uri("/api/v1/palaces/kg-list/kg/subjects?limit=10")
3683 .body(Body::empty())
3684 .unwrap(),
3685 )
3686 .await
3687 .unwrap();
3688 assert_eq!(resp.status(), StatusCode::OK);
3689 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3690 let v: Value = serde_json::from_slice(&bytes).unwrap();
3691 let arr = v.as_array().expect("subjects must be array");
3692 let subjects: Vec<String> = arr
3693 .iter()
3694 .filter_map(|x| x.as_str().map(String::from))
3695 .collect();
3696 assert_eq!(subjects, vec!["alpha".to_string(), "beta".to_string()]);
3697 }
3698
3699 #[tokio::test]
3706 async fn kg_list_all_returns_paginated_triples() {
3707 let state = test_state();
3708 let app = router().with_state(state.clone());
3709
3710 let resp = app
3711 .clone()
3712 .oneshot(
3713 Request::builder()
3714 .method("POST")
3715 .uri("/api/v1/palaces")
3716 .header("content-type", "application/json")
3717 .body(Body::from(json!({"name": "kg-all"}).to_string()))
3718 .unwrap(),
3719 )
3720 .await
3721 .unwrap();
3722 assert_eq!(resp.status(), StatusCode::OK);
3723
3724 let body = json!({
3725 "subject": "alpha",
3726 "predicate": "is",
3727 "object": "thing",
3728 })
3729 .to_string();
3730 let r = app
3731 .clone()
3732 .oneshot(
3733 Request::builder()
3734 .method("POST")
3735 .uri("/api/v1/palaces/kg-all/kg")
3736 .header("content-type", "application/json")
3737 .body(Body::from(body))
3738 .unwrap(),
3739 )
3740 .await
3741 .unwrap();
3742 assert_eq!(r.status(), StatusCode::NO_CONTENT);
3743
3744 let resp = app
3745 .oneshot(
3746 Request::builder()
3747 .uri("/api/v1/palaces/kg-all/kg/all?limit=10&offset=0")
3748 .body(Body::empty())
3749 .unwrap(),
3750 )
3751 .await
3752 .unwrap();
3753 assert_eq!(resp.status(), StatusCode::OK);
3754 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3755 let v: Value = serde_json::from_slice(&bytes).unwrap();
3756 let arr = v.as_array().expect("triples must be array");
3757 assert_eq!(arr.len(), 1);
3758 assert_eq!(arr[0]["subject"], "alpha");
3759 assert_eq!(arr[0]["predicate"], "is");
3760 assert_eq!(arr[0]["object"], "thing");
3761 }
3762
3763 #[tokio::test]
3772 async fn kg_graph_returns_active_triples() {
3773 let state = test_state();
3774 let app = router().with_state(state.clone());
3775
3776 let resp = app
3777 .clone()
3778 .oneshot(
3779 Request::builder()
3780 .method("POST")
3781 .uri("/api/v1/palaces")
3782 .header("content-type", "application/json")
3783 .body(Body::from(json!({"name": "kg-graph"}).to_string()))
3784 .unwrap(),
3785 )
3786 .await
3787 .unwrap();
3788 assert_eq!(resp.status(), StatusCode::OK);
3789
3790 let body = json!({
3791 "subject": "alpha",
3792 "predicate": "is",
3793 "object": "thing",
3794 })
3795 .to_string();
3796 let r = app
3797 .clone()
3798 .oneshot(
3799 Request::builder()
3800 .method("POST")
3801 .uri("/api/v1/palaces/kg-graph/kg")
3802 .header("content-type", "application/json")
3803 .body(Body::from(body))
3804 .unwrap(),
3805 )
3806 .await
3807 .unwrap();
3808 assert_eq!(r.status(), StatusCode::NO_CONTENT);
3809
3810 let resp = app
3811 .oneshot(
3812 Request::builder()
3813 .uri("/api/v1/palaces/kg-graph/kg/graph")
3814 .body(Body::empty())
3815 .unwrap(),
3816 )
3817 .await
3818 .unwrap();
3819 assert_eq!(resp.status(), StatusCode::OK);
3820 let bytes = to_bytes(resp.into_body(), 16_384).await.unwrap();
3821 let v: Value = serde_json::from_slice(&bytes).unwrap();
3822 let triples = v["triples"].as_array().expect("triples array");
3823 assert!(triples
3824 .iter()
3825 .any(|t| t["subject"] == "alpha" && t["predicate"] == "is" && t["object"] == "thing"));
3826 assert!(v["node_count"].as_u64().is_some());
3827 assert!(v["edge_count"].as_u64().is_some());
3828 assert!(v["community_count"].as_u64().is_some());
3829 }
3830
3831 #[tokio::test]
3843 async fn kg_graph_meets_perf_budget_for_500_triples() {
3844 let state = test_state();
3845 let app = router().with_state(state.clone());
3846
3847 let resp = app
3848 .clone()
3849 .oneshot(
3850 Request::builder()
3851 .method("POST")
3852 .uri("/api/v1/palaces")
3853 .header("content-type", "application/json")
3854 .body(Body::from(json!({"name": "kg-perf"}).to_string()))
3855 .unwrap(),
3856 )
3857 .await
3858 .unwrap();
3859 assert_eq!(resp.status(), StatusCode::OK);
3860
3861 let pid = trusty_common::memory_core::palace::PalaceId::new("kg-perf");
3862 let handle = state
3863 .registry
3864 .open_palace(&state.data_root, &pid)
3865 .expect("open palace");
3866 let now = chrono::Utc::now();
3867 for s in 0..10 {
3868 for o in 0..50 {
3869 handle
3870 .kg
3871 .assert(Triple {
3872 subject: format!("s{s}"),
3873 predicate: format!("p{o}"),
3874 object: format!("o{o}"),
3875 valid_from: now,
3876 valid_to: None,
3877 confidence: 1.0,
3878 provenance: Some("perf-test".to_string()),
3879 })
3880 .await
3881 .expect("kg.assert");
3882 }
3883 }
3884
3885 let started = std::time::Instant::now();
3886 let resp = app
3887 .oneshot(
3888 Request::builder()
3889 .uri("/api/v1/palaces/kg-perf/kg/graph")
3890 .body(Body::empty())
3891 .unwrap(),
3892 )
3893 .await
3894 .unwrap();
3895 let elapsed = started.elapsed();
3896 assert_eq!(resp.status(), StatusCode::OK);
3897 let bytes = to_bytes(resp.into_body(), 1_000_000).await.unwrap();
3898 let v: Value = serde_json::from_slice(&bytes).unwrap();
3899 let n = v["triples"].as_array().map(|a| a.len()).unwrap_or(0);
3900 assert_eq!(n, 500, "expected 500 triples in payload");
3901 assert!(
3902 elapsed.as_secs_f64() < 10.0,
3903 "graph endpoint should serve 500 triples in well under 10s; took {elapsed:?}"
3904 );
3905 eprintln!(
3906 "[perf] kg_graph endpoint served 500 triples in {:.3}ms",
3907 elapsed.as_secs_f64() * 1000.0
3908 );
3909 }
3910
3911 #[tokio::test]
3915 async fn prompt_context_endpoint_returns_formatted_block() {
3916 let state = test_state();
3917
3918 let app = router().with_state(state.clone());
3920 let resp = app
3921 .oneshot(
3922 Request::builder()
3923 .uri("/api/v1/kg/prompt-context")
3924 .body(Body::empty())
3925 .unwrap(),
3926 )
3927 .await
3928 .unwrap();
3929 assert_eq!(resp.status(), StatusCode::OK);
3930 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3931 let text = String::from_utf8(bytes.to_vec()).unwrap();
3932 assert_eq!(text, "No prompt facts stored yet.");
3933
3934 {
3936 let mut guard = state.prompt_context_cache.write().await;
3937 let triples = vec![(
3938 "tga".to_string(),
3939 "is_alias_for".to_string(),
3940 "trusty-git-analytics".to_string(),
3941 )];
3942 let formatted = crate::prompt_facts::build_prompt_context(&triples);
3943 *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
3944 }
3945 let app = router().with_state(state);
3946 let resp = app
3947 .oneshot(
3948 Request::builder()
3949 .uri("/api/v1/kg/prompt-context")
3950 .body(Body::empty())
3951 .unwrap(),
3952 )
3953 .await
3954 .unwrap();
3955 assert_eq!(resp.status(), StatusCode::OK);
3956 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3957 let text = String::from_utf8(bytes.to_vec()).unwrap();
3958 assert!(text.contains("tga → trusty-git-analytics"), "got: {text}");
3959 }
3960
3961 #[tokio::test]
3965 async fn add_alias_endpoint_asserts_triple_and_refreshes_cache() {
3966 let tmp = tempfile::tempdir().expect("tempdir");
3967 let root = tmp.path().to_path_buf();
3968 std::mem::forget(tmp);
3969 let state = AppState::new(root).with_default_palace(Some("aliases".to_string()));
3970 let palace = trusty_common::memory_core::Palace {
3971 id: PalaceId::new("aliases"),
3972 name: "aliases".to_string(),
3973 description: None,
3974 created_at: chrono::Utc::now(),
3975 data_dir: state.data_root.join("aliases"),
3976 };
3977 state
3978 .registry
3979 .create_palace(&state.data_root, palace)
3980 .expect("create palace");
3981
3982 let body = json!({"short": "tm", "full": "trusty-memory"});
3983 let app = router().with_state(state.clone());
3984 let resp = app
3985 .oneshot(
3986 Request::builder()
3987 .method("POST")
3988 .uri("/api/v1/kg/aliases")
3989 .header("content-type", "application/json")
3990 .body(Body::from(serde_json::to_vec(&body).unwrap()))
3991 .unwrap(),
3992 )
3993 .await
3994 .unwrap();
3995 assert_eq!(resp.status(), StatusCode::OK);
3996 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3997 let v: Value = serde_json::from_slice(&bytes).unwrap();
3998 assert_eq!(v["subject"], "tm");
3999 assert_eq!(v["object"], "trusty-memory");
4000
4001 let guard = state.prompt_context_cache.read().await;
4003 assert!(
4004 guard.formatted.contains("tm → trusty-memory"),
4005 "cache missing alias; got: {}",
4006 guard.formatted
4007 );
4008 }
4009
4010 #[tokio::test]
4014 async fn list_prompt_facts_endpoint_returns_hot_triples() {
4015 let tmp = tempfile::tempdir().expect("tempdir");
4016 let root = tmp.path().to_path_buf();
4017 std::mem::forget(tmp);
4018 let state = AppState::new(root).with_default_palace(Some("listfacts".to_string()));
4019 let palace = trusty_common::memory_core::Palace {
4020 id: PalaceId::new("listfacts"),
4021 name: "listfacts".to_string(),
4022 description: None,
4023 created_at: chrono::Utc::now(),
4024 data_dir: state.data_root.join("listfacts"),
4025 };
4026 let handle = state
4027 .registry
4028 .create_palace(&state.data_root, palace)
4029 .expect("create palace");
4030
4031 handle
4034 .kg
4035 .assert(Triple {
4036 subject: "ts".to_string(),
4037 predicate: "is_alias_for".to_string(),
4038 object: "trusty-search".to_string(),
4039 valid_from: chrono::Utc::now(),
4040 valid_to: None,
4041 confidence: 1.0,
4042 provenance: None,
4043 })
4044 .await
4045 .expect("assert alias");
4046 handle
4047 .kg
4048 .assert(Triple {
4049 subject: "alice".to_string(),
4050 predicate: "works_at".to_string(),
4051 object: "Acme".to_string(),
4052 valid_from: chrono::Utc::now(),
4053 valid_to: None,
4054 confidence: 1.0,
4055 provenance: None,
4056 })
4057 .await
4058 .expect("assert works_at");
4059
4060 let app = router().with_state(state);
4061 let resp = app
4062 .oneshot(
4063 Request::builder()
4064 .uri("/api/v1/kg/prompt-facts")
4065 .body(Body::empty())
4066 .unwrap(),
4067 )
4068 .await
4069 .unwrap();
4070 assert_eq!(resp.status(), StatusCode::OK);
4071 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4072 let v: Value = serde_json::from_slice(&bytes).unwrap();
4073 let arr = v.as_array().expect("array");
4074 assert!(
4075 arr.iter().any(|r| r["subject"] == "ts"
4076 && r["predicate"] == "is_alias_for"
4077 && r["object"] == "trusty-search"),
4078 "missing ts alias; got {arr:?}"
4079 );
4080 assert!(
4082 !arr.iter().any(|r| r["predicate"] == "works_at"),
4083 "non-hot triple leaked into prompt facts: {arr:?}"
4084 );
4085 }
4086
4087 #[tokio::test]
4090 async fn remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache() {
4091 let tmp = tempfile::tempdir().expect("tempdir");
4092 let root = tmp.path().to_path_buf();
4093 std::mem::forget(tmp);
4094 let state = AppState::new(root).with_default_palace(Some("rmfacts".to_string()));
4095 let palace = trusty_common::memory_core::Palace {
4096 id: PalaceId::new("rmfacts"),
4097 name: "rmfacts".to_string(),
4098 description: None,
4099 created_at: chrono::Utc::now(),
4100 data_dir: state.data_root.join("rmfacts"),
4101 };
4102 let handle = state
4103 .registry
4104 .create_palace(&state.data_root, palace)
4105 .expect("create palace");
4106
4107 handle
4108 .kg
4109 .assert(Triple {
4110 subject: "ta".to_string(),
4111 predicate: "is_alias_for".to_string(),
4112 object: "trusty-analyze".to_string(),
4113 valid_from: chrono::Utc::now(),
4114 valid_to: None,
4115 confidence: 1.0,
4116 provenance: None,
4117 })
4118 .await
4119 .expect("assert alias");
4120 crate::prompt_facts::rebuild_prompt_cache(&state)
4122 .await
4123 .expect("rebuild prompt cache");
4124
4125 let app = router().with_state(state.clone());
4126 let resp = app
4127 .oneshot(
4128 Request::builder()
4129 .method("DELETE")
4130 .uri("/api/v1/kg/prompt-facts?subject=ta&predicate=is_alias_for")
4131 .body(Body::empty())
4132 .unwrap(),
4133 )
4134 .await
4135 .unwrap();
4136 assert_eq!(resp.status(), StatusCode::OK);
4137 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4138 let v: Value = serde_json::from_slice(&bytes).unwrap();
4139 assert_eq!(v["removed"], true);
4140 assert!(v["closed"].as_u64().unwrap_or(0) >= 1);
4141
4142 {
4144 let guard = state.prompt_context_cache.read().await;
4145 assert!(
4146 !guard.formatted.contains("ta → trusty-analyze"),
4147 "alias still in cache after delete: {}",
4148 guard.formatted
4149 );
4150 }
4151
4152 let app = router().with_state(state);
4154 let resp = app
4155 .oneshot(
4156 Request::builder()
4157 .method("DELETE")
4158 .uri("/api/v1/kg/prompt-facts?subject=nope&predicate=is_alias_for")
4159 .body(Body::empty())
4160 .unwrap(),
4161 )
4162 .await
4163 .unwrap();
4164 assert_eq!(resp.status(), StatusCode::OK);
4165 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4166 let v: Value = serde_json::from_slice(&bytes).unwrap();
4167 assert_eq!(v["removed"], false);
4168 }
4169
4170 #[tokio::test]
4171 async fn serves_index_html_fallback() {
4172 let state = test_state();
4173 let app = router().with_state(state);
4174 let resp = app
4175 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4176 .await
4177 .unwrap();
4178 assert!(
4180 resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
4181 "got {}",
4182 resp.status()
4183 );
4184 }
4185
4186 #[tokio::test]
4197 async fn activity_endpoint_lists_recent_emits() {
4198 let state = test_state();
4199 state.emit(DaemonEvent::PalaceCreated {
4201 id: "alpha".into(),
4202 name: "alpha".into(),
4203 source: ActivitySource::Http,
4204 });
4205 state.emit(DaemonEvent::DrawerAdded {
4206 palace_id: "alpha".into(),
4207 palace_name: "alpha".into(),
4208 drawer_count: 1,
4209 timestamp: chrono::Utc::now(),
4210 content_preview: "hello".into(),
4211 source: ActivitySource::Mcp,
4212 });
4213 state.emit(DaemonEvent::DrawerAdded {
4214 palace_id: "beta".into(),
4215 palace_name: "beta".into(),
4216 drawer_count: 1,
4217 timestamp: chrono::Utc::now(),
4218 content_preview: "hi there".into(),
4219 source: ActivitySource::Http,
4220 });
4221 state.emit(DaemonEvent::DrawerDeleted {
4222 palace_id: "alpha".into(),
4223 drawer_count: 0,
4224 source: ActivitySource::Http,
4225 });
4226 state.flush_activity_writes().await;
4230
4231 let app = router().with_state(state);
4232 let resp = app
4233 .oneshot(
4234 Request::builder()
4235 .uri("/api/v1/activity?limit=10")
4236 .body(Body::empty())
4237 .unwrap(),
4238 )
4239 .await
4240 .unwrap();
4241 assert_eq!(resp.status(), StatusCode::OK);
4242 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4243 let v: Value = serde_json::from_slice(&bytes).unwrap();
4244 assert_eq!(v["limit"], 10);
4245 assert_eq!(v["offset"], 0);
4246 assert_eq!(v["total"], 4);
4247 let entries = v["entries"].as_array().expect("entries array");
4248 assert_eq!(entries.len(), 4);
4249 assert_eq!(entries[0]["event_type"], "drawer_deleted");
4251 assert_eq!(entries[3]["event_type"], "palace_created");
4252 let sources: Vec<&str> = entries
4254 .iter()
4255 .filter_map(|e| e["source"].as_str())
4256 .collect();
4257 assert!(sources.contains(&"http"));
4258 assert!(sources.contains(&"mcp"));
4259 assert!(entries[0]["payload"].is_object());
4261 }
4262
4263 #[tokio::test]
4269 async fn activity_endpoint_clamps_limit() {
4270 let state = test_state();
4271 let app = router().with_state(state);
4272 let resp = app
4273 .oneshot(
4274 Request::builder()
4275 .uri("/api/v1/activity?limit=10000")
4276 .body(Body::empty())
4277 .unwrap(),
4278 )
4279 .await
4280 .unwrap();
4281 assert_eq!(resp.status(), StatusCode::OK);
4282 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4283 let v: Value = serde_json::from_slice(&bytes).unwrap();
4284 assert_eq!(v["limit"], ACTIVITY_MAX_LIMIT);
4285 }
4286
4287 #[tokio::test]
4294 async fn activity_endpoint_filters_by_source_and_palace() {
4295 let state = test_state();
4296 state.emit(DaemonEvent::DrawerAdded {
4297 palace_id: "alpha".into(),
4298 palace_name: "alpha".into(),
4299 drawer_count: 1,
4300 timestamp: chrono::Utc::now(),
4301 content_preview: "".into(),
4302 source: ActivitySource::Mcp,
4303 });
4304 state.emit(DaemonEvent::DrawerAdded {
4305 palace_id: "alpha".into(),
4306 palace_name: "alpha".into(),
4307 drawer_count: 2,
4308 timestamp: chrono::Utc::now(),
4309 content_preview: "".into(),
4310 source: ActivitySource::Http,
4311 });
4312 state.emit(DaemonEvent::DrawerAdded {
4313 palace_id: "beta".into(),
4314 palace_name: "beta".into(),
4315 drawer_count: 1,
4316 timestamp: chrono::Utc::now(),
4317 content_preview: "".into(),
4318 source: ActivitySource::Mcp,
4319 });
4320 state.flush_activity_writes().await;
4322
4323 let app = router().with_state(state);
4324 let resp = app
4325 .oneshot(
4326 Request::builder()
4327 .uri("/api/v1/activity?palace=alpha&source=mcp&limit=50")
4328 .body(Body::empty())
4329 .unwrap(),
4330 )
4331 .await
4332 .unwrap();
4333 assert_eq!(resp.status(), StatusCode::OK);
4334 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4335 let v: Value = serde_json::from_slice(&bytes).unwrap();
4336 let entries = v["entries"].as_array().unwrap();
4337 assert_eq!(entries.len(), 1, "filter should leave one row, got {v}");
4338 assert_eq!(entries[0]["palace_id"], "alpha");
4339 assert_eq!(entries[0]["source"], "mcp");
4340 }
4341
4342 #[tokio::test]
4345 async fn activity_endpoint_rejects_unknown_source() {
4346 let state = test_state();
4347 let app = router().with_state(state);
4348 let resp = app
4349 .oneshot(
4350 Request::builder()
4351 .uri("/api/v1/activity?source=nope")
4352 .body(Body::empty())
4353 .unwrap(),
4354 )
4355 .await
4356 .unwrap();
4357 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
4358 }
4359
4360 #[tokio::test]
4368 async fn mcp_memory_remember_emits_drawer_added_with_mcp_source() {
4369 use crate::tools::dispatch_tool;
4370 let state = test_state();
4371 let mut rx = state.events.subscribe();
4372 let _ = dispatch_tool(&state, "palace_create", json!({"name": "p1"}))
4375 .await
4376 .expect("palace_create");
4377 let first = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
4379 .await
4380 .expect("first event")
4381 .expect("channel open");
4382 assert!(
4383 matches!(first, DaemonEvent::PalaceCreated { ref source, .. } if *source == ActivitySource::Mcp)
4384 );
4385
4386 let _ = dispatch_tool(
4387 &state,
4388 "memory_remember",
4389 json!({
4390 "palace": "p1",
4391 "text": "the quick brown fox jumps over the lazy dog and more"
4392 }),
4393 )
4394 .await
4395 .expect("memory_remember");
4396
4397 let next = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
4399 .await
4400 .expect("drawer_added event")
4401 .expect("channel open");
4402 match next {
4403 DaemonEvent::DrawerAdded {
4404 source, palace_id, ..
4405 } => {
4406 assert_eq!(source, ActivitySource::Mcp);
4407 assert_eq!(palace_id, "p1");
4408 }
4409 other => panic!("expected DrawerAdded, got {other:?}"),
4410 }
4411
4412 state.flush_activity_writes().await;
4417 let app = router().with_state(state);
4418 let resp = app
4419 .oneshot(
4420 Request::builder()
4421 .uri("/api/v1/activity?source=mcp&limit=10")
4422 .body(Body::empty())
4423 .unwrap(),
4424 )
4425 .await
4426 .unwrap();
4427 assert_eq!(resp.status(), StatusCode::OK);
4428 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4429 let v: Value = serde_json::from_slice(&bytes).unwrap();
4430 let entries = v["entries"].as_array().unwrap();
4431 let event_types: std::collections::HashSet<&str> = entries
4432 .iter()
4433 .filter_map(|e| e["event_type"].as_str())
4434 .collect();
4435 assert!(event_types.contains("drawer_added"));
4436 assert!(event_types.contains("palace_created"));
4437 }
4438
4439 #[tokio::test]
4454 async fn hook_fired_activity_emit_smoke() {
4455 let state = test_state();
4456 let app = router().with_state(state.clone());
4457
4458 let payload = serde_json::json!({
4459 "palace_id": "alpha",
4460 "palace_name": "alpha",
4461 "hook_type": "UserPromptSubmit",
4462 "injection_kind": "prompt-context",
4463 "injection_length": 256,
4464 "trigger_prompt_excerpt": "test prompt",
4465 "duration_ms": 12,
4466 });
4467 let resp = app
4468 .oneshot(
4469 Request::builder()
4470 .method("POST")
4471 .uri("/api/v1/activity/hook")
4472 .header("content-type", "application/json")
4473 .body(Body::from(payload.to_string()))
4474 .unwrap(),
4475 )
4476 .await
4477 .unwrap();
4478 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
4479 state.flush_activity_writes().await;
4483
4484 let app = router().with_state(state);
4486 let resp = app
4487 .oneshot(
4488 Request::builder()
4489 .uri("/api/v1/activity?source=hook&limit=10")
4490 .body(Body::empty())
4491 .unwrap(),
4492 )
4493 .await
4494 .unwrap();
4495 assert_eq!(resp.status(), StatusCode::OK);
4496 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4497 let v: Value = serde_json::from_slice(&bytes).unwrap();
4498 let entries = v["entries"].as_array().expect("entries array");
4499 assert!(
4500 !entries.is_empty(),
4501 "expected at least one hook activity row, got {entries:?}"
4502 );
4503 let first = &entries[0];
4504 assert_eq!(first["source"], "hook");
4505 assert_eq!(first["event_type"], "hook_fired");
4506 assert_eq!(first["palace_id"], "alpha");
4507 let body = &first["payload"];
4508 assert_eq!(body["hook_type"], "UserPromptSubmit");
4509 assert_eq!(body["injection_kind"], "prompt-context");
4510 }
4511
4512 #[tokio::test]
4522 async fn drawer_creator_attribution_http_default() {
4523 let tmp = tempfile::tempdir().expect("tempdir");
4524 let root = tmp.path().to_path_buf();
4525 std::mem::forget(tmp);
4526 let state = AppState::new(root);
4527 let palace = trusty_common::memory_core::Palace {
4528 id: PalaceId::new("cred-default"),
4529 name: "cred-default".to_string(),
4530 description: None,
4531 created_at: chrono::Utc::now(),
4532 data_dir: state.data_root.join("cred-default"),
4533 };
4534 state
4535 .registry
4536 .create_palace(&state.data_root, palace)
4537 .expect("create palace");
4538
4539 let app = router().with_state(state.clone());
4540 let body = serde_json::json!({
4541 "content": "hello world from anonymous client",
4542 "tags": ["user-tag"],
4543 });
4544 let resp = app
4545 .oneshot(
4546 Request::builder()
4547 .method("POST")
4548 .uri("/api/v1/palaces/cred-default/drawers")
4549 .header("content-type", "application/json")
4550 .body(Body::from(body.to_string()))
4551 .unwrap(),
4552 )
4553 .await
4554 .unwrap();
4555 assert_eq!(resp.status(), StatusCode::OK);
4556
4557 let app = router().with_state(state);
4559 let resp = app
4560 .oneshot(
4561 Request::builder()
4562 .uri("/api/v1/palaces/cred-default/drawers?limit=10")
4563 .body(Body::empty())
4564 .unwrap(),
4565 )
4566 .await
4567 .unwrap();
4568 assert_eq!(resp.status(), StatusCode::OK);
4569 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4570 let v: Value = serde_json::from_slice(&bytes).unwrap();
4571 let drawers = v.as_array().expect("drawers array");
4572 assert_eq!(drawers.len(), 1, "expected one drawer, got {drawers:?}");
4573 let tags: Vec<&str> = drawers[0]["tags"]
4574 .as_array()
4575 .expect("tags array")
4576 .iter()
4577 .filter_map(|t| t.as_str())
4578 .collect();
4579 assert!(
4580 tags.contains(&"user-tag"),
4581 "user-supplied tag must survive; got {tags:?}"
4582 );
4583 assert!(
4584 tags.contains(&"creator:client=unknown-http-client"),
4585 "expected default client tag; got {tags:?}"
4586 );
4587 assert!(
4588 tags.contains(&"creator:source=http"),
4589 "expected http source tag; got {tags:?}"
4590 );
4591 assert!(
4592 tags.iter().any(|t| t.starts_with("creator:version=")),
4593 "expected creator:version tag; got {tags:?}"
4594 );
4595 }
4596
4597 #[tokio::test]
4605 async fn drawer_creator_attribution_http_header() {
4606 let tmp = tempfile::tempdir().expect("tempdir");
4607 let root = tmp.path().to_path_buf();
4608 std::mem::forget(tmp);
4609 let state = AppState::new(root);
4610 let palace = trusty_common::memory_core::Palace {
4611 id: PalaceId::new("cred-header"),
4612 name: "cred-header".to_string(),
4613 description: None,
4614 created_at: chrono::Utc::now(),
4615 data_dir: state.data_root.join("cred-header"),
4616 };
4617 state
4618 .registry
4619 .create_palace(&state.data_root, palace)
4620 .expect("create palace");
4621
4622 let app = router().with_state(state.clone());
4623 let body = serde_json::json!({
4624 "content": "this is enough content to pass the signal/noise filter applied by remember",
4625 "tags": [],
4626 });
4627 let resp = app
4628 .oneshot(
4629 Request::builder()
4630 .method("POST")
4631 .uri("/api/v1/palaces/cred-header/drawers")
4632 .header("content-type", "application/json")
4633 .header("x-trusty-client-name", "qa-curl")
4634 .header("x-trusty-client-cwd", "/tmp/qa")
4635 .body(Body::from(body.to_string()))
4636 .unwrap(),
4637 )
4638 .await
4639 .unwrap();
4640 assert_eq!(resp.status(), StatusCode::OK);
4641
4642 let app = router().with_state(state);
4643 let resp = app
4644 .oneshot(
4645 Request::builder()
4646 .uri("/api/v1/palaces/cred-header/drawers?limit=10")
4647 .body(Body::empty())
4648 .unwrap(),
4649 )
4650 .await
4651 .unwrap();
4652 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4653 let v: Value = serde_json::from_slice(&bytes).unwrap();
4654 let tags: Vec<&str> = v[0]["tags"]
4655 .as_array()
4656 .expect("tags")
4657 .iter()
4658 .filter_map(|t| t.as_str())
4659 .collect();
4660 assert!(
4661 tags.contains(&"creator:client=qa-curl"),
4662 "expected custom client tag; got {tags:?}"
4663 );
4664 assert!(
4665 tags.contains(&"creator:cwd=/tmp/qa"),
4666 "expected cwd tag from header; got {tags:?}"
4667 );
4668 }
4669
4670 #[tokio::test]
4679 async fn drawer_creator_attribution_mcp_default() {
4680 let tmp = tempfile::tempdir().expect("tempdir");
4681 let root = tmp.path().to_path_buf();
4682 std::mem::forget(tmp);
4683 let state = AppState::new(root);
4684 let palace = trusty_common::memory_core::Palace {
4685 id: PalaceId::new("cred-mcp"),
4686 name: "cred-mcp".to_string(),
4687 description: None,
4688 created_at: chrono::Utc::now(),
4689 data_dir: state.data_root.join("cred-mcp"),
4690 };
4691 state
4692 .registry
4693 .create_palace(&state.data_root, palace)
4694 .expect("create palace");
4695
4696 let _ = crate::tools::dispatch_tool(
4697 &state,
4698 "memory_remember",
4699 json!({
4700 "palace": "cred-mcp",
4701 "text": "remember a sentence with enough tokens to pass filters please",
4702 "room": "General",
4703 "tags": ["from-test"],
4704 }),
4705 )
4706 .await
4707 .expect("memory_remember dispatch");
4708
4709 let app = router().with_state(state);
4710 let resp = app
4711 .oneshot(
4712 Request::builder()
4713 .uri("/api/v1/palaces/cred-mcp/drawers?limit=10")
4714 .body(Body::empty())
4715 .unwrap(),
4716 )
4717 .await
4718 .unwrap();
4719 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4720 let v: Value = serde_json::from_slice(&bytes).unwrap();
4721 let drawers = v.as_array().expect("drawers array");
4722 assert!(!drawers.is_empty(), "expected at least one drawer");
4723 let tags: Vec<&str> = drawers[0]["tags"]
4724 .as_array()
4725 .expect("tags array")
4726 .iter()
4727 .filter_map(|t| t.as_str())
4728 .collect();
4729 assert!(
4730 tags.contains(&"creator:client=trusty-memory-mcp"),
4731 "expected MCP client tag; got {tags:?}"
4732 );
4733 assert!(
4734 tags.contains(&"creator:source=mcp"),
4735 "expected MCP source tag; got {tags:?}"
4736 );
4737 }
4738
4739 #[tokio::test]
4750 async fn hook_emit_failure_isolated() {
4751 let _guard = crate::commands::env_test_lock().lock().await;
4752 let tmp = tempfile::tempdir().expect("tempdir");
4753 unsafe {
4755 std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
4756 }
4757 let res = crate::commands::prompt_context::handle_prompt_context().await;
4758 unsafe {
4759 std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
4760 }
4761 assert!(
4762 res.is_ok(),
4763 "hook must complete even when daemon emit fails; got {res:?}"
4764 );
4765 }
4766
4767 #[test]
4775 fn decode_triple_id_round_trips() {
4776 let cases = [
4777 ("drawer:some-uuid", "has_tag"),
4778 ("entity:alice", "works_at"),
4779 ("entity:project/foo", "depends_on"),
4780 ("subject", ""),
4782 ("path/to/node", "rel:type:sub"),
4784 ];
4785 for (subject, predicate) in cases {
4786 let encoded = encode_triple_id(subject, predicate);
4787 assert!(
4789 !encoded.contains('+') && !encoded.contains('/') && !encoded.contains('='),
4790 "encoded triple id {encoded:?} is not URL-safe"
4791 );
4792 let (s, p) = decode_triple_id(&encoded)
4793 .unwrap_or_else(|| panic!("decode_triple_id failed for {encoded:?}"));
4794 assert_eq!(s, subject, "subject mismatch for ({subject}, {predicate})");
4795 assert_eq!(
4796 p, predicate,
4797 "predicate mismatch for ({subject}, {predicate})"
4798 );
4799 }
4800 }
4801
4802 #[test]
4806 fn decode_triple_id_returns_none_for_invalid_input() {
4807 assert!(decode_triple_id("not!!valid%%base64").is_none());
4808 use base64::Engine as _;
4810 let no_sep = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"no-separator");
4811 assert!(decode_triple_id(&no_sep).is_none());
4812 }
4813}