trusty_memory/web.rs
1//! HTTP API + embedded SPA shell for the trusty-memory admin UI.
2//!
3//! Why: The web admin panel is the primary GUI for non-MCP clients. Bundling
4//! the Svelte build via `rust-embed` keeps deployment to "drop the binary on
5//! a host"; the JSON API surface mirrors the MCP tool set so anything
6//! trusty-memory can do via Claude Code can also be done via curl or browser.
7//! What: All `/api/v1/*` handlers (status, palaces, drawers, recall, KG,
8//! config, chat) plus an embedded-asset fallback that serves `ui/dist/`.
9//! Test: `cargo test -p trusty-memory-mcp web::tests` covers the asset
10//! fallback and JSON shape of every read endpoint against an in-memory
11//! palace built on a `tempdir`.
12
13use 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
35/// Dedicated palace id used by the `/health` round-trip probe (issue #185).
36///
37/// Why: Earlier revisions of `run_health_round_trip` picked whichever palace
38/// happened to be first on disk (APFS creation order on macOS), which meant
39/// the probe always wrote — and, if recall failed, *leaked* — a drawer in a
40/// real user-facing palace. Routing the probe to a dedicated palace whose id
41/// starts with the reserved `__` prefix means leaked drawers are confined to a
42/// palace the user never sees (filtered by `MemoryService::list_palaces`) and
43/// real palaces stay clean.
44/// What: A constant `&str` reused by the probe and tests. The leading double
45/// underscore is the project-wide convention for "system" palaces hidden from
46/// user listings.
47/// Test: `health_probe_palace_is_invisible`, `health_probe_cleans_up_on_success`,
48/// `health_probe_cleans_up_on_recall_miss`.
49pub(crate) const HEALTH_PROBE_PALACE: &str = "__health_probe__";
50
51/// Embedded UI assets produced by `pnpm build` in `ui/`.
52///
53/// Why: Single-binary deploys with no separate static-file dance. `build.rs`
54/// runs the Vite build before compilation so this folder is always populated.
55/// What: All files under `ui/dist/` are included in the binary.
56/// Test: `serves_index_html` confirms the SPA shell loads.
57#[derive(RustEmbed)]
58// Monorepo migration: upstream trusty-memory put the Svelte UI at the repo
59// root (`ui/dist/`), so the original path was `$CARGO_MANIFEST_DIR/../../ui/dist/`.
60// In the trusty-tools monorepo we keep the UI inside the crate to avoid
61// polluting the workspace root with per-crate asset directories.
62#[folder = "$CARGO_MANIFEST_DIR/ui/dist/"]
63struct WebAssets;
64
65/// Build the public router with API routes + SPA asset fallback.
66///
67/// Why: `run_http` calls this so the same router shape is used in tests.
68/// What: All API routes under `/api/v1`, fallback to the SPA shell.
69/// Test: `serves_index_html` and `status_endpoint_returns_payload`.
70pub fn router() -> Router<AppState> {
71 // axum 0.8 path syntax uses `{param}` instead of `:param`. The shared
72 // `trusty_common::server::with_standard_middleware` layer brings in CORS,
73 // tracing, and gzip (with SSE excluded) so we don't drift from sibling
74 // trusty-* daemons.
75 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 // Issue #70 — `/memories` is a backward-compatible alias for `/drawers`.
94 // Some clients (and earlier docs) POST/GET against `…/memories`, which
95 // 404'd because only `/drawers` was registered. Aliasing here keeps
96 // both vocabularies working against the same handlers without breaking
97 // existing `/drawers` callers.
98 .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 // Issue #99: inter-project messaging.
145 .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 // Issue: fire-and-forget memory save for callers that cannot speak
159 // MCP. Sub-agents spawned via Claude Code's Agent tool inherit no
160 // MCP connections, so `memory_remember` is unreachable to them.
161 // This endpoint lets the agent shell out to `trusty-memory note`
162 // (which in turn POSTs here) and the request returns 202 the moment
163 // the body is parsed — the actual `memory_remember` dispatch runs
164 // on a detached `tokio::spawn`. Failures are logged at warn but
165 // never surface to the caller because the contract is one-way.
166 .route("/api/v1/remember", post(remember_async))
167 // Multi-transport refactor: a single JSON-RPC 2.0 endpoint that
168 // accepts the same envelopes the UDS transport speaks. Lets
169 // browser clients, curl, and the stdio bridge fallback hit the
170 // tool surface without learning the REST routes. The REST
171 // routes above remain for backwards compatibility.
172 .route("/rpc", post(rpc_handler))
173 .fallback(static_handler);
174
175 trusty_common::server::with_standard_middleware(router)
176}
177
178// ---------------------------------------------------------------------------
179// Health check
180// ---------------------------------------------------------------------------
181
182/// Liveness/version payload for `GET /health`.
183///
184/// Why: `daemon_probe` requires an HTTP 200 from `/health` to confirm that the
185/// port is owned by this daemon (and not a stale or foreign process). Issue
186/// #35 enriches it with process resource metrics so operators (and the admin
187/// UI) can see RSS, disk footprint, CPU, and uptime in one cheap call.
188/// What: Carries a fixed `status` string, the compile-time crate version, and
189/// the issue-#35 resource block (`rss_mb`, `disk_bytes`, `cpu_pct`,
190/// `uptime_secs`).
191/// Test: Asserted by `health_endpoint_returns_ok` and
192/// `health_endpoint_includes_resource_fields` in this module's tests.
193#[derive(serde::Serialize)]
194struct HealthResponse {
195 /// `"ok"` when the round-trip smoke test succeeds (or no palace exists
196 /// yet), `"degraded"` when store/recall is broken (issue #71). Owned
197 /// `String` so the handler can report different statuses without
198 /// requiring static lifetimes.
199 status: String,
200 /// Populated only when `status == "degraded"` (issue #71). Carries a
201 /// short phrase identifying which round-trip stage failed so operators
202 /// can triage quickly (e.g. `"store failed: ..."`).
203 #[serde(skip_serializing_if = "Option::is_none")]
204 detail: Option<String>,
205 version: &'static str,
206 /// Current process Resident Set Size in megabytes (issue #35). Sampled
207 /// via the shared `SysMetrics` on each health request.
208 rss_mb: u64,
209 /// On-disk footprint of the daemon's `data_root` in bytes (issue #35):
210 /// the sum of every palace file. Refreshed by a background task every
211 /// 10 s; `0` until the first walk completes.
212 disk_bytes: u64,
213 /// Current process CPU usage as a percentage (issue #35), where `100.0`
214 /// means one fully-saturated core. The first reading after daemon start
215 /// may be `0.0` until a delta window exists.
216 cpu_pct: f32,
217 /// Seconds elapsed since the daemon started (issue #35).
218 uptime_secs: u64,
219 /// Bound `host:port` of the HTTP listener. Why: dynamic port selection
220 /// (7070..=7079 + OS fallback) means clients cannot assume `7070`; this
221 /// field advertises the real port without forcing them to read
222 /// `~/.trusty-memory/http_addr`. `None` when the daemon was constructed
223 /// without ever binding (tests that drive the router with `TestServer`).
224 #[serde(skip_serializing_if = "Option::is_none")]
225 addr: Option<String>,
226}
227
228/// `GET /health` — unauthenticated liveness probe with store/recall smoke test.
229///
230/// Why: Gives `daemon_probe` and external monitors a cheap way to confirm port
231/// ownership without touching palace state. Issue #35 additionally reports
232/// process RSS, CPU, the `data_root` disk footprint, and uptime. Issue #71
233/// upgrades the check to a full memory round-trip (store → recall → verify →
234/// delete) so operators learn about store/recall regressions immediately
235/// instead of after a real request fails. Issue #185 routes the round-trip
236/// to a dedicated `__health_probe__` palace (hidden from user listings) so
237/// the probe never leaks drawers into a real user palace even on recall
238/// failures.
239/// What: Returns HTTP 200 with `{status, version, rss_mb, disk_bytes,
240/// cpu_pct, uptime_secs, detail?}`. RSS + CPU are sampled live; `disk_bytes`
241/// is read from the background ticker; `uptime_secs` is elapsed since
242/// `state.started_at`. The handler provisions the dedicated probe palace if
243/// missing and then attempts a full remember/recall/forget cycle — `status`
244/// is `"ok"` on success, `"degraded"` with a `detail` string explaining the
245/// failing stage otherwise. The probe never returns non-200 so monitors
246/// keyed on HTTP status still see the daemon as up.
247/// Test: `health_endpoint_returns_ok`,
248/// `health_endpoint_includes_resource_fields`,
249/// `health_endpoint_round_trip_on_fresh_install_is_ok`,
250/// `health_endpoint_round_trip_with_palace_is_ok`,
251/// `health_probe_palace_is_invisible`,
252/// `health_probe_cleans_up_on_success`,
253/// `health_probe_cleans_up_on_recall_miss`.
254async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
255 let (rss_mb, cpu_pct) = {
256 let mut metrics = state.sys_metrics.lock().await;
257 metrics.sample()
258 };
259 let disk_bytes = state.disk_bytes.load(std::sync::atomic::Ordering::Relaxed);
260 let uptime_secs = state.started_at.elapsed().as_secs();
261 let addr = state.bound_addr.get().map(|a| a.to_string());
262
263 let (status, detail) = match run_health_round_trip(&state).await {
264 Ok(()) => ("ok".to_string(), None),
265 Err(err) => {
266 tracing::warn!("/health round-trip degraded: {err}");
267 ("degraded".to_string(), Some(err.to_string()))
268 }
269 };
270
271 Json(HealthResponse {
272 status,
273 detail,
274 version: env!("CARGO_PKG_VERSION"),
275 rss_mb,
276 disk_bytes,
277 cpu_pct,
278 uptime_secs,
279 addr,
280 })
281}
282
283/// Stages of the `/health` round-trip that can fail (issue #71).
284///
285/// Why: `thiserror`-derived enum gives every failure point a stable phrase the
286/// handler can render into the `detail` field without printing implementation
287/// detail or full backtraces. Issue #185 dropped the `NoPalaces` and
288/// `ListPalaces` sentinels: the probe now provisions its dedicated
289/// `__health_probe__` palace itself, so neither short-circuit can occur.
290/// What: One variant per stage (open palace, ensure-probe-palace, store,
291/// recall, missing-in-results, delete).
292/// Test: Exercised indirectly by the `health_endpoint_round_trip_*` and
293/// `health_probe_*` tests.
294#[derive(Debug, thiserror::Error)]
295enum HealthProbeError {
296 #[error("open palace failed: {0}")]
297 OpenPalace(String),
298 #[error("provision health probe palace failed: {0}")]
299 EnsureProbePalace(String),
300 #[error("store failed: {0}")]
301 Store(String),
302 #[error("recall failed: {0}")]
303 Recall(String),
304 #[error("recall did not return the probe drawer (id={0})")]
305 ProbeMissing(Uuid),
306 #[error("delete probe drawer failed: {0}")]
307 Delete(String),
308}
309
310/// Ensure the dedicated `__health_probe__` palace exists on disk.
311///
312/// Why: Issue #185 — picking whichever palace `list_palaces` returns first
313/// leaked health-probe drawers into a real user palace whenever recall failed
314/// or returned an empty result. Routing the probe to a dedicated palace whose
315/// id starts with the reserved `__` prefix confines any leak (e.g. a daemon
316/// crash mid-round-trip) to a palace the user can never see. This helper is
317/// idempotent: it is safe to call on every `/health` request, even when the
318/// palace already exists.
319/// What: Calls `PalaceRegistry::open_palace` first (cheap cache hit when the
320/// palace is already registered). If the palace metadata is missing on disk,
321/// creates it via `PalaceRegistry::create_palace` with a description that
322/// flags its purpose. Either path returns success when the palace is ready
323/// for the round-trip; failures propagate as `HealthProbeError::EnsureProbePalace`.
324/// Test: `health_probe_palace_is_invisible`, `health_probe_cleans_up_on_success`,
325/// `health_probe_cleans_up_on_recall_miss`.
326fn ensure_health_probe_palace(state: &AppState) -> Result<(), HealthProbeError> {
327 let id = PalaceId::new(HEALTH_PROBE_PALACE);
328
329 // Fast path: already registered in-memory, no disk hit needed.
330 if state.registry.get(&id).is_some() {
331 return Ok(());
332 }
333
334 // Try to open from disk first — succeeds on every request after the
335 // first one once the palace has been persisted.
336 if state.registry.open_palace(&state.data_root, &id).is_ok() {
337 return Ok(());
338 }
339
340 // Cold path: first run on this `data_root`. Create the palace metadata
341 // on disk so subsequent probes hit the open-path above.
342 let palace = Palace {
343 id: id.clone(),
344 name: HEALTH_PROBE_PALACE.to_string(),
345 description: Some(
346 "Internal health-probe palace (issue #185). Hidden from listings; \
347 holds short-lived round-trip drawers cleaned up on every probe."
348 .to_string(),
349 ),
350 created_at: chrono::Utc::now(),
351 data_dir: state.data_root.join(HEALTH_PROBE_PALACE),
352 };
353 state
354 .registry
355 .create_palace(&state.data_root, palace)
356 .map_err(|e| HealthProbeError::EnsureProbePalace(format!("{e:#}")))?;
357 Ok(())
358}
359
360/// Execute a remember/recall/forget cycle against the dedicated probe palace.
361///
362/// Why: `/health` used to return `status: "ok"` even when `POST /drawers` or
363/// the recall path was broken — only that the process was alive. Issue #71
364/// asks the probe to actually exercise the store and recall service layer
365/// (no HTTP loopback) so monitors detect data-plane regressions on the next
366/// poll instead of waiting for a real client to surface them. Issue #185
367/// additionally requires the probe to (a) never touch user-facing palaces and
368/// (b) never leak drawers even when recall fails or returns an empty result.
369/// What: Provisions the dedicated `__health_probe__` palace via
370/// [`ensure_health_probe_palace`], opens its handle, stores a content-unique
371/// probe drawer via `PalaceHandle::remember`, runs
372/// `recall_with_default_embedder` with the probe phrase, and then **always**
373/// attempts `PalaceHandle::forget` *before* propagating any recall error so a
374/// failing recall (Err *or* empty result) can never leave a drawer behind.
375/// The probe palace is hidden from `MemoryService::list_palaces`, so any rare
376/// leak (e.g. mid-call daemon crash) is confined to a palace the user can't see.
377/// Test: Indirect — `health_endpoint_round_trip_with_palace_is_ok`,
378/// `health_endpoint_round_trip_on_fresh_install_is_ok`, plus the three
379/// `health_probe_*` cleanup tests added for issue #185.
380async fn run_health_round_trip(state: &AppState) -> Result<(), HealthProbeError> {
381 // Issue #185: always use the dedicated probe palace. Provision it on the
382 // first request so a fresh install with zero user palaces still exercises
383 // the full data plane — no more `NoPalaces` short-circuit.
384 ensure_health_probe_palace(state)?;
385 let probe_id = PalaceId::new(HEALTH_PROBE_PALACE);
386 let handle = state
387 .registry
388 .open_palace(&state.data_root, &probe_id)
389 .map_err(|e| HealthProbeError::OpenPalace(format!("{e:#}")))?;
390
391 // Delegate the cleanup-ordering logic to the testable helper so unit tests
392 // can substitute the recall implementation. The real handler always uses
393 // the shared ONNX embedder.
394 run_health_round_trip_inner(handle, |handle, query| async move {
395 recall_with_default_embedder(&handle, &query, 5)
396 .await
397 .map_err(|e| HealthProbeError::Recall(format!("{e:#}")))
398 })
399 .await
400}
401
402/// Store-recall-forget core that always cleans up the probe drawer.
403///
404/// Why: Issue #185 — the cleanup invariant ("the probe drawer is always
405/// deleted before any error returns") is the central correctness property of
406/// the health round-trip. Splitting it out from `run_health_round_trip` lets
407/// the tests inject a recall stub that returns `Ok(empty)` or
408/// `Err(Recall(...))` and prove the invariant directly, without relying on
409/// the ONNX embedder.
410/// What: Stores a content-unique probe drawer via `PalaceHandle::remember`,
411/// invokes `recall` with the probe phrase, and then **always** calls
412/// `PalaceHandle::forget` *before* propagating any recall error. The recall
413/// result is evaluated after the forget so a missing or errored recall can
414/// never leave a drawer behind. Cleanup errors are reported only when recall
415/// succeeded; otherwise the upstream recall error is preserved as the root
416/// cause for operators.
417/// Test: `health_probe_cleans_up_on_recall_miss` and
418/// `health_probe_cleans_up_on_recall_error` exercise both failure modes with
419/// a stubbed recall; `health_probe_cleans_up_on_success` covers the happy path.
420async fn run_health_round_trip_inner<F, Fut>(
421 handle: std::sync::Arc<trusty_common::memory_core::PalaceHandle>,
422 recall: F,
423) -> Result<(), HealthProbeError>
424where
425 F: FnOnce(std::sync::Arc<trusty_common::memory_core::PalaceHandle>, String) -> Fut,
426 Fut: std::future::Future<
427 Output = Result<Vec<trusty_common::memory_core::retrieval::RecallResult>, HealthProbeError>,
428 >,
429{
430 // Content-unique probe phrase. `__trusty_memory_healthcheck__` makes the
431 // probe identifiable in logs / drawer dumps if a forget step is ever
432 // skipped (e.g. handler panic between store and delete); the UUID
433 // guarantees uniqueness across concurrent probes.
434 let probe_token = Uuid::new_v4();
435 let probe_content = format!("__trusty_memory_healthcheck__ probe {probe_token}");
436
437 let drawer_id = handle
438 .remember(
439 probe_content.clone(),
440 RoomType::General,
441 vec!["healthcheck".to_string()],
442 0.0,
443 )
444 .await
445 .map_err(|e| HealthProbeError::Store(format!("{e:#}")))?;
446
447 let recall_result = recall(handle.clone(), probe_content).await;
448
449 // Issue #185: cleanup runs BEFORE we propagate any recall error so the
450 // probe can never leave a drawer behind. Both the Err and the
451 // empty-result failure modes used to bypass forget; this ordering closes
452 // both holes. Cleanup errors are surfaced only when the recall path
453 // itself succeeded; otherwise we preserve the upstream recall failure as
454 // the root cause for operators.
455 let delete_result = handle.forget(drawer_id).await;
456
457 match recall_result {
458 Ok(hits) => {
459 if !hits.iter().any(|hit| hit.drawer.id == drawer_id) {
460 return Err(HealthProbeError::ProbeMissing(drawer_id));
461 }
462 }
463 Err(e) => return Err(e),
464 }
465
466 delete_result.map_err(|e| HealthProbeError::Delete(format!("{e:#}")))?;
467 Ok(())
468}
469
470// ---------------------------------------------------------------------------
471// Logs tail + admin stop (issue #35)
472// ---------------------------------------------------------------------------
473
474/// Default number of log lines returned by `GET /api/v1/logs/tail` when `n`
475/// is absent. 100 lines is enough context for a glance without a huge payload.
476const DEFAULT_LOGS_TAIL_N: usize = 100;
477
478/// Hard ceiling on `GET /api/v1/logs/tail?n=` — equal to the ring-buffer
479/// capacity, so a request can never ask for more lines than the buffer holds.
480const MAX_LOGS_TAIL_N: usize = trusty_common::log_buffer::DEFAULT_LOG_CAPACITY;
481
482fn default_logs_tail_n() -> usize {
483 DEFAULT_LOGS_TAIL_N
484}
485
486/// Query parameters for `GET /api/v1/logs/tail`.
487///
488/// Why (issue #35): callers ask for a bounded number of recent log lines;
489/// `n` defaults to a useful page size and is clamped server-side so a
490/// misconfigured client cannot request more lines than the buffer holds.
491/// What: `n` is optional; absent → [`DEFAULT_LOGS_TAIL_N`]. Clamped to
492/// `[1, MAX_LOGS_TAIL_N]` in the handler.
493/// Test: `logs_tail_clamps_n` exercises the clamp.
494#[derive(serde::Deserialize)]
495struct LogsTailParams {
496 #[serde(default = "default_logs_tail_n")]
497 n: usize,
498}
499
500/// `GET /api/v1/logs/tail?n=200` — return the most recent N tracing log lines.
501///
502/// Why (issue #35): operators debugging a running daemon want recent logs
503/// over HTTP without SSHing to the box or restarting with a different
504/// `RUST_LOG`. The in-memory ring buffer (fed by the `LogBufferLayer` wired
505/// into the subscriber at startup) makes this near-free.
506/// What: clamps `n` to `[1, MAX_LOGS_TAIL_N]`, drains the tail of
507/// `state.log_buffer`, and returns `{ "lines": [...], "total": <buffered> }`
508/// where `total` is the number of lines currently buffered (so callers can
509/// tell whether the ring has wrapped).
510/// Test: `logs_tail_returns_recent_lines` and `logs_tail_clamps_n`.
511async fn logs_tail(
512 State(state): State<AppState>,
513 Query(params): Query<LogsTailParams>,
514) -> Json<Value> {
515 let n = params.n.clamp(1, MAX_LOGS_TAIL_N);
516 let lines = state.log_buffer.tail(n);
517 Json(serde_json::json!({
518 "lines": lines,
519 "total": state.log_buffer.len(),
520 }))
521}
522
523/// `POST /api/v1/admin/stop` — request a graceful shutdown of the daemon.
524///
525/// Why (issue #35): the admin UI and operators want a one-call way to stop
526/// the daemon without resolving its PID and sending a signal. The daemon is
527/// localhost-only and trusts every caller, so no auth is required.
528/// What: spawns a detached task that sleeps 200 ms (giving this HTTP response
529/// time to flush to the client) and then calls `std::process::exit(0)`.
530/// Returns `{ "ok": true, "message": "shutting down" }` immediately.
531/// Test: `admin_stop_returns_ok` asserts the response shape (it does not
532/// drive the real exit — that would terminate the test process).
533async fn admin_stop(State(_state): State<AppState>) -> Json<Value> {
534 tracing::warn!("admin_stop: shutdown requested via POST /api/v1/admin/stop");
535 tokio::spawn(async {
536 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
537 std::process::exit(0);
538 });
539 Json(serde_json::json!({ "ok": true, "message": "shutting down" }))
540}
541
542// ---------------------------------------------------------------------------
543// Fire-and-forget memory save (`POST /api/v1/remember`)
544// ---------------------------------------------------------------------------
545
546/// Request body for `POST /api/v1/remember`.
547///
548/// Why: agents spawned via Claude Code's Agent tool do not inherit any MCP
549/// connections, so the `memory_remember` MCP tool is unreachable to them.
550/// Exposing a plain HTTP entry point lets those agents shell out via the
551/// `trusty-memory note` CLI (or any `curl` call) without learning MCP.
552/// What: `content` is the drawer body and is required; `palace` falls back
553/// to the daemon's `--palace` default when omitted; `tags` is optional and
554/// passed through verbatim to the underlying handler.
555/// Test: `remember_async_*` tests in this module.
556#[derive(Debug, Deserialize)]
557struct RememberAsyncBody {
558 /// Drawer body. Required.
559 content: String,
560 /// Target palace. When `None`, the daemon's `--palace` default is used;
561 /// callers without a default-palace configured must pass this field or
562 /// the spawned task logs a warning and drops the request.
563 #[serde(default)]
564 palace: Option<String>,
565 /// Optional tag list to attach to the drawer.
566 #[serde(default)]
567 tags: Option<Vec<String>>,
568}
569
570/// `POST /api/v1/remember` — fire-and-forget memory save.
571///
572/// Why: sub-agents spawned via Claude Code's Agent tool have no MCP
573/// connection (MCP servers are not inherited across sub-agent spawns), so
574/// they cannot invoke `mcp__trusty-memory__memory_remember` directly. They
575/// can, however, run shell commands — this endpoint plus the
576/// `trusty-memory note` CLI gives them a writable handle that needs no
577/// MCP plumbing. The contract is one-way: the request is parsed, validated,
578/// and queued on a `tokio::spawn`, then `202 Accepted` is returned
579/// immediately. Failures during the spawned dispatch (palace not found,
580/// content gate skip, redb error) are logged at `warn` but never propagate
581/// back to the caller because the agent has already exited by then.
582/// What: deserialises the body, rejects an empty `content` with 400 (the
583/// only synchronous validation), then maps `{content, palace, tags}` →
584/// `{text, palace, tags}` (the field names `handle_memory_remember`
585/// expects) and dispatches `memory_remember` from a detached task. Returns
586/// `202 Accepted` with `{"status":"queued"}`.
587/// Test: `remember_async_returns_202_and_persists` (happy path) and
588/// `remember_async_rejects_empty_content` (input validation).
589async fn remember_async(
590 State(state): State<AppState>,
591 Json(body): Json<RememberAsyncBody>,
592) -> Result<(StatusCode, Json<Value>), ApiError> {
593 let content = body.content.trim();
594 if content.is_empty() {
595 return Err(ApiError::bad_request(
596 "remember: 'content' must be a non-empty string",
597 ));
598 }
599
600 // Build the MCP-shaped args once on the request thread so deserialisation
601 // errors never end up swallowed by the spawned task. `handle_memory_remember`
602 // expects `text` (not `content`), so translate the field name here.
603 let mut args = serde_json::Map::new();
604 args.insert("text".to_string(), Value::String(content.to_string()));
605 if let Some(p) = body.palace {
606 args.insert("palace".to_string(), Value::String(p));
607 }
608 if let Some(tags) = body.tags {
609 args.insert(
610 "tags".to_string(),
611 Value::Array(tags.into_iter().map(Value::String).collect()),
612 );
613 }
614 let args = Value::Object(args);
615
616 let state_for_task = state.clone();
617 tokio::spawn(async move {
618 match crate::tools::dispatch_tool(&state_for_task, "memory_remember", args).await {
619 Ok(v) => {
620 tracing::debug!(target: "trusty_memory::remember_async", result = %v, "queued remember succeeded");
621 }
622 Err(e) => {
623 tracing::warn!(
624 target: "trusty_memory::remember_async",
625 error = %format!("{e:#}"),
626 "queued remember failed (caller already returned 202)",
627 );
628 }
629 }
630 });
631
632 Ok((StatusCode::ACCEPTED, Json(json!({ "status": "queued" }))))
633}
634
635// ---------------------------------------------------------------------------
636// Activity log (issue #96)
637// ---------------------------------------------------------------------------
638
639/// Default page size returned by `GET /api/v1/activity` when the client
640/// omits `limit`. Matches the existing 50-row dashboard window.
641const ACTIVITY_DEFAULT_LIMIT: usize = 50;
642
643/// Hard cap on a single activity-page response.
644///
645/// Why: bounds the per-request work the handler performs and the response
646/// size on the wire. The UI never asks for more than a screen's worth at a
647/// time; this leaves headroom for power users running curl.
648/// What: 500 entries — large enough for ad-hoc inspection without becoming
649/// a DoS lever.
650/// Test: `activity_endpoint_clamps_limit`.
651const ACTIVITY_MAX_LIMIT: usize = 500;
652
653/// Query parameters accepted by `GET /api/v1/activity`.
654///
655/// Why: serde-driven extraction keeps the handler signature small while
656/// validating shape (numeric/ISO timestamps, optional fields). All filter
657/// fields are optional and combine with AND.
658/// What: see [`ActivityFilter`] for the underlying filter semantics.
659/// Test: `activity_endpoint_lists_recent_emits`,
660/// `activity_endpoint_filters_by_source_and_palace`.
661#[derive(Deserialize, Debug, Default)]
662struct ActivityQuery {
663 #[serde(default)]
664 limit: Option<usize>,
665 #[serde(default)]
666 offset: Option<usize>,
667 #[serde(default)]
668 palace: Option<String>,
669 #[serde(default)]
670 source: Option<String>,
671 #[serde(default)]
672 since: Option<String>,
673 #[serde(default)]
674 until: Option<String>,
675}
676
677/// Wire shape for one row in the `GET /api/v1/activity` response.
678///
679/// Why: the persisted `ActivityEntry` carries a JSON-encoded `payload`
680/// string so the schema is decoupled from `DaemonEvent` evolution; we
681/// re-decode the payload to a `Value` here so the UI receives a structured
682/// JSON object instead of a nested escaped string.
683/// What: same fields as `ActivityEntry` except `payload` is the parsed
684/// JSON `Value` (falls back to a string when parse fails).
685/// Test: `activity_endpoint_lists_recent_emits`.
686#[derive(Serialize, Debug)]
687struct ActivityRow {
688 id: u64,
689 timestamp: chrono::DateTime<chrono::Utc>,
690 source: &'static str,
691 #[serde(skip_serializing_if = "Option::is_none")]
692 palace_id: Option<String>,
693 event_type: String,
694 payload: Value,
695}
696
697/// `GET /api/v1/activity` — paginated activity history (issue #96).
698///
699/// Why: the dashboard activity feed (`ActivityFeed.svelte`) used to be a
700/// pure live-stream — opening the UI rendered an empty pane. Returning a
701/// paginated history lets the UI seed the feed on mount and load more on
702/// scroll, then layer the SSE live-tail on top.
703/// What: clamps `limit` to [1, [`ACTIVITY_MAX_LIMIT`]], parses optional
704/// filters, and queries the persistent log. The response shape is
705/// `{ entries: [...], total, limit, offset }` so the UI can decide
706/// whether more rows exist.
707/// Test: `activity_endpoint_lists_recent_emits`,
708/// `activity_endpoint_clamps_limit`,
709/// `activity_endpoint_filters_by_source_and_palace`.
710async fn activity_handler(
711 State(state): State<AppState>,
712 Query(q): Query<ActivityQuery>,
713) -> Result<Json<Value>, ApiError> {
714 let limit = q
715 .limit
716 .unwrap_or(ACTIVITY_DEFAULT_LIMIT)
717 .clamp(1, ACTIVITY_MAX_LIMIT);
718 let offset = q.offset.unwrap_or(0);
719
720 let source = match q.source.as_deref() {
721 Some(s) => match ActivitySource::parse(s) {
722 Some(parsed) => Some(parsed),
723 None => {
724 return Err(ApiError::bad_request(format!(
725 "unknown source '{s}'; expected one of http, mcp, hook",
726 )))
727 }
728 },
729 None => None,
730 };
731
732 let since = parse_iso_or_bad_request(q.since.as_deref(), "since")?;
733 let until = parse_iso_or_bad_request(q.until.as_deref(), "until")?;
734
735 let filter = ActivityFilter {
736 palace_id: q.palace.filter(|s| !s.is_empty()),
737 source,
738 since,
739 until,
740 };
741
742 let entries = state
743 .activity_log
744 .list(&filter, limit, offset)
745 .map_err(|e| ApiError::internal(format!("activity list: {e:#}")))?;
746 let total = state
747 .activity_log
748 .count()
749 .map_err(|e| ApiError::internal(format!("activity count: {e:#}")))?;
750
751 let rows: Vec<ActivityRow> = entries
752 .into_iter()
753 .map(|e| {
754 let payload = serde_json::from_str::<Value>(&e.payload)
755 .unwrap_or_else(|_| Value::String(e.payload.clone()));
756 ActivityRow {
757 id: e.id,
758 timestamp: e.timestamp,
759 source: e.source.as_str(),
760 palace_id: e.palace_id,
761 event_type: e.event_type,
762 payload,
763 }
764 })
765 .collect();
766
767 Ok(Json(json!({
768 "entries": rows,
769 "total": total,
770 "limit": limit,
771 "offset": offset,
772 })))
773}
774
775/// `POST /api/v1/activity/hook` — ingest a hook firing for the activity feed.
776///
777/// Why: Claude Code's hooks (`UserPromptSubmit` → `prompt-context`,
778/// `SessionStart` → `inbox-check`) run as ephemeral CLI subprocesses with no
779/// in-process access to `AppState`. Without an ingestion endpoint they had no
780/// way to populate the activity feed, which left the TUI feed empty for any
781/// session whose only daemon traffic was hooks. This endpoint accepts the
782/// hook's self-reported payload and forwards it to `state.emit` so the same
783/// persistence + SSE broadcast pipeline that handles `DrawerAdded`/etc. also
784/// covers `HookFired`.
785/// What: deserialises a [`HookEventPayload`], maps it onto a
786/// `DaemonEvent::HookFired` with `source = ActivitySource::Hook`, hands it to
787/// `state.emit`, and returns `204 No Content`. Errors only happen for
788/// malformed JSON — handled by axum's own `Json` rejection.
789/// Test: `hook_activity_endpoint_appends_to_activity_log`.
790async fn hook_activity_handler(
791 State(state): State<AppState>,
792 Json(payload): Json<HookEventPayload>,
793) -> Result<StatusCode, ApiError> {
794 state.emit(DaemonEvent::HookFired {
795 palace_id: payload.palace_id,
796 palace_name: payload.palace_name,
797 hook_type: payload.hook_type,
798 injection_kind: payload.injection_kind,
799 injection_length: payload.injection_length,
800 trigger_prompt_excerpt: payload.trigger_prompt_excerpt,
801 timestamp: chrono::Utc::now(),
802 duration_ms: payload.duration_ms,
803 source: ActivitySource::Hook,
804 });
805 Ok(StatusCode::NO_CONTENT)
806}
807
808/// `POST /rpc` — JSON-RPC 2.0 dispatch endpoint.
809///
810/// Why: the multi-transport refactor needs a single HTTP route that
811/// accepts the same envelopes the UDS transport speaks. Browser
812/// clients that want the new tool surface (or third-party scripts
813/// that prefer JSON-RPC to REST) can POST a request envelope here
814/// and get a response back without learning the per-tool REST
815/// vocabulary. The existing `/api/v1/*` REST routes continue to work
816/// unchanged — this is purely additive.
817/// What: deserialises a [`JsonRpcRequest`] from the request body,
818/// calls [`crate::transport::rpc::dispatch`], and returns the
819/// [`JsonRpcResponse`] as JSON. Always returns HTTP 200 with the
820/// envelope inside (JSON-RPC errors are carried in the `error`
821/// field, not the HTTP status). Returns HTTP 400 only on JSON
822/// deserialisation failure of the outer envelope.
823/// Test: `http_rpc_endpoint_roundtrip` in `web::tests`.
824async fn rpc_handler(
825 State(state): State<AppState>,
826 Json(req): Json<crate::transport::rpc::JsonRpcRequest>,
827) -> Json<crate::transport::rpc::JsonRpcResponse> {
828 let resp = crate::transport::rpc::dispatch(&state, req).await;
829 Json(resp)
830}
831
832/// Extract a [`CreatorInfo`] for an HTTP write request.
833///
834/// Why: every HTTP write path (drawers, messages) must attach
835/// attribution tags so operators can trace which client wrote which
836/// drawer. Centralising the extraction here keeps the `X-Trusty-Client-*`
837/// header contract in one place.
838/// What: pulls `X-Trusty-Client-Name` (default
839/// [`HTTP_DEFAULT_CLIENT`]) and the optional `X-Trusty-Client-Cwd`
840/// header off the request, then builds a `CreatorInfo` with
841/// `source = Http` and the current daemon crate version.
842/// Test: `drawer_creator_attribution_http_default`,
843/// `drawer_creator_attribution_http_header`.
844pub(crate) fn creator_info_from_http(headers: &HeaderMap) -> CreatorInfo {
845 let client = headers
846 .get(X_TRUSTY_CLIENT_NAME)
847 .and_then(|v| v.to_str().ok())
848 .map(|s| s.trim())
849 .filter(|s| !s.is_empty())
850 .unwrap_or(HTTP_DEFAULT_CLIENT)
851 .to_string();
852 let cwd = headers
853 .get(X_TRUSTY_CLIENT_CWD)
854 .and_then(|v| v.to_str().ok())
855 .map(|s| s.to_string())
856 .filter(|s| !s.is_empty());
857 CreatorInfo {
858 client,
859 version: env!("CARGO_PKG_VERSION").to_string(),
860 source: CreatorSource::Http,
861 cwd,
862 }
863}
864
865/// Parse an optional ISO-8601 timestamp string for the activity filter.
866///
867/// Why: the `since` / `until` query params are user-supplied; a bad value
868/// should reject the request with a clear 400 rather than be silently
869/// dropped (which would return seemingly-correct but mis-filtered data).
870/// What: returns `Ok(None)` when the input is `None` or empty;
871/// `Ok(Some(_))` on a parseable RFC 3339 timestamp; `Err(ApiError::bad_request)`
872/// otherwise.
873/// Test: `activity_endpoint_lists_recent_emits` exercises the happy path
874/// (no timestamps); a bad timestamp returns 400 — see manual curl.
875fn parse_iso_or_bad_request(
876 s: Option<&str>,
877 field: &str,
878) -> Result<Option<chrono::DateTime<chrono::Utc>>, ApiError> {
879 match s {
880 None | Some("") => Ok(None),
881 Some(raw) => chrono::DateTime::parse_from_rfc3339(raw)
882 .map(|dt| Some(dt.with_timezone(&chrono::Utc)))
883 .map_err(|e| ApiError::bad_request(format!("invalid {field} (RFC 3339): {e}"))),
884 }
885}
886
887// ---------------------------------------------------------------------------
888// Static asset serving
889// ---------------------------------------------------------------------------
890
891/// Serve any embedded asset; fall back to `index.html` for SPA routes.
892///
893/// Why: Hash-based routing lives client-side, but `/assets/foo.js` etc. must
894/// resolve to the embedded file directly.
895/// What: Looks up the request path under `WebAssets`; if absent, returns
896/// `index.html`. Unknown paths under `/api/` return 404.
897/// Test: `serves_index_html`, `serves_static_asset`, `unknown_api_404`.
898async fn static_handler(req: Request<Body>) -> Response {
899 let path = req.uri().path().trim_start_matches('/').to_string();
900
901 if path.starts_with("api/") {
902 return (StatusCode::NOT_FOUND, "not found").into_response();
903 }
904
905 serve_embedded(&path).unwrap_or_else(|| {
906 // SPA fallback.
907 serve_embedded("index.html")
908 .unwrap_or_else(|| (StatusCode::NOT_FOUND, "ui assets missing").into_response())
909 })
910}
911
912fn serve_embedded(path: &str) -> Option<Response> {
913 let path = if path.is_empty() { "index.html" } else { path };
914 let asset = WebAssets::get(path)?;
915 let mime = mime_guess::from_path(path).first_or_octet_stream();
916 let body = Body::from(asset.data.into_owned());
917 let mut resp = Response::new(body);
918 resp.headers_mut().insert(
919 header::CONTENT_TYPE,
920 HeaderValue::from_str(mime.as_ref())
921 .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
922 );
923 Some(resp)
924}
925
926// ---------------------------------------------------------------------------
927// /api/v1/status, /api/v1/config
928// ---------------------------------------------------------------------------
929
930pub(crate) use crate::service::StatusPayload;
931
932async fn status(State(state): State<AppState>) -> Json<StatusPayload> {
933 Json(crate::service::MemoryService::new(state).status().await)
934}
935
936#[derive(Serialize)]
937struct ConfigPayload {
938 openrouter_configured: bool,
939 model: String,
940 data_root: String,
941}
942
943async fn config(State(state): State<AppState>) -> Json<ConfigPayload> {
944 let cfg = load_user_config().unwrap_or_default();
945 Json(ConfigPayload {
946 openrouter_configured: !cfg.openrouter_api_key.is_empty(),
947 model: cfg.openrouter_model,
948 data_root: state.data_root.display().to_string(),
949 })
950}
951
952pub(crate) use crate::service::load_user_config;
953#[allow(unused_imports)]
954pub(crate) use crate::service::LoadedUserConfig;
955
956// ---------------------------------------------------------------------------
957// /api/v1/palaces
958// ---------------------------------------------------------------------------
959
960pub(crate) use crate::service::{palace_info_from, CreatePalaceBody, PalaceInfo};
961
962async fn list_palaces(State(state): State<AppState>) -> Result<Json<Vec<PalaceInfo>>, ApiError> {
963 Ok(Json(
964 crate::service::MemoryService::new(state)
965 .list_palaces()
966 .await?,
967 ))
968}
969
970async fn create_palace(
971 State(state): State<AppState>,
972 Json(body): Json<CreatePalaceBody>,
973) -> Result<Json<Value>, ApiError> {
974 let id = crate::service::MemoryService::new(state)
975 .create_palace(body, ActivitySource::Http)
976 .await?;
977 Ok(Json(json!({ "id": id })))
978}
979
980async fn get_palace_handler(
981 State(state): State<AppState>,
982 AxumPath(id): AxumPath<String>,
983) -> Result<Json<PalaceInfo>, ApiError> {
984 Ok(Json(
985 crate::service::MemoryService::new(state)
986 .get_palace(&id)
987 .await?,
988 ))
989}
990
991/// Query parameters for `DELETE /api/v1/palaces/{id}`.
992///
993/// Why: Issue #180 — `force=true` is the explicit opt-in to delete a
994/// palace that still has drawers. Defaulting to `false` keeps the
995/// "must be empty" guard active when callers omit the flag.
996/// What: a single optional bool that the handler unwraps to `false`.
997/// Test: `delete_palace_refuses_when_drawers_present`,
998/// `delete_palace_force_removes_populated_palace`.
999#[derive(Deserialize, Default)]
1000struct DeletePalaceQuery {
1001 #[serde(default)]
1002 force: Option<bool>,
1003}
1004
1005/// `DELETE /api/v1/palaces/{id}?force=<bool>` — drop an entire palace.
1006///
1007/// Why: Issue #180 — operators need a single call to clean up a palace
1008/// they no longer want. The legacy drawer-by-drawer delete path is too
1009/// noisy and leaves the palace's KG / vector index behind.
1010/// What: delegates to `MemoryService::delete_palace`. Returns
1011/// `204 No Content` on success, `404 Not Found` when the id is unknown,
1012/// and `409 Conflict` when the palace still has drawers and `force` is
1013/// not set. Other failures bubble up as 500.
1014/// Test: `delete_palace_removes_dir_when_empty`,
1015/// `delete_palace_refuses_when_drawers_present`,
1016/// `delete_palace_force_removes_populated_palace`,
1017/// `delete_palace_returns_not_found_for_missing_id`.
1018async fn delete_palace_handler(
1019 State(state): State<AppState>,
1020 AxumPath(id): AxumPath<String>,
1021 Query(q): Query<DeletePalaceQuery>,
1022) -> Result<StatusCode, ApiError> {
1023 crate::service::MemoryService::new(state)
1024 .delete_palace(&id, q.force.unwrap_or(false))
1025 .await?;
1026 Ok(StatusCode::NO_CONTENT)
1027}
1028
1029/// Request body for `PATCH /api/v1/palaces/{id}`.
1030///
1031/// Why: The only mutable palace metadata exposed today is the display name;
1032/// keeping the body to a single field keeps the wire contract obvious and
1033/// lets us extend later without breaking older clients (additive fields
1034/// only). Issue #180 follow-up.
1035/// What: a single required `name` string. Empty / whitespace-only values
1036/// are rejected with 400 by the handler.
1037/// Test: `update_palace_name_renames_palace`,
1038/// `update_palace_name_rejects_empty_name`.
1039#[derive(Deserialize)]
1040struct UpdatePalaceBody {
1041 name: String,
1042}
1043
1044/// `PATCH /api/v1/palaces/{id}` — rename a palace's display name.
1045///
1046/// Why: Issue #180 follow-up — operators need to relabel palaces without
1047/// re-creating them (which would lose all stored drawers / KG / vectors).
1048/// Only the human-readable `name` changes; the directory name (which is the
1049/// palace id) is immutable.
1050/// What: delegates to `MemoryService::update_palace_name_typed`. Returns
1051/// `200 OK` with the updated palace info on success, `404 Not Found` when
1052/// the id is unknown, and `400 Bad Request` when the supplied name is
1053/// empty after trimming.
1054/// Test: `update_palace_name_renames_palace`,
1055/// `update_palace_name_rejects_empty_name`,
1056/// `update_palace_name_returns_not_found_for_missing_id`.
1057async fn update_palace_handler(
1058 State(state): State<AppState>,
1059 AxumPath(id): AxumPath<String>,
1060 Json(body): Json<UpdatePalaceBody>,
1061) -> Result<Json<Value>, ApiError> {
1062 let value = crate::service::MemoryService::new(state)
1063 .update_palace_name_typed(&id, &body.name)
1064 .await?;
1065 Ok(Json(value))
1066}
1067
1068// ---------------------------------------------------------------------------
1069// Drawers
1070// ---------------------------------------------------------------------------
1071
1072pub(crate) use crate::service::{CreateDrawerBody, ListDrawersQuery};
1073
1074async fn list_drawers(
1075 State(state): State<AppState>,
1076 AxumPath(id): AxumPath<String>,
1077 Query(q): Query<ListDrawersQuery>,
1078) -> Result<Json<Value>, ApiError> {
1079 Ok(Json(
1080 crate::service::MemoryService::new(state)
1081 .list_drawers(&id, q)
1082 .await?,
1083 ))
1084}
1085
1086async fn create_drawer(
1087 State(state): State<AppState>,
1088 AxumPath(id): AxumPath<String>,
1089 headers: HeaderMap,
1090 Json(body): Json<CreateDrawerBody>,
1091) -> Result<Json<Value>, ApiError> {
1092 let creator = creator_info_from_http(&headers);
1093 let drawer_id = crate::service::MemoryService::new(state)
1094 .create_drawer(&id, body, creator, ActivitySource::Http)
1095 .await?;
1096 Ok(Json(json!({ "id": drawer_id })))
1097}
1098
1099async fn delete_drawer(
1100 State(state): State<AppState>,
1101 AxumPath((id, drawer_id)): AxumPath<(String, String)>,
1102) -> Result<StatusCode, ApiError> {
1103 crate::service::MemoryService::new(state)
1104 .delete_drawer(&id, &drawer_id, ActivitySource::Http)
1105 .await?;
1106 Ok(StatusCode::NO_CONTENT)
1107}
1108
1109// Why: this shim previously bridged `tools.rs` callers into the service
1110// implementation, but issue #226 moved those callers to use
1111// `crate::service::MemoryService::new(...).aggregate_status_event()`
1112// directly so the call site does not require the `axum-server`
1113// feature. Removing the shim eliminates a dead-code warning.
1114
1115// ---------------------------------------------------------------------------
1116// Recall
1117// ---------------------------------------------------------------------------
1118
1119#[derive(Deserialize)]
1120struct RecallQuery {
1121 q: String,
1122 #[serde(default)]
1123 top_k: Option<usize>,
1124 #[serde(default)]
1125 deep: Option<bool>,
1126}
1127
1128async fn recall_handler(
1129 State(state): State<AppState>,
1130 AxumPath(id): AxumPath<String>,
1131 Query(q): Query<RecallQuery>,
1132) -> Result<Json<Value>, ApiError> {
1133 Ok(Json(
1134 crate::service::MemoryService::new(state)
1135 .recall(&id, &q.q, q.top_k.unwrap_or(10), q.deep.unwrap_or(false))
1136 .await?,
1137 ))
1138}
1139
1140#[allow(unused_imports)]
1141pub(crate) use crate::service::recall_entry_json;
1142
1143/// `GET /api/v1/recall?q=<query>&top_k=<n>&deep=<bool>` — cross-palace semantic
1144/// search.
1145///
1146/// Why: Agents and dashboard widgets often need the most relevant memories
1147/// regardless of palace boundary; forcing the caller to issue one request per
1148/// palace and merge client-side is both slower (no fan-out) and wrong (no
1149/// dedup/rerank). Serving the merged top-k from the daemon collapses the
1150/// round-trip and reuses the shared embedder singleton.
1151/// What: Lists all palaces, opens each (skipping any that fail to open with a
1152/// warning), and delegates to `execute_recall_all`. Returns a JSON array of
1153/// `{ palace_id, drawer, score, layer }` entries sorted by score descending.
1154/// Test: Exercised via `execute_recall_all` directly and through the MCP
1155/// `memory_recall_all` tool dispatch.
1156async fn recall_all_handler(
1157 State(state): State<AppState>,
1158 Query(q): Query<RecallQuery>,
1159) -> Result<Json<Value>, ApiError> {
1160 let value = crate::service::MemoryService::new(state)
1161 .recall_all(&q.q, q.top_k.unwrap_or(10), q.deep.unwrap_or(false))
1162 .await;
1163 if let Some(err) = value.get("error").and_then(|v| v.as_str()) {
1164 return Err(ApiError::internal(err.to_string()));
1165 }
1166 Ok(Json(value))
1167}
1168
1169// ---------------------------------------------------------------------------
1170// Knowledge Graph
1171// ---------------------------------------------------------------------------
1172
1173#[derive(Deserialize)]
1174struct KgQueryParams {
1175 subject: String,
1176}
1177
1178async fn kg_query(
1179 State(state): State<AppState>,
1180 AxumPath(id): AxumPath<String>,
1181 Query(q): Query<KgQueryParams>,
1182) -> Result<Json<Vec<Triple>>, ApiError> {
1183 Ok(Json(
1184 crate::service::MemoryService::new(state)
1185 .kg_query(&id, &q.subject)
1186 .await?,
1187 ))
1188}
1189
1190pub(crate) use crate::service::KgAssertBody;
1191
1192async fn kg_assert(
1193 State(state): State<AppState>,
1194 AxumPath(id): AxumPath<String>,
1195 Json(body): Json<KgAssertBody>,
1196) -> Result<StatusCode, ApiError> {
1197 crate::service::MemoryService::new(state)
1198 .kg_assert(&id, body)
1199 .await?;
1200 Ok(StatusCode::NO_CONTENT)
1201}
1202
1203/// Default page size for KG explorer list endpoints when caller omits `limit`.
1204///
1205/// Why: 50 is large enough to feel responsive in the SPA without dumping a
1206/// full graph in one request; matches the default the spec calls for.
1207const DEFAULT_KG_LIST_LIMIT: usize = 50;
1208
1209/// Hard ceiling on `limit` for KG explorer list endpoints.
1210///
1211/// Why: prevent a misconfigured client from asking the daemon to materialize
1212/// thousands of rows in one go; matches the spec's max=200.
1213const MAX_KG_LIST_LIMIT: usize = 200;
1214
1215fn default_kg_list_limit() -> usize {
1216 DEFAULT_KG_LIST_LIMIT
1217}
1218
1219/// Query parameters for `GET /api/v1/palaces/{id}/kg/subjects`.
1220///
1221/// Why: The KG Explorer's left panel asks for a bounded subject list; `limit`
1222/// is clamped server-side so the SPA cannot accidentally pull the whole graph.
1223/// What: `limit` defaults to [`DEFAULT_KG_LIST_LIMIT`] and is clamped to
1224/// `[1, MAX_KG_LIST_LIMIT]` in the handler.
1225/// Test: indirectly by the KG explorer UI; `kg_list_subjects_returns_distinct`
1226/// in the web tests below covers the happy path.
1227#[derive(Deserialize)]
1228struct KgListSubjectsParams {
1229 #[serde(default = "default_kg_list_limit")]
1230 limit: usize,
1231}
1232
1233/// `GET /api/v1/palaces/{id}/kg/subjects?limit=N` — list distinct active
1234/// subjects.
1235///
1236/// Why: The KG Explorer needs to browse subjects without a prior query (the
1237/// existing `kg_query` endpoint requires one). Surfacing this read on the
1238/// daemon avoids the SPA having to know how to issue SQL.
1239/// What: clamps `limit` to `[1, MAX_KG_LIST_LIMIT]` and delegates to
1240/// `KnowledgeGraph::list_subjects`. Returns a JSON array of strings.
1241/// Test: `kg_list_subjects_returns_distinct` (web tests).
1242async fn kg_list_subjects(
1243 State(state): State<AppState>,
1244 AxumPath(id): AxumPath<String>,
1245 Query(q): Query<KgListSubjectsParams>,
1246) -> Result<Json<Vec<String>>, ApiError> {
1247 let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1248 Ok(Json(
1249 crate::service::MemoryService::new(state)
1250 .kg_list_subjects(&id, limit)
1251 .await?,
1252 ))
1253}
1254
1255/// `GET /api/v1/palaces/{id}/kg/subjects_with_counts?limit=N` — list distinct
1256/// active subjects with their active-triple counts.
1257///
1258/// Why: The KG Explorer's subject list shows a count badge per subject and
1259/// supports sort-by-count. Returning the grouped counts in a single SQL pass
1260/// is cheaper than issuing one query per subject from the SPA.
1261/// What: clamps `limit` to `[1, MAX_KG_LIST_LIMIT]` and delegates to
1262/// `KnowledgeGraph::list_subjects_with_counts`. Returns a JSON array of
1263/// `{subject, count}` objects ordered alphabetically.
1264/// Test: indirectly via the KG Explorer UI; the core `list_subjects_with_counts`
1265/// test in `kg.rs` covers the SQL grouping.
1266async fn kg_list_subjects_with_counts(
1267 State(state): State<AppState>,
1268 AxumPath(id): AxumPath<String>,
1269 Query(q): Query<KgListSubjectsParams>,
1270) -> Result<Json<Vec<Value>>, ApiError> {
1271 let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1272 let rows = crate::service::MemoryService::new(state)
1273 .kg_list_subjects_with_counts(&id, limit)
1274 .await?;
1275 let out: Vec<Value> = rows
1276 .into_iter()
1277 .map(|(subject, count)| json!({ "subject": subject, "count": count }))
1278 .collect();
1279 Ok(Json(out))
1280}
1281
1282/// Query parameters for `GET /api/v1/palaces/{id}/kg/all`.
1283///
1284/// Why: The KG Explorer's "All" mode pages through every active triple;
1285/// `limit`+`offset` give the SPA stable prev/next controls.
1286/// What: defaults match `kg_list_subjects` for limit; `offset` defaults to 0.
1287/// Test: `kg_list_all_returns_paginated_triples` (web tests).
1288#[derive(Deserialize)]
1289struct KgListAllParams {
1290 #[serde(default = "default_kg_list_limit")]
1291 limit: usize,
1292 #[serde(default)]
1293 offset: usize,
1294}
1295
1296/// `GET /api/v1/palaces/{id}/kg/all?limit=N&offset=N` — list all active
1297/// triples ordered by `valid_from` descending.
1298///
1299/// Why: The KG Explorer's "All" mode wants a paged view across every active
1300/// triple regardless of subject. The existing `kg_query` requires a subject.
1301/// What: clamps `limit` to `[1, MAX_KG_LIST_LIMIT]` and delegates to
1302/// `KnowledgeGraph::list_active`. Returns a JSON array of `Triple` objects.
1303/// Test: `kg_list_all_returns_paginated_triples` (web tests).
1304async fn kg_list_all(
1305 State(state): State<AppState>,
1306 AxumPath(id): AxumPath<String>,
1307 Query(q): Query<KgListAllParams>,
1308) -> Result<Json<Vec<Triple>>, ApiError> {
1309 let limit = q.limit.clamp(1, MAX_KG_LIST_LIMIT);
1310 Ok(Json(
1311 crate::service::MemoryService::new(state)
1312 .kg_list_all(&id, limit, q.offset)
1313 .await?,
1314 ))
1315}
1316
1317/// `GET /api/v1/palaces/{id}/kg/count` — count of currently-active triples.
1318///
1319/// Why: The KG Explorer header shows a quick "N triples" badge; computing the
1320/// count server-side avoids fetching every triple to count them.
1321/// What: returns `{ "active": N }` where N is `count_active_triples()` on the
1322/// palace's KG.
1323/// Test: indirectly via the same palace counts surfaced on `/api/v1/status`.
1324async fn kg_count(
1325 State(state): State<AppState>,
1326 AxumPath(id): AxumPath<String>,
1327) -> Result<Json<Value>, ApiError> {
1328 let active = crate::service::MemoryService::new(state)
1329 .kg_count(&id)
1330 .await?;
1331 Ok(Json(json!({ "active": active })))
1332}
1333
1334/// Separator byte sequence used inside a URL-safe base64 triple ID.
1335///
1336/// Why: The triple primary key is `(subject, predicate)`. Encoding them as a
1337/// single opaque ID lets the REST path look like `/kg/triples/<id>` (a
1338/// resource identifier) rather than carrying both parts in the URL path, which
1339/// would require double-escaping arbitrary strings. A `\0` separator is safe
1340/// because neither subjects nor predicates ever contain null bytes.
1341/// What: Used by [`encode_triple_id`] and [`decode_triple_id`].
1342/// Test: `decode_triple_id_round_trips`.
1343const TRIPLE_ID_SEPARATOR: u8 = 0x00;
1344
1345/// Encode a `(subject, predicate)` pair as a URL-safe base64 triple ID.
1346///
1347/// Why: Produces a single opaque string that can travel as a URL path segment
1348/// without percent-encoding. The null-byte separator ensures the encoding is
1349/// injective (no two distinct pairs can produce the same encoded string).
1350/// What: `base64url(subject_bytes + "\0" + predicate_bytes)`, no padding.
1351/// Test: `decode_triple_id_round_trips`.
1352// Only used in tests (for round-trip assertions); suppress the dead_code lint
1353// that fires in non-test builds because `pub(crate)` alone doesn't silence it.
1354#[allow(dead_code)]
1355pub(crate) fn encode_triple_id(subject: &str, predicate: &str) -> String {
1356 use base64::Engine as _;
1357 let mut buf = Vec::with_capacity(subject.len() + 1 + predicate.len());
1358 buf.extend_from_slice(subject.as_bytes());
1359 buf.push(TRIPLE_ID_SEPARATOR);
1360 buf.extend_from_slice(predicate.as_bytes());
1361 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf)
1362}
1363
1364/// Decode a URL-safe base64 triple ID back to `(subject, predicate)`.
1365///
1366/// Why: The handler for `DELETE /kg/triples/<id>` needs to recover the
1367/// `(subject, predicate)` pair from the opaque path segment to call the
1368/// service layer.
1369/// What: Decodes base64url, splits on the first null byte. Returns `None`
1370/// when the input is not valid base64url or contains no null separator.
1371/// Test: `decode_triple_id_round_trips`.
1372pub(crate) fn decode_triple_id(id: &str) -> Option<(String, String)> {
1373 use base64::Engine as _;
1374 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
1375 .decode(id)
1376 .ok()?;
1377 let sep_pos = bytes.iter().position(|&b| b == TRIPLE_ID_SEPARATOR)?;
1378 let subject = String::from_utf8(bytes[..sep_pos].to_vec()).ok()?;
1379 let predicate = String::from_utf8(bytes[sep_pos + 1..].to_vec()).ok()?;
1380 Some((subject, predicate))
1381}
1382
1383/// `DELETE /api/v1/palaces/{id}/kg/triples/{triple_id}` — surgically remove
1384/// one active triple by its opaque base64url-encoded `(subject, predicate)` ID.
1385///
1386/// Why: Issue #278 — the existing `(subject, predicate)` retract via
1387/// `/kg/prompt-facts` is scope-wide (retract across all palaces). This
1388/// endpoint targets exactly one triple in exactly one palace, giving callers
1389/// a surgical way to delete a specific edge without affecting other palaces
1390/// or other predicates for the same subject.
1391/// What: Decodes `triple_id` (base64url of `subject\0predicate`) back into
1392/// `(subject, predicate)`, retracts the active interval via
1393/// `MemoryService::kg_retract_triple`, and returns:
1394/// - `204 No Content` on success
1395/// - `404 Not Found` when the triple_id is malformed or no active triple
1396/// matched
1397///
1398/// Test: `kg_delete_triple_returns_204_on_success` and
1399/// `kg_delete_triple_returns_404_for_missing`.
1400async fn kg_delete_triple(
1401 State(state): State<AppState>,
1402 AxumPath((id, triple_id)): AxumPath<(String, String)>,
1403) -> Result<StatusCode, ApiError> {
1404 let (subject, predicate) = decode_triple_id(&triple_id).ok_or_else(|| {
1405 ApiError::not_found("invalid triple id — expected base64url(subject\\0predicate)")
1406 })?;
1407 let found = crate::service::MemoryService::new(state)
1408 .kg_retract_triple(&id, &subject, &predicate)
1409 .await?;
1410 if found {
1411 Ok(StatusCode::NO_CONTENT)
1412 } else {
1413 Err(ApiError::not_found(format!(
1414 "no active triple with subject={subject:?} predicate={predicate:?} in palace {id:?}"
1415 )))
1416 }
1417}
1418
1419pub(crate) use crate::service::KgGraphPayload;
1420
1421async fn kg_graph(
1422 State(state): State<AppState>,
1423 AxumPath(id): AxumPath<String>,
1424) -> Result<Json<KgGraphPayload>, ApiError> {
1425 Ok(Json(
1426 crate::service::MemoryService::new(state)
1427 .kg_graph(&id)
1428 .await?,
1429 ))
1430}
1431
1432// ---------------------------------------------------------------------------
1433// Dream cycle status + on-demand run
1434// ---------------------------------------------------------------------------
1435
1436pub(crate) use crate::service::DreamStatusPayload;
1437
1438async fn dream_status(State(state): State<AppState>) -> Json<DreamStatusPayload> {
1439 Json(
1440 crate::service::MemoryService::new(state)
1441 .dream_status_aggregate()
1442 .await,
1443 )
1444}
1445
1446async fn palace_dream_status(
1447 State(state): State<AppState>,
1448 AxumPath(id): AxumPath<String>,
1449) -> Result<Json<DreamStatusPayload>, ApiError> {
1450 Ok(Json(
1451 crate::service::MemoryService::new(state)
1452 .dream_status_for_palace(&id)
1453 .await?,
1454 ))
1455}
1456
1457async fn dream_run(State(state): State<AppState>) -> Result<Json<DreamStatusPayload>, ApiError> {
1458 Ok(Json(
1459 crate::service::MemoryService::new(state)
1460 .dream_run()
1461 .await?,
1462 ))
1463}
1464
1465// ---------------------------------------------------------------------------
1466// Knowledge gaps — community detection cache (issue #53)
1467// ---------------------------------------------------------------------------
1468
1469/// Wire shape for a single knowledge gap returned by `/api/v1/kg/gaps`.
1470///
1471/// Why: `KnowledgeGap` (in `trusty-common`) does not derive `Serialize`
1472/// because that would force serde into the memory-core feature surface; the
1473/// HTTP layer instead owns a narrow response struct mirroring its fields.
1474/// What: One-for-one wire representation of `KnowledgeGap` — entities, the
1475/// internal-density score, the cross-community bridge count, and the
1476/// LLM/template exploration hint.
1477/// Test: `kg_gaps_endpoint_returns_cached_gaps`.
1478#[derive(Serialize, Debug, Clone)]
1479pub struct KnowledgeGapResponse {
1480 pub entities: Vec<String>,
1481 pub internal_density: f32,
1482 pub external_bridges: usize,
1483 pub suggested_exploration: String,
1484}
1485
1486impl From<KnowledgeGap> for KnowledgeGapResponse {
1487 fn from(g: KnowledgeGap) -> Self {
1488 Self {
1489 entities: g.entities,
1490 internal_density: g.internal_density,
1491 external_bridges: g.external_bridges,
1492 suggested_exploration: g.suggested_exploration,
1493 }
1494 }
1495}
1496
1497#[derive(Deserialize)]
1498struct KgGapsQuery {
1499 #[serde(default)]
1500 palace: Option<String>,
1501}
1502
1503/// `GET /api/v1/kg/gaps?palace=<name>` — return the cached knowledge gaps.
1504///
1505/// Why: Issue #53 — surfaces the community-detection output computed by the
1506/// dream cycle so callers (dashboard, MCP tool, external tooling) can list
1507/// the sparse-cluster targets the model should explore next. Reading from
1508/// the in-memory cache means a `/kg/gaps` request never triggers a Louvain
1509/// run; it just clones the latest snapshot.
1510/// What: Resolves the palace from the optional `palace` query arg (falling
1511/// back to the daemon's `default_palace`, then erroring with 400 if neither
1512/// is set). Returns `[]` when the cache has no entry yet — the dream cycle
1513/// simply hasn't populated it. Returns 404 only when the palace name is
1514/// unknown to the registry (handle.open failed).
1515/// Test: `kg_gaps_endpoint_returns_cached_gaps`,
1516/// `kg_gaps_endpoint_returns_empty_when_uncached`.
1517async fn kg_gaps_handler(
1518 State(state): State<AppState>,
1519 Query(q): Query<KgGapsQuery>,
1520) -> Result<Json<Vec<KnowledgeGapResponse>>, ApiError> {
1521 let palace_name = q
1522 .palace
1523 .clone()
1524 .or_else(|| state.default_palace.clone())
1525 .ok_or_else(|| {
1526 ApiError::bad_request("missing 'palace' query parameter (no default palace configured)")
1527 })?;
1528
1529 // Validate the palace exists; we don't strictly need the handle for the
1530 // cache lookup but we want a 404 rather than an empty-array masking a
1531 // typo in the palace name.
1532 let _handle = open_handle(&state, &palace_name)?;
1533
1534 let pid = PalaceId::new(&palace_name);
1535 let gaps = state.registry.get_gaps(&pid).unwrap_or_default();
1536 let body: Vec<KnowledgeGapResponse> =
1537 gaps.into_iter().map(KnowledgeGapResponse::from).collect();
1538 Ok(Json(body))
1539}
1540
1541// ---------------------------------------------------------------------------
1542// Prompt-facts surface (issue #42)
1543// ---------------------------------------------------------------------------
1544
1545/// Query parameters shared by the prompt-context / prompt-facts read endpoints.
1546///
1547/// Why: Both `GET /api/v1/kg/prompt-context` and `GET /api/v1/kg/prompt-facts`
1548/// optionally accept a `palace` filter so callers can scope reads to a single
1549/// project namespace. A shared struct keeps the wire shape consistent.
1550/// What: A single optional `palace` query parameter. When omitted, handlers
1551/// span every palace in the registry (matching the MCP tool behaviour).
1552/// Test: `prompt_context_endpoint_returns_formatted_block`,
1553/// `list_prompt_facts_endpoint_returns_hot_triples`.
1554#[derive(Deserialize)]
1555struct PromptFactsQuery {
1556 // Accepted for forward-compat with the MCP tool surface, but ignored:
1557 // the prompt cache is registry-wide, so reads always span every palace.
1558 // We keep the field rather than ignoring `?palace=...` silently so a
1559 // future per-palace filter is a non-breaking schema addition.
1560 #[serde(default)]
1561 #[allow(dead_code)]
1562 palace: Option<String>,
1563}
1564
1565/// Wire shape for `POST /api/v1/kg/aliases`.
1566///
1567/// Why: Mirrors the `add_alias` MCP tool: a short → full mapping with an
1568/// optional palace target. Keeping the field names identical between the
1569/// HTTP and MCP surfaces makes documentation and client code reuse trivial.
1570/// What: Required `short` and `full`; optional `palace` (falls back to the
1571/// daemon default).
1572/// Test: `add_alias_endpoint_asserts_triple_and_refreshes_cache`.
1573#[derive(Deserialize)]
1574struct AddAliasRequest {
1575 short: String,
1576 full: String,
1577 #[serde(default)]
1578 palace: Option<String>,
1579}
1580
1581/// Wire shape for a single hot-predicate triple in JSON responses.
1582///
1583/// Why: `list_prompt_facts` returns a structured array rather than the
1584/// pre-formatted Markdown so dashboards and tooling can render their own
1585/// views over the raw data.
1586/// What: subject/predicate/object string trio matching the underlying KG row.
1587/// Test: `list_prompt_facts_endpoint_returns_hot_triples`.
1588#[derive(Serialize)]
1589struct PromptFactRow {
1590 subject: String,
1591 predicate: String,
1592 object: String,
1593}
1594
1595/// Query parameters for `DELETE /api/v1/kg/prompt-facts`.
1596///
1597/// Why: The MCP tool retracts the active interval for a `(subject, predicate)`
1598/// pair across every palace; the HTTP endpoint matches that contract so a
1599/// dashboard "Remove" button doesn't need to know which palace owns the fact.
1600/// What: Required `subject` and `predicate`; the issue spec mentions an
1601/// optional `object` filter but the underlying `KnowledgeGraph::retract` API
1602/// closes the entire `(subject, predicate)` interval — we accept `object`
1603/// for forward-compat but currently ignore it, mirroring the MCP tool.
1604/// Test: `remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache`.
1605#[derive(Deserialize)]
1606struct RemovePromptFactQuery {
1607 subject: String,
1608 predicate: String,
1609 #[serde(default)]
1610 #[allow(dead_code)]
1611 object: Option<String>,
1612 #[serde(default)]
1613 #[allow(dead_code)]
1614 palace: Option<String>,
1615}
1616
1617/// `GET /api/v1/kg/prompt-context` — return the formatted prompt-context block.
1618///
1619/// Why: Lets non-MCP callers (the admin UI, curl, integration tests) fetch
1620/// the same Markdown block the `get_prompt_context` tool returns, without
1621/// needing to speak JSON-RPC. The body is a plain text response so it can
1622/// be piped straight into a model prompt.
1623/// What: Reads the in-memory `prompt_context_cache` (already kept fresh by
1624/// any write that touches a hot predicate), returns the formatted string,
1625/// or a placeholder message when nothing has been stored yet.
1626/// Test: `prompt_context_endpoint_returns_formatted_block`.
1627async fn prompt_context_handler(
1628 State(state): State<AppState>,
1629 Query(_q): Query<PromptFactsQuery>,
1630) -> Result<Response, ApiError> {
1631 let cache_snapshot = {
1632 let guard = state.prompt_context_cache.read().await;
1633 guard.clone()
1634 };
1635 let body = if cache_snapshot.formatted.is_empty() {
1636 "No prompt facts stored yet.".to_string()
1637 } else {
1638 cache_snapshot.formatted
1639 };
1640 let mut resp = body.into_response();
1641 resp.headers_mut().insert(
1642 header::CONTENT_TYPE,
1643 HeaderValue::from_static("text/plain; charset=utf-8"),
1644 );
1645 Ok(resp)
1646}
1647
1648/// `POST /api/v1/kg/aliases` — assert a `(short, is_alias_for, full)` triple.
1649///
1650/// Why: HTTP counterpart to the `add_alias` MCP tool — lets the admin UI
1651/// (or an external automation) register aliases without speaking JSON-RPC.
1652/// What: Resolves the target palace (request body → daemon default), opens
1653/// the palace handle, asserts the alias triple, and rebuilds the prompt
1654/// cache so subsequent `GET /api/v1/kg/prompt-context` calls reflect the
1655/// write immediately.
1656/// Test: `add_alias_endpoint_asserts_triple_and_refreshes_cache`.
1657async fn add_alias_handler(
1658 State(state): State<AppState>,
1659 Json(req): Json<AddAliasRequest>,
1660) -> Result<Json<Value>, ApiError> {
1661 if req.short.is_empty() || req.full.is_empty() {
1662 return Err(ApiError::bad_request("short and full are required"));
1663 }
1664 let palace_name = req
1665 .palace
1666 .clone()
1667 .or_else(|| state.default_palace.clone())
1668 .ok_or_else(|| ApiError::bad_request("missing 'palace' (no default palace configured)"))?;
1669 let handle = open_handle(&state, &palace_name)?;
1670 let triple = Triple {
1671 subject: req.short.clone(),
1672 predicate: "is_alias_for".to_string(),
1673 object: req.full.clone(),
1674 valid_from: chrono::Utc::now(),
1675 valid_to: None,
1676 confidence: 1.0,
1677 provenance: Some("add_alias_http".to_string()),
1678 };
1679 handle
1680 .kg
1681 .assert(triple)
1682 .await
1683 .map_err(|e| ApiError::internal(format!("kg.assert failed: {e:#}")))?;
1684 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
1685 tracing::warn!("rebuild_prompt_cache after HTTP add_alias failed: {e:#}");
1686 }
1687 Ok(Json(json!({
1688 "subject": req.short,
1689 "predicate": "is_alias_for",
1690 "object": req.full,
1691 "palace": palace_name,
1692 })))
1693}
1694
1695/// `GET /api/v1/kg/prompt-facts` — list every active hot-predicate triple.
1696///
1697/// Why: Mirrors the `list_prompt_facts` MCP tool. Returning the raw triples
1698/// (rather than the formatted block) lets dashboards group, search, and
1699/// edit them with their own UI.
1700/// What: Calls `gather_hot_triples` over the live registry and serialises
1701/// each row as `{subject, predicate, object}`.
1702/// Test: `list_prompt_facts_endpoint_returns_hot_triples`.
1703async fn list_prompt_facts_handler(
1704 State(state): State<AppState>,
1705 Query(_q): Query<PromptFactsQuery>,
1706) -> Result<Json<Vec<PromptFactRow>>, ApiError> {
1707 let triples = crate::prompt_facts::gather_hot_triples(&state)
1708 .await
1709 .map_err(|e| ApiError::internal(format!("gather_hot_triples: {e:#}")))?;
1710 let rows: Vec<PromptFactRow> = triples
1711 .into_iter()
1712 .map(|(subject, predicate, object)| PromptFactRow {
1713 subject,
1714 predicate,
1715 object,
1716 })
1717 .collect();
1718 Ok(Json(rows))
1719}
1720
1721/// `DELETE /api/v1/kg/prompt-facts?subject=...&predicate=...` — soft-delete
1722/// the active triple matching the given `(subject, predicate)` pair.
1723///
1724/// Why: HTTP counterpart to the `remove_prompt_fact` MCP tool. Mirrors the
1725/// retract-across-palaces semantics so a single call cleans up the fact
1726/// regardless of which palace stored it.
1727/// What: Iterates every palace, calls `kg.retract(subject, predicate)`, and
1728/// reports the total number of intervals closed. Rebuilds the prompt cache
1729/// when at least one retraction occurred.
1730/// Test: `remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache`.
1731async fn remove_prompt_fact_handler(
1732 State(state): State<AppState>,
1733 Query(q): Query<RemovePromptFactQuery>,
1734) -> Result<Json<Value>, ApiError> {
1735 if q.subject.is_empty() || q.predicate.is_empty() {
1736 return Err(ApiError::bad_request("subject and predicate are required"));
1737 }
1738 let mut closed_total: usize = 0;
1739 for palace_id in state.registry.list() {
1740 if let Some(handle) = state.registry.get(&palace_id) {
1741 match handle.kg.retract(&q.subject, &q.predicate).await {
1742 Ok(n) => closed_total += n,
1743 Err(e) => tracing::warn!(
1744 palace = %palace_id.as_str(),
1745 "HTTP retract failed: {e:#}",
1746 ),
1747 }
1748 }
1749 }
1750 if closed_total > 0 {
1751 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
1752 tracing::warn!("rebuild_prompt_cache after HTTP remove_prompt_fact failed: {e:#}");
1753 }
1754 Ok(Json(json!({"removed": true, "closed": closed_total})))
1755 } else {
1756 Ok(Json(json!({"removed": false, "reason": "not found"})))
1757 }
1758}
1759
1760#[allow(unused_imports)]
1761pub(crate) use crate::service::refresh_gaps_cache;
1762
1763// ---------------------------------------------------------------------------
1764// Helpers
1765// ---------------------------------------------------------------------------
1766
1767pub(crate) fn open_handle(
1768 state: &AppState,
1769 id: &str,
1770) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>, ApiError> {
1771 state
1772 .registry
1773 .open_palace(&state.data_root, &PalaceId::new(id))
1774 .map_err(|e| ApiError::not_found(format!("palace not found: {id} ({e:#})")))
1775}
1776
1777/// Lightweight error type for HTTP handlers.
1778pub(crate) struct ApiError {
1779 status: StatusCode,
1780 message: String,
1781}
1782
1783impl ApiError {
1784 pub(crate) fn bad_request(msg: impl Into<String>) -> Self {
1785 Self {
1786 status: StatusCode::BAD_REQUEST,
1787 message: msg.into(),
1788 }
1789 }
1790 pub(crate) fn not_found(msg: impl Into<String>) -> Self {
1791 Self {
1792 status: StatusCode::NOT_FOUND,
1793 message: msg.into(),
1794 }
1795 }
1796 /// Build a 409 Conflict response.
1797 ///
1798 /// Why: `DELETE /palaces/{id}` (issue #180) returns 409 when the
1799 /// palace still has drawers and `force=true` is not set. A 400 would
1800 /// be misleading (the request is well-formed) and 404 would lie about
1801 /// existence.
1802 /// What: wraps the message with `StatusCode::CONFLICT`.
1803 /// Test: `delete_palace_refuses_when_drawers_present`.
1804 #[allow(dead_code)]
1805 pub(crate) fn conflict(msg: impl Into<String>) -> Self {
1806 Self {
1807 status: StatusCode::CONFLICT,
1808 message: msg.into(),
1809 }
1810 }
1811 pub(crate) fn internal(msg: impl Into<String>) -> Self {
1812 Self {
1813 status: StatusCode::INTERNAL_SERVER_ERROR,
1814 message: msg.into(),
1815 }
1816 }
1817}
1818
1819impl IntoResponse for ApiError {
1820 fn into_response(self) -> Response {
1821 (self.status, Json(json!({ "error": self.message }))).into_response()
1822 }
1823}
1824
1825impl From<crate::service::ServiceError> for ApiError {
1826 fn from(e: crate::service::ServiceError) -> Self {
1827 match e {
1828 crate::service::ServiceError::BadRequest(m) => ApiError::bad_request(m),
1829 crate::service::ServiceError::NotFound(m) => ApiError::not_found(m),
1830 crate::service::ServiceError::Conflict(m) => ApiError::conflict(m),
1831 crate::service::ServiceError::Internal(m) => ApiError::internal(m),
1832 }
1833 }
1834}
1835
1836#[cfg(test)]
1837mod tests {
1838 use super::*;
1839 // Why (issue #226): `drawer_content_preview` is now used via the
1840 // axum-free `service` path; the test still validates the same
1841 // helper, so we import directly to keep the assertions intact.
1842 use crate::service::drawer_content_preview;
1843 use crate::service::DRAWER_PREVIEW_MAX_CHARS;
1844 use axum::body::to_bytes;
1845 use axum::http::Request;
1846 use tower::util::ServiceExt;
1847 use trusty_common::memory_core::palace::Palace;
1848 use trusty_common::memory_core::retrieval::RecallResult;
1849
1850 fn test_state() -> AppState {
1851 let tmp = tempfile::tempdir().expect("tempdir");
1852 let root = tmp.path().to_path_buf();
1853 std::mem::forget(tmp);
1854 // Issue #88: bypass the project-slug enforcement gate so tests can
1855 // create palaces with arbitrary names without having a real project
1856 // root on disk. The env var is harmless once set to "1" because all
1857 // tests in this process use the same setting.
1858 // SAFETY: no other thread reads/writes this var concurrently — the
1859 // const value "1" is idempotent and the write happens before any
1860 // test that creates a palace via the HTTP layer.
1861 unsafe {
1862 std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
1863 }
1864 AppState::new(root)
1865 }
1866
1867 #[test]
1868 fn drawer_preview_collapses_whitespace_and_truncates() {
1869 // Short single-line content is returned verbatim.
1870 assert_eq!(drawer_content_preview("hello world"), "hello world");
1871
1872 // Multiline / tab-laden content collapses to single-spaced text.
1873 assert_eq!(
1874 drawer_content_preview("first line\n\nsecond\tline third"),
1875 "first line second line third"
1876 );
1877
1878 // Leading / trailing whitespace is stripped.
1879 assert_eq!(drawer_content_preview(" padded "), "padded");
1880
1881 // Empty content yields an empty preview (fallback signal for clients).
1882 assert_eq!(drawer_content_preview(""), "");
1883
1884 // Long content is truncated to DRAWER_PREVIEW_MAX_CHARS with an ellipsis.
1885 let long = "x".repeat(DRAWER_PREVIEW_MAX_CHARS + 50);
1886 let preview = drawer_content_preview(&long);
1887 assert_eq!(preview.chars().count(), DRAWER_PREVIEW_MAX_CHARS);
1888 assert!(preview.ends_with('…'));
1889
1890 // Content right at the limit is not truncated.
1891 let exact = "y".repeat(DRAWER_PREVIEW_MAX_CHARS);
1892 assert_eq!(drawer_content_preview(&exact), exact);
1893 }
1894
1895 /// `GET /health` returns HTTP 200 with `status: "ok"` after the
1896 /// round-trip clears every stage against the auto-provisioned probe palace.
1897 ///
1898 /// Why: confirms the JSON contract (`status`, `version`) for monitors that
1899 /// poll `/health`. Marked `#[ignore]` because issue #185 routes the probe
1900 /// through the dedicated palace and `recall_with_default_embedder` loads
1901 /// ONNX — too heavy for the default CI matrix. Run with
1902 /// `cargo test -p trusty-memory -- --include-ignored`.
1903 /// What: Drives `/health` and asserts the basic JSON keys.
1904 /// Test: this test.
1905 #[tokio::test]
1906 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1907 async fn health_endpoint_returns_ok() {
1908 let state = test_state();
1909 let app = router().with_state(state);
1910 let resp = app
1911 .oneshot(
1912 Request::builder()
1913 .uri("/health")
1914 .body(Body::empty())
1915 .unwrap(),
1916 )
1917 .await
1918 .unwrap();
1919 assert_eq!(resp.status(), StatusCode::OK);
1920 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1921 let v: Value = serde_json::from_slice(&bytes).unwrap();
1922 assert_eq!(v["status"], "ok");
1923 assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
1924 }
1925
1926 /// Issue #35 — `GET /health` carries the enriched resource block
1927 /// (`rss_mb`, `disk_bytes`, `cpu_pct`, `uptime_secs`).
1928 ///
1929 /// Why: external probes and the admin UI render these; the JSON contract
1930 /// must remain stable. `rss_mb` is sampled live so it is asserted only
1931 /// for a sane unit, not an exact value. Marked `#[ignore]` because
1932 /// issue #185 makes every `/health` request run the full round-trip and
1933 /// `recall_with_default_embedder` loads the ONNX embedder.
1934 /// What: drives `/health` through the router and asserts every new field
1935 /// deserialises with a plausible value.
1936 /// Test: this test.
1937 #[tokio::test]
1938 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1939 async fn health_endpoint_includes_resource_fields() {
1940 let state = test_state();
1941 let app = router().with_state(state);
1942 let resp = app
1943 .oneshot(
1944 Request::builder()
1945 .uri("/health")
1946 .body(Body::empty())
1947 .unwrap(),
1948 )
1949 .await
1950 .unwrap();
1951 assert_eq!(resp.status(), StatusCode::OK);
1952 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1953 let v: Value = serde_json::from_slice(&bytes).unwrap();
1954 // rss_mb must be a sane unit (megabytes, not bytes).
1955 let rss_mb = v["rss_mb"].as_u64().expect("rss_mb is u64");
1956 assert!(rss_mb < 1024 * 1024, "rss_mb unit must be MB");
1957 // cpu_pct is a non-negative percentage (first sample may be 0.0).
1958 let cpu = v["cpu_pct"].as_f64().expect("cpu_pct is a number");
1959 assert!(cpu >= 0.0, "cpu_pct must be non-negative");
1960 // disk ticker has not run in this oneshot test → 0.
1961 assert_eq!(v["disk_bytes"].as_u64(), Some(0));
1962 // uptime_secs is present and a u64.
1963 assert!(v["uptime_secs"].is_u64(), "uptime_secs must be present");
1964 }
1965
1966 /// Issue #71 + #185 — `GET /health` reports `status: "ok"` on a fresh
1967 /// install by auto-provisioning the dedicated probe palace and running
1968 /// the full remember/recall/forget cycle against it.
1969 ///
1970 /// Why: Pre-#185 the handler short-circuited with "no palaces" on a fresh
1971 /// install, so a broken data plane would not surface until a real user
1972 /// created a palace. The dedicated `__health_probe__` palace removes that
1973 /// blind spot: the probe runs from boot. Marked `#[ignore]` because the
1974 /// round-trip now loads the ONNX embedder via `recall_with_default_embedder`,
1975 /// which is too heavy for the default CI matrix — run with
1976 /// `cargo test -p trusty-memory -- --include-ignored` for local verification.
1977 /// What: Drives `/health` through the router with an empty `data_root`
1978 /// and asserts `status == "ok"` (probe palace was auto-created and the
1979 /// round-trip cleared every stage) and the `detail` key is absent.
1980 /// Test: this test.
1981 #[tokio::test]
1982 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
1983 async fn health_endpoint_round_trip_on_fresh_install_is_ok() {
1984 let state = test_state();
1985 let app = router().with_state(state);
1986 let resp = app
1987 .oneshot(
1988 Request::builder()
1989 .uri("/health")
1990 .body(Body::empty())
1991 .unwrap(),
1992 )
1993 .await
1994 .unwrap();
1995 assert_eq!(resp.status(), StatusCode::OK);
1996 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
1997 let v: Value = serde_json::from_slice(&bytes).unwrap();
1998 assert_eq!(v["status"], "ok");
1999 assert!(
2000 v.get("detail").is_none() || v["detail"].is_null(),
2001 "fresh-install health must not carry a degraded detail (got {v:?})"
2002 );
2003 }
2004
2005 /// Issue #71 — `GET /health` exercises the full store/recall/forget
2006 /// cycle against the first palace and reports `status: "ok"` on success.
2007 ///
2008 /// Why: The whole point of issue #71 is to catch store/recall
2009 /// regressions at probe time rather than via real client traffic. This
2010 /// test creates a real palace, hits `/health`, and asserts the
2011 /// round-trip path is happy. Marked `#[ignore]` because
2012 /// `recall_with_default_embedder` pulls in the ONNX model and is too
2013 /// heavy for the default CI matrix — run with
2014 /// `cargo test -p trusty-memory -- --include-ignored` for local
2015 /// verification.
2016 /// What: Builds an `AppState` with a tempdir `data_root`, creates a
2017 /// `health-probe-palace` via `registry.create_palace`, hits `/health`,
2018 /// and asserts both the status and the absence of any `detail` field.
2019 /// Test: this test.
2020 #[tokio::test]
2021 #[ignore = "loads the default ONNX embedder; run with --include-ignored"]
2022 async fn health_endpoint_round_trip_with_palace_is_ok() {
2023 let state = test_state();
2024 let palace = trusty_common::memory_core::Palace {
2025 id: PalaceId::new("health-probe-palace"),
2026 name: "health-probe-palace".to_string(),
2027 description: None,
2028 created_at: chrono::Utc::now(),
2029 data_dir: state.data_root.join("health-probe-palace"),
2030 };
2031 state
2032 .registry
2033 .create_palace(&state.data_root, palace)
2034 .expect("create_palace");
2035
2036 let app = router().with_state(state);
2037 let resp = app
2038 .oneshot(
2039 Request::builder()
2040 .uri("/health")
2041 .body(Body::empty())
2042 .unwrap(),
2043 )
2044 .await
2045 .unwrap();
2046 assert_eq!(resp.status(), StatusCode::OK);
2047 let bytes = to_bytes(resp.into_body(), 2048).await.unwrap();
2048 let v: Value = serde_json::from_slice(&bytes).unwrap();
2049 assert_eq!(
2050 v["status"], "ok",
2051 "round-trip should succeed against a fresh palace; got {v:?}"
2052 );
2053 assert!(
2054 v.get("detail").is_none() || v["detail"].is_null(),
2055 "successful round-trip must not carry a detail field (got {v:?})"
2056 );
2057 }
2058
2059 /// Issue #185 — the `__health_probe__` palace is hidden from
2060 /// `MemoryService::list_palaces`.
2061 ///
2062 /// Why: The dedicated health-probe palace exists on disk and must keep
2063 /// existing across restarts, but it is an internal implementation detail
2064 /// of `/health` and must never confuse the user (in the admin UI, TUI,
2065 /// chat-tool palace roster, etc.).
2066 /// What: Provisions the probe palace via the same helper the handler uses,
2067 /// confirms the directory exists on disk, then asks
2068 /// `MemoryService::list_palaces` for the user-facing roster and asserts
2069 /// no palace with the reserved id (or any `__`-prefixed id) is returned.
2070 /// Test: this test.
2071 #[tokio::test]
2072 async fn health_probe_palace_is_invisible() {
2073 let state = test_state();
2074 ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2075
2076 // The probe palace was persisted under the data root.
2077 assert!(
2078 state.data_root.join(HEALTH_PROBE_PALACE).exists(),
2079 "probe palace directory should be persisted on disk"
2080 );
2081
2082 let service = crate::service::MemoryService::new(state);
2083 let listed = service.list_palaces().await.expect("list_palaces");
2084 assert!(
2085 listed.iter().all(|p| !p.id.starts_with("__")),
2086 "no `__`-prefixed palace may appear in the user-facing list; got {:?}",
2087 listed.iter().map(|p| &p.id).collect::<Vec<_>>()
2088 );
2089 assert!(
2090 !listed.iter().any(|p| p.id == HEALTH_PROBE_PALACE),
2091 "the dedicated `__health_probe__` palace must be invisible; got {:?}",
2092 listed.iter().map(|p| &p.id).collect::<Vec<_>>()
2093 );
2094 }
2095
2096 /// Issue #185 — after a successful round-trip, the probe palace holds
2097 /// zero drawers.
2098 ///
2099 /// Why: The probe must clean up after itself on every success path. If
2100 /// the forget step were ever skipped silently, the probe palace would
2101 /// grow unbounded over time (the original symptom was ~1,420 leaked
2102 /// drawers in `localLLM`). This test pins the post-condition without
2103 /// requiring the heavy ONNX recall — it exercises
2104 /// `run_health_round_trip_inner` with a recall stub that returns a
2105 /// synthetic hit matching the probe drawer id.
2106 /// What: Provisions the probe palace, opens its handle, runs the inner
2107 /// round-trip with a stubbed recall that returns the probe drawer, and
2108 /// asserts the handle's drawer count drops back to zero.
2109 /// Test: this test.
2110 #[tokio::test]
2111 async fn health_probe_cleans_up_on_success() {
2112 use trusty_common::memory_core::Drawer;
2113
2114 let state = test_state();
2115 ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2116 let handle = state
2117 .registry
2118 .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2119 .expect("open probe palace");
2120
2121 let result = run_health_round_trip_inner(handle.clone(), move |h, _query| async move {
2122 // Synthesize a hit that points at the most recently stored drawer
2123 // so the round-trip treats this as a successful recall.
2124 let drawers = h.drawers.read();
2125 let last = drawers
2126 .last()
2127 .cloned()
2128 .unwrap_or_else(|| Drawer::new(Uuid::new_v4(), "stub"));
2129 drop(drawers);
2130 Ok(vec![RecallResult {
2131 drawer: last,
2132 score: 1.0,
2133 layer: 1,
2134 }])
2135 })
2136 .await;
2137 assert!(
2138 result.is_ok(),
2139 "successful round-trip should return Ok; got {result:?}"
2140 );
2141
2142 let drawer_count = handle.drawers.read().len();
2143 assert_eq!(
2144 drawer_count, 0,
2145 "probe palace must have zero drawers after a successful round-trip (got {drawer_count})"
2146 );
2147 }
2148
2149 /// Issue #185 — when recall returns an empty result, the probe drawer is
2150 /// still deleted before the round-trip surfaces the failure.
2151 ///
2152 /// Why: This is the bug fix's central correctness property. Before #185
2153 /// the empty-result branch did `return Err(RecallMiss)` *before* calling
2154 /// `handle.forget(drawer_id)`, leaking the drawer. The new code calls
2155 /// forget unconditionally and then evaluates the recall outcome, so a
2156 /// recall miss can never leave a drawer behind.
2157 /// What: Drives `run_health_round_trip_inner` with a recall stub that
2158 /// returns an empty `Vec`, asserts the function reports
2159 /// `HealthProbeError::ProbeMissing`, and then asserts the probe palace
2160 /// is empty.
2161 /// Test: this test.
2162 #[tokio::test]
2163 async fn health_probe_cleans_up_on_recall_miss() {
2164 let state = test_state();
2165 ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2166 let handle = state
2167 .registry
2168 .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2169 .expect("open probe palace");
2170
2171 let result = run_health_round_trip_inner(handle.clone(), |_h, _q| async move {
2172 // Empty result — pre-#185 this leaked the drawer.
2173 Ok(Vec::new())
2174 })
2175 .await;
2176 assert!(
2177 matches!(result, Err(HealthProbeError::ProbeMissing(_))),
2178 "recall miss must surface as ProbeMissing; got {result:?}"
2179 );
2180
2181 let drawer_count = handle.drawers.read().len();
2182 assert_eq!(
2183 drawer_count, 0,
2184 "probe palace must be empty after a recall miss (got {drawer_count})"
2185 );
2186 }
2187
2188 /// Issue #185 — when recall errors out, the probe drawer is still
2189 /// deleted before the round-trip surfaces the failure.
2190 ///
2191 /// Why: The second leak mode pre-#185: `recall` returning `Err(_)` made
2192 /// the function `return Err(Recall(e))` before reaching `forget`. The
2193 /// fix calls forget unconditionally; this test guards that ordering by
2194 /// stubbing a recall that always errors and asserting the palace ends
2195 /// empty.
2196 /// What: Drives `run_health_round_trip_inner` with a recall stub that
2197 /// returns `Err(Recall(...))`, asserts the function surfaces a Recall
2198 /// error, and then asserts the probe palace is empty.
2199 /// Test: this test.
2200 #[tokio::test]
2201 async fn health_probe_cleans_up_on_recall_error() {
2202 let state = test_state();
2203 ensure_health_probe_palace(&state).expect("ensure_health_probe_palace");
2204 let handle = state
2205 .registry
2206 .open_palace(&state.data_root, &PalaceId::new(HEALTH_PROBE_PALACE))
2207 .expect("open probe palace");
2208
2209 let result = run_health_round_trip_inner(handle.clone(), |_h, _q| async move {
2210 Err(HealthProbeError::Recall("simulated failure".to_string()))
2211 })
2212 .await;
2213 assert!(
2214 matches!(result, Err(HealthProbeError::Recall(_))),
2215 "recall error must surface as Recall; got {result:?}"
2216 );
2217
2218 let drawer_count = handle.drawers.read().len();
2219 assert_eq!(
2220 drawer_count, 0,
2221 "probe palace must be empty after a recall error (got {drawer_count})"
2222 );
2223 }
2224
2225 /// Issue #69 — `recall_entry_json` hoists the drawer's fields to the top
2226 /// level so `content` is directly reachable.
2227 ///
2228 /// Why: The recall API previously wrapped the drawer under a `"drawer"`
2229 /// key, so clients scanning the top level for `content`/`tags` found
2230 /// nothing and recall always looked empty. This locks the flattened shape
2231 /// in place so the regression cannot silently return.
2232 /// What: Builds a `RecallResult`, runs it through `recall_entry_json`, and
2233 /// asserts `content`, `tags`, and `importance` are at the top level, that
2234 /// `score`/`layer` sit alongside them, and that the old `drawer` wrapper
2235 /// key is gone.
2236 /// Test: this test.
2237 #[test]
2238 fn recall_entry_json_hoists_drawer_fields() {
2239 use trusty_common::memory_core::Drawer;
2240
2241 let room = Uuid::new_v4();
2242 let mut drawer = Drawer::new(room, "the answer is 42");
2243 drawer.tags = vec!["source:kuzu".to_string()];
2244 drawer.importance = 0.7;
2245
2246 let entry = recall_entry_json(RecallResult {
2247 drawer,
2248 score: 0.699,
2249 layer: 1,
2250 });
2251
2252 // Content must be reachable WITHOUT a `drawer` wrapper (issue #69).
2253 assert_eq!(
2254 entry.get("content").and_then(|v| v.as_str()),
2255 Some("the answer is 42"),
2256 "content must be at the top level, got {entry:?}"
2257 );
2258 assert!(
2259 entry.get("drawer").is_none(),
2260 "the legacy `drawer` wrapper must not be present, got {entry:?}"
2261 );
2262 // Other drawer fields are hoisted too.
2263 assert_eq!(
2264 entry["importance"].as_f64().map(|f| (f * 10.0).round()),
2265 Some(7.0)
2266 );
2267 assert_eq!(
2268 entry["tags"][0].as_str(),
2269 Some("source:kuzu"),
2270 "tags must be hoisted, got {entry:?}"
2271 );
2272 // Ranking metadata sits alongside the hoisted fields.
2273 assert_eq!(entry["layer"].as_u64(), Some(1));
2274 assert!(
2275 entry["score"]
2276 .as_f64()
2277 .is_some_and(|s| (s - 0.699).abs() < 1e-6),
2278 "score must be preserved, got {entry:?}"
2279 );
2280 }
2281
2282 /// Issue #35 — `GET /api/v1/logs/tail` returns the most recent buffered
2283 /// lines and the total count.
2284 ///
2285 /// Why: operators inspect a running daemon via this endpoint; it must
2286 /// surface exactly what the shared `LogBuffer` holds.
2287 /// What: attaches a `LogBuffer` to the state, pushes three lines, GETs
2288 /// `?n=2`, and asserts the tail + `total`.
2289 /// Test: this test.
2290 #[tokio::test]
2291 async fn logs_tail_returns_recent_lines() {
2292 let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2293 buffer.push("line one".to_string());
2294 buffer.push("line two".to_string());
2295 buffer.push("line three".to_string());
2296 let state = test_state().with_log_buffer(buffer);
2297 let app = router().with_state(state);
2298 let resp = app
2299 .oneshot(
2300 Request::builder()
2301 .uri("/api/v1/logs/tail?n=2")
2302 .body(Body::empty())
2303 .unwrap(),
2304 )
2305 .await
2306 .unwrap();
2307 assert_eq!(resp.status(), StatusCode::OK);
2308 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2309 let v: Value = serde_json::from_slice(&bytes).unwrap();
2310 let lines = v["lines"].as_array().expect("lines array");
2311 assert_eq!(lines.len(), 2, "n=2 must return two lines");
2312 assert_eq!(lines[0].as_str(), Some("line two"));
2313 assert_eq!(lines[1].as_str(), Some("line three"));
2314 assert_eq!(v["total"].as_u64(), Some(3));
2315 }
2316
2317 /// Issue #35 — `GET /api/v1/logs/tail?n=` is clamped to
2318 /// `[1, MAX_LOGS_TAIL_N]`.
2319 ///
2320 /// Why: a misconfigured client must not request more lines than the
2321 /// buffer holds, and `n=0` must still return at least one line.
2322 /// What: pushes five lines, requests `n=0` (clamps to 1) and an oversized
2323 /// `n` (clamps to the buffer length).
2324 /// Test: this test.
2325 #[tokio::test]
2326 async fn logs_tail_clamps_n() {
2327 let buffer = trusty_common::log_buffer::LogBuffer::new(100);
2328 for i in 0..5 {
2329 buffer.push(format!("l{i}"));
2330 }
2331 let state = test_state().with_log_buffer(buffer);
2332 let app = router().with_state(state);
2333
2334 // n=0 clamps up to 1.
2335 let resp = app
2336 .clone()
2337 .oneshot(
2338 Request::builder()
2339 .uri("/api/v1/logs/tail?n=0")
2340 .body(Body::empty())
2341 .unwrap(),
2342 )
2343 .await
2344 .unwrap();
2345 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2346 let v: Value = serde_json::from_slice(&bytes).unwrap();
2347 assert_eq!(v["lines"].as_array().expect("lines").len(), 1);
2348
2349 // n far past MAX clamps down to the buffer length (5).
2350 let resp = app
2351 .oneshot(
2352 Request::builder()
2353 .uri("/api/v1/logs/tail?n=999999")
2354 .body(Body::empty())
2355 .unwrap(),
2356 )
2357 .await
2358 .unwrap();
2359 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2360 let v: Value = serde_json::from_slice(&bytes).unwrap();
2361 assert_eq!(v["lines"].as_array().expect("lines").len(), 5);
2362 }
2363
2364 /// Issue #35 — `POST /api/v1/admin/stop` acknowledges the shutdown
2365 /// request with `{ ok, message }`.
2366 ///
2367 /// Why: the response shape is the documented contract for the admin UI's
2368 /// stop button.
2369 /// What: calls `admin_stop` directly and asserts the JSON body. It does
2370 /// NOT await the spawned exit task — that would terminate the test
2371 /// process — but the 200 ms delay before `process::exit` guarantees the
2372 /// test returns first.
2373 /// Test: this test.
2374 #[tokio::test]
2375 async fn admin_stop_returns_ok() {
2376 let state = test_state();
2377 let Json(body) = admin_stop(State(state)).await;
2378 assert_eq!(body["ok"], Value::Bool(true));
2379 assert_eq!(body["message"].as_str(), Some("shutting down"));
2380 }
2381
2382 /// `POST /api/v1/remember` returns 202 Accepted with a `queued` envelope
2383 /// and the spawned task actually persists a drawer in the target palace.
2384 ///
2385 /// Why: this is the central contract of the fire-and-forget endpoint —
2386 /// the response must come back immediately (no waiting on the redb write)
2387 /// and the work must still happen. Without this test the endpoint could
2388 /// silently regress to either "returns 202 but never writes" or "blocks
2389 /// the caller on the dispatch".
2390 /// What: provisions a palace, POSTs `{content, palace, tags}` to the
2391 /// endpoint, asserts 202 + `{status:"queued"}`, then polls the palace's
2392 /// drawer list (up to ~2 s) until the spawned task lands the write.
2393 /// Test: this test.
2394 #[tokio::test]
2395 async fn remember_async_returns_202_and_persists() {
2396 let state = test_state();
2397 // Pre-create the target palace so the spawned task does not race
2398 // against palace_create — we want to assert only the persist path.
2399 let palace = Palace {
2400 id: PalaceId::new("remember-async"),
2401 name: "remember-async".to_string(),
2402 description: None,
2403 created_at: chrono::Utc::now(),
2404 data_dir: state.data_root.join("remember-async"),
2405 };
2406 state
2407 .registry
2408 .create_palace(&state.data_root, palace)
2409 .expect("create_palace");
2410
2411 let app = router().with_state(state.clone());
2412 let body = json!({
2413 "content": "Trusty-memory note CLI ships a fire-and-forget HTTP endpoint for sub-agents.",
2414 "palace": "remember-async",
2415 "tags": ["docs", "note-cli"],
2416 })
2417 .to_string();
2418 let resp = app
2419 .oneshot(
2420 Request::builder()
2421 .method("POST")
2422 .uri("/api/v1/remember")
2423 .header("content-type", "application/json")
2424 .body(Body::from(body))
2425 .unwrap(),
2426 )
2427 .await
2428 .unwrap();
2429 assert_eq!(
2430 resp.status(),
2431 StatusCode::ACCEPTED,
2432 "remember endpoint must respond 202 immediately"
2433 );
2434 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2435 let v: Value = serde_json::from_slice(&bytes).unwrap();
2436 assert_eq!(v["status"], "queued");
2437
2438 // Wait for the spawned task to finish. The dedup/blocklist gates run
2439 // on the spawn thread, so we cannot synchronously await the write;
2440 // poll the registry until the drawer lands or the deadline expires.
2441 let handle = state
2442 .registry
2443 .open_palace(&state.data_root, &PalaceId::new("remember-async"))
2444 .expect("open palace");
2445 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2446 loop {
2447 let count = handle.drawers.read().len();
2448 if count >= 1 {
2449 break;
2450 }
2451 if std::time::Instant::now() >= deadline {
2452 panic!("spawned remember task never persisted a drawer (count={count})");
2453 }
2454 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2455 }
2456 }
2457
2458 /// `POST /api/v1/remember` with empty `content` returns 400 — the only
2459 /// synchronous validation the endpoint performs.
2460 ///
2461 /// Why: empty content is a programming error in the caller (the spawned
2462 /// task would just hit the content-gate and silently drop the request),
2463 /// so we surface it as a 400 before queueing. Every other failure mode
2464 /// (palace not found, blocklist, dedup) is logged on the spawn task and
2465 /// still returns 202 because the agent has already exited by then.
2466 /// What: POST `{content: ""}` and assert 400. Also covers the trim path —
2467 /// whitespace-only content is treated as empty.
2468 /// Test: this test.
2469 #[tokio::test]
2470 async fn remember_async_rejects_empty_content() {
2471 let state = test_state();
2472 let app = router().with_state(state);
2473 for body in [json!({"content": ""}), json!({"content": " \n "})] {
2474 let resp = app
2475 .clone()
2476 .oneshot(
2477 Request::builder()
2478 .method("POST")
2479 .uri("/api/v1/remember")
2480 .header("content-type", "application/json")
2481 .body(Body::from(body.to_string()))
2482 .unwrap(),
2483 )
2484 .await
2485 .unwrap();
2486 assert_eq!(
2487 resp.status(),
2488 StatusCode::BAD_REQUEST,
2489 "empty content must be rejected; body={body}"
2490 );
2491 }
2492 }
2493
2494 #[tokio::test]
2495 async fn status_endpoint_returns_payload() {
2496 let state = test_state();
2497 let app = router().with_state(state);
2498 let resp = app
2499 .oneshot(
2500 Request::builder()
2501 .uri("/api/v1/status")
2502 .body(Body::empty())
2503 .unwrap(),
2504 )
2505 .await
2506 .unwrap();
2507 assert_eq!(resp.status(), StatusCode::OK);
2508 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
2509 let v: Value = serde_json::from_slice(&bytes).unwrap();
2510 assert!(v["version"].is_string());
2511 assert_eq!(v["palace_count"], 0);
2512 }
2513
2514 #[tokio::test]
2515 async fn unknown_api_returns_404() {
2516 let state = test_state();
2517 let app = router().with_state(state);
2518 let resp = app
2519 .oneshot(
2520 Request::builder()
2521 .uri("/api/v1/does-not-exist")
2522 .body(Body::empty())
2523 .unwrap(),
2524 )
2525 .await
2526 .unwrap();
2527 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2528 }
2529
2530 /// Issue #70 — `…/memories` is a working alias for `…/drawers`.
2531 ///
2532 /// Why: Clients that POST/GET against `…/memories` previously hit a 404
2533 /// because only `/drawers` was registered, which silently broke every
2534 /// store call (and pushed callers onto an OOM-prone CLI fallback). The
2535 /// alias must route to the same handler as `/drawers`.
2536 /// What: Creates a real palace via the registry, then GETs the `/memories`
2537 /// alias and asserts a 200 with a JSON array body (the list-drawers shape).
2538 /// Uses GET, not POST, so the test stays embedder-free (no ONNX load).
2539 /// Test: this test.
2540 #[tokio::test]
2541 async fn memories_alias_routes_to_drawers() {
2542 let state = test_state();
2543 let palace = Palace {
2544 id: PalaceId::new("alias-test"),
2545 name: "alias-test".to_string(),
2546 description: None,
2547 created_at: chrono::Utc::now(),
2548 data_dir: state.data_root.join("alias-test"),
2549 };
2550 state
2551 .registry
2552 .create_palace(&state.data_root, palace)
2553 .expect("create_palace");
2554
2555 let app = router().with_state(state);
2556 let resp = app
2557 .oneshot(
2558 Request::builder()
2559 .uri("/api/v1/palaces/alias-test/memories")
2560 .body(Body::empty())
2561 .unwrap(),
2562 )
2563 .await
2564 .unwrap();
2565 assert_eq!(
2566 resp.status(),
2567 StatusCode::OK,
2568 "the /memories alias must resolve to list_drawers, not 404"
2569 );
2570 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2571 let v: Value = serde_json::from_slice(&bytes).unwrap();
2572 assert!(
2573 v.is_array(),
2574 "the alias must return the list-drawers array shape, got {v:?}"
2575 );
2576 }
2577
2578 /// Issue #133 — `POST /api/v1/palaces/{id}/drawers` must trigger the
2579 /// same auto-KG extraction as the MCP `memory_remember` tool.
2580 ///
2581 /// Why: PR #106 wired auto-extract only into the MCP path; HTTP-origin
2582 /// writes silently skipped it, leaving every palace populated via the
2583 /// HTTP API with an empty KG. This regression test posts a drawer over
2584 /// HTTP and then queries the KG to confirm the expected `tag:`,
2585 /// `room:`, and `topic:` (`#hashtag`) auto-extracted triples landed.
2586 /// What: creates a palace via the registry, posts a drawer with tags +
2587 /// room + a `#hashtag` over the HTTP endpoint, reads
2588 /// `/api/v1/palaces/{id}/kg/graph`, and asserts the auto-extracted
2589 /// triples (provenance = `auto:remember`) appear.
2590 /// Test: this test.
2591 #[tokio::test]
2592 async fn http_create_drawer_runs_auto_kg_extraction() {
2593 let state = test_state();
2594 let palace = Palace {
2595 id: PalaceId::new("kgauto-http"),
2596 name: "kgauto-http".to_string(),
2597 description: None,
2598 created_at: chrono::Utc::now(),
2599 data_dir: state.data_root.join("kgauto-http"),
2600 };
2601 state
2602 .registry
2603 .create_palace(&state.data_root, palace)
2604 .expect("create_palace");
2605
2606 let app = router().with_state(state.clone());
2607 // Why: tag "test" is in the KG extraction deny-list (issue #278), so we
2608 // use "backend" and "kg" tags to exercise the auto-extraction path
2609 // without triggering the deny-list skip.
2610 let body = json!({
2611 "content": "trusty-memory is a Rust crate that ships an MCP server. \
2612 It tracks #mcp and #rust topics with care.",
2613 "room": "Backend",
2614 "tags": ["backend", "kg"],
2615 "importance": 0.5,
2616 })
2617 .to_string();
2618 let resp = app
2619 .clone()
2620 .oneshot(
2621 Request::builder()
2622 .method("POST")
2623 .uri("/api/v1/palaces/kgauto-http/drawers")
2624 .header("content-type", "application/json")
2625 .body(Body::from(body))
2626 .unwrap(),
2627 )
2628 .await
2629 .unwrap();
2630 assert_eq!(
2631 resp.status(),
2632 StatusCode::OK,
2633 "create_drawer must return 200 OK"
2634 );
2635
2636 // Read the KG graph for the same palace and assert auto-extracted
2637 // triples landed. The exact set is exercised in
2638 // `tools::tests::auto_kg_extraction_hooks_into_memory_remember`; here
2639 // we only need to confirm the HTTP path now mirrors the MCP path.
2640 let resp = app
2641 .oneshot(
2642 Request::builder()
2643 .uri("/api/v1/palaces/kgauto-http/kg/graph")
2644 .body(Body::empty())
2645 .unwrap(),
2646 )
2647 .await
2648 .unwrap();
2649 assert_eq!(resp.status(), StatusCode::OK);
2650 let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
2651 let v: Value = serde_json::from_slice(&bytes).unwrap();
2652 let triples = v["triples"].as_array().expect("triples array");
2653 assert!(
2654 !triples.is_empty(),
2655 "HTTP-origin drawer must populate the KG; got empty graph"
2656 );
2657 let auto: Vec<&Value> = triples
2658 .iter()
2659 .filter(|t| t["provenance"].as_str() == Some(crate::kg_extract::AUTO_PROVENANCE))
2660 .collect();
2661 assert!(
2662 !auto.is_empty(),
2663 "expected at least one auto-extracted triple in HTTP-populated KG; got: {triples:?}"
2664 );
2665 // Spot-check the tag-as-subject encoding survived (matches the MCP
2666 // path's behaviour and proves the extractor saw the body's tags).
2667 // Note: "test" is in the deny-list, so we use "backend" in the drawer
2668 // tags above (issue #278); assert on that tag instead.
2669 assert!(
2670 auto.iter()
2671 .any(|t| t["subject"].as_str() == Some("tag:backend")),
2672 "expected `tag:backend` auto-extracted edge, got: {auto:?}"
2673 );
2674 // Hashtag mention triples (room-aware extractor).
2675 assert!(
2676 auto.iter()
2677 .any(|t| t["predicate"].as_str() == Some("mentioned-in")),
2678 "expected at least one #hashtag mention triple, got: {auto:?}"
2679 );
2680 }
2681
2682 #[tokio::test]
2683 async fn create_then_list_palace() {
2684 let state = test_state();
2685 let app = router().with_state(state.clone());
2686 let body = json!({"name": "web-test", "description": "from test"}).to_string();
2687 let resp = app
2688 .clone()
2689 .oneshot(
2690 Request::builder()
2691 .method("POST")
2692 .uri("/api/v1/palaces")
2693 .header("content-type", "application/json")
2694 .body(Body::from(body))
2695 .unwrap(),
2696 )
2697 .await
2698 .unwrap();
2699 assert_eq!(resp.status(), StatusCode::OK);
2700
2701 let resp = app
2702 .oneshot(
2703 Request::builder()
2704 .uri("/api/v1/palaces")
2705 .body(Body::empty())
2706 .unwrap(),
2707 )
2708 .await
2709 .unwrap();
2710 assert_eq!(resp.status(), StatusCode::OK);
2711 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2712 let v: Value = serde_json::from_slice(&bytes).unwrap();
2713 let arr = v.as_array().expect("array");
2714 assert!(arr.iter().any(|p| p["id"] == "web-test"));
2715 }
2716
2717 /// Why: Issue #180 — verify the happy path: create an empty palace,
2718 /// `DELETE /api/v1/palaces/{id}` returns 204, and a follow-up
2719 /// `GET /api/v1/palaces/{id}` returns 404 because the directory is gone.
2720 /// What: Drives the router through axum's `oneshot` testing layer; no
2721 /// query parameters are passed so `force` defaults to `false`. A freshly
2722 /// created palace has no drawers, so the conflict guard does not fire.
2723 /// Test: This test itself.
2724 #[tokio::test]
2725 async fn delete_palace_removes_dir_when_empty() {
2726 let state = test_state();
2727 let app = router().with_state(state.clone());
2728 let body = json!({"name": "to-delete"}).to_string();
2729 let resp = app
2730 .clone()
2731 .oneshot(
2732 Request::builder()
2733 .method("POST")
2734 .uri("/api/v1/palaces")
2735 .header("content-type", "application/json")
2736 .body(Body::from(body))
2737 .unwrap(),
2738 )
2739 .await
2740 .unwrap();
2741 assert_eq!(resp.status(), StatusCode::OK);
2742
2743 let resp = app
2744 .clone()
2745 .oneshot(
2746 Request::builder()
2747 .method("DELETE")
2748 .uri("/api/v1/palaces/to-delete")
2749 .body(Body::empty())
2750 .unwrap(),
2751 )
2752 .await
2753 .unwrap();
2754 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2755
2756 // Confirm the palace is gone from the on-disk registry.
2757 let resp = app
2758 .oneshot(
2759 Request::builder()
2760 .uri("/api/v1/palaces/to-delete")
2761 .body(Body::empty())
2762 .unwrap(),
2763 )
2764 .await
2765 .unwrap();
2766 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2767
2768 // And the on-disk directory itself was removed.
2769 let palace_dir = state.data_root.join("to-delete");
2770 assert!(
2771 !palace_dir.exists(),
2772 "palace dir should be removed: {}",
2773 palace_dir.display()
2774 );
2775 }
2776
2777 /// Why: Issue #180 — without `force=true` we must refuse to drop a
2778 /// palace that still has drawers, otherwise a stray DELETE could nuke
2779 /// hours of memory in one request.
2780 /// What: Create a palace, write a drawer into it, then DELETE without
2781 /// `force`. Expect 409 Conflict and verify the palace and drawer are
2782 /// still on disk.
2783 /// Test: This test itself.
2784 #[tokio::test]
2785 async fn delete_palace_refuses_when_drawers_present() {
2786 let state = test_state();
2787 let app = router().with_state(state.clone());
2788 // Create the palace.
2789 let resp = app
2790 .clone()
2791 .oneshot(
2792 Request::builder()
2793 .method("POST")
2794 .uri("/api/v1/palaces")
2795 .header("content-type", "application/json")
2796 .body(Body::from(json!({"name": "keep-me"}).to_string()))
2797 .unwrap(),
2798 )
2799 .await
2800 .unwrap();
2801 assert_eq!(resp.status(), StatusCode::OK);
2802 // Add a drawer so the conflict guard fires.
2803 let resp = app
2804 .clone()
2805 .oneshot(
2806 Request::builder()
2807 .method("POST")
2808 .uri("/api/v1/palaces/keep-me/drawers")
2809 .header("content-type", "application/json")
2810 .body(Body::from(
2811 json!({
2812 "content": "Important fact that should not be deleted accidentally.",
2813 "tags": [],
2814 })
2815 .to_string(),
2816 ))
2817 .unwrap(),
2818 )
2819 .await
2820 .unwrap();
2821 assert_eq!(resp.status(), StatusCode::OK);
2822
2823 let resp = app
2824 .clone()
2825 .oneshot(
2826 Request::builder()
2827 .method("DELETE")
2828 .uri("/api/v1/palaces/keep-me")
2829 .body(Body::empty())
2830 .unwrap(),
2831 )
2832 .await
2833 .unwrap();
2834 assert_eq!(resp.status(), StatusCode::CONFLICT);
2835
2836 // Palace still resolves.
2837 let resp = app
2838 .oneshot(
2839 Request::builder()
2840 .uri("/api/v1/palaces/keep-me")
2841 .body(Body::empty())
2842 .unwrap(),
2843 )
2844 .await
2845 .unwrap();
2846 assert_eq!(resp.status(), StatusCode::OK);
2847 }
2848
2849 /// Why: Issue #180 — `?force=true` is the explicit destructive opt-in;
2850 /// the conflict guard must yield and the palace must vanish even with
2851 /// drawers present.
2852 /// What: Same setup as the conflict test, but pass `?force=true` and
2853 /// assert the 204 + 404 follow-up shape.
2854 /// Test: This test itself.
2855 #[tokio::test]
2856 async fn delete_palace_force_removes_populated_palace() {
2857 let state = test_state();
2858 let app = router().with_state(state.clone());
2859 let resp = app
2860 .clone()
2861 .oneshot(
2862 Request::builder()
2863 .method("POST")
2864 .uri("/api/v1/palaces")
2865 .header("content-type", "application/json")
2866 .body(Body::from(json!({"name": "force-delete"}).to_string()))
2867 .unwrap(),
2868 )
2869 .await
2870 .unwrap();
2871 assert_eq!(resp.status(), StatusCode::OK);
2872 let resp = app
2873 .clone()
2874 .oneshot(
2875 Request::builder()
2876 .method("POST")
2877 .uri("/api/v1/palaces/force-delete/drawers")
2878 .header("content-type", "application/json")
2879 .body(Body::from(
2880 json!({"content": "Sacrificial drawer for the force-delete path.", "tags": []}).to_string(),
2881 ))
2882 .unwrap(),
2883 )
2884 .await
2885 .unwrap();
2886 assert_eq!(resp.status(), StatusCode::OK);
2887
2888 let resp = app
2889 .clone()
2890 .oneshot(
2891 Request::builder()
2892 .method("DELETE")
2893 .uri("/api/v1/palaces/force-delete?force=true")
2894 .body(Body::empty())
2895 .unwrap(),
2896 )
2897 .await
2898 .unwrap();
2899 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
2900
2901 let resp = app
2902 .oneshot(
2903 Request::builder()
2904 .uri("/api/v1/palaces/force-delete")
2905 .body(Body::empty())
2906 .unwrap(),
2907 )
2908 .await
2909 .unwrap();
2910 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2911 }
2912
2913 /// Why: Issue #180 — deleting a missing palace must yield 404 so
2914 /// idempotent retries on the client are distinguishable from the
2915 /// "drawers present" precondition failure.
2916 /// What: DELETE against a never-created id and assert 404.
2917 /// Test: This test itself.
2918 #[tokio::test]
2919 async fn delete_palace_returns_not_found_for_missing_id() {
2920 let state = test_state();
2921 let app = router().with_state(state);
2922 let resp = app
2923 .oneshot(
2924 Request::builder()
2925 .method("DELETE")
2926 .uri("/api/v1/palaces/never-existed")
2927 .body(Body::empty())
2928 .unwrap(),
2929 )
2930 .await
2931 .unwrap();
2932 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2933 }
2934
2935 /// Why: Issue #180 follow-up — verify the happy path of `PATCH
2936 /// /api/v1/palaces/{id}`: create a palace, rename it, and confirm
2937 /// `GET /api/v1/palaces/{id}` returns the new display name. The id
2938 /// (which is the on-disk directory) must stay stable.
2939 /// What: POST a palace named "rename-me", PATCH with a new display
2940 /// name, expect 200 + payload showing the rename, then GET to confirm
2941 /// persistence to disk.
2942 /// Test: This test itself.
2943 #[tokio::test]
2944 async fn update_palace_name_renames_palace() {
2945 let state = test_state();
2946 let app = router().with_state(state);
2947 let resp = app
2948 .clone()
2949 .oneshot(
2950 Request::builder()
2951 .method("POST")
2952 .uri("/api/v1/palaces")
2953 .header("content-type", "application/json")
2954 .body(Body::from(json!({"name": "rename-me"}).to_string()))
2955 .unwrap(),
2956 )
2957 .await
2958 .unwrap();
2959 assert_eq!(resp.status(), StatusCode::OK);
2960
2961 let resp = app
2962 .clone()
2963 .oneshot(
2964 Request::builder()
2965 .method("PATCH")
2966 .uri("/api/v1/palaces/rename-me")
2967 .header("content-type", "application/json")
2968 .body(Body::from(json!({"name": "New Display Name"}).to_string()))
2969 .unwrap(),
2970 )
2971 .await
2972 .unwrap();
2973 assert_eq!(resp.status(), StatusCode::OK);
2974 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2975 let v: Value = serde_json::from_slice(&bytes).unwrap();
2976 assert_eq!(v["id"].as_str(), Some("rename-me"));
2977 assert_eq!(v["name"].as_str(), Some("New Display Name"));
2978
2979 let resp = app
2980 .oneshot(
2981 Request::builder()
2982 .uri("/api/v1/palaces/rename-me")
2983 .body(Body::empty())
2984 .unwrap(),
2985 )
2986 .await
2987 .unwrap();
2988 assert_eq!(resp.status(), StatusCode::OK);
2989 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
2990 let v: Value = serde_json::from_slice(&bytes).unwrap();
2991 assert_eq!(v["id"].as_str(), Some("rename-me"));
2992 assert_eq!(v["name"].as_str(), Some("New Display Name"));
2993 }
2994
2995 /// Why: Issue #180 follow-up — empty / whitespace-only names would
2996 /// break the dashboard label. Reject with 400 so the caller knows the
2997 /// request was well-formed but the value is invalid.
2998 /// What: Create a palace, PATCH with `{"name": " "}`, expect 400.
2999 /// Test: This test itself.
3000 #[tokio::test]
3001 async fn update_palace_name_rejects_empty_name() {
3002 let state = test_state();
3003 let app = router().with_state(state);
3004 let resp = app
3005 .clone()
3006 .oneshot(
3007 Request::builder()
3008 .method("POST")
3009 .uri("/api/v1/palaces")
3010 .header("content-type", "application/json")
3011 .body(Body::from(json!({"name": "keep-name"}).to_string()))
3012 .unwrap(),
3013 )
3014 .await
3015 .unwrap();
3016 assert_eq!(resp.status(), StatusCode::OK);
3017
3018 let resp = app
3019 .oneshot(
3020 Request::builder()
3021 .method("PATCH")
3022 .uri("/api/v1/palaces/keep-name")
3023 .header("content-type", "application/json")
3024 .body(Body::from(json!({"name": " "}).to_string()))
3025 .unwrap(),
3026 )
3027 .await
3028 .unwrap();
3029 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
3030 }
3031
3032 /// Why: Issue #180 follow-up — patching a non-existent palace must
3033 /// yield 404 so retries against the wrong id surface the real problem
3034 /// rather than silently no-op'ing.
3035 /// What: PATCH against a never-created id and assert 404.
3036 /// Test: This test itself.
3037 #[tokio::test]
3038 async fn update_palace_name_returns_not_found_for_missing_id() {
3039 let state = test_state();
3040 let app = router().with_state(state);
3041 let resp = app
3042 .oneshot(
3043 Request::builder()
3044 .method("PATCH")
3045 .uri("/api/v1/palaces/no-such-palace")
3046 .header("content-type", "application/json")
3047 .body(Body::from(json!({"name": "irrelevant"}).to_string()))
3048 .unwrap(),
3049 )
3050 .await
3051 .unwrap();
3052 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3053 }
3054
3055 /// Why: The operator TUI's MEMORY tab reads `node_count`, `edge_count`,
3056 /// `community_count`, and `is_compacting` straight off the
3057 /// `/api/v1/palaces` payload. If any of those fields disappear or change
3058 /// type the spinner / counters break silently. Pin the shape here.
3059 /// What: Creates a palace, lists `/api/v1/palaces`, and asserts every new
3060 /// field is present and typed as expected (numbers default to 0, the
3061 /// compacting flag defaults to false on a freshly-opened palace).
3062 /// Test: This test itself.
3063 #[tokio::test]
3064 async fn palace_list_includes_graph_counts() {
3065 let state = test_state();
3066 let app = router().with_state(state.clone());
3067 let body = json!({"name": "graph-counts", "description": null}).to_string();
3068 let resp = app
3069 .clone()
3070 .oneshot(
3071 Request::builder()
3072 .method("POST")
3073 .uri("/api/v1/palaces")
3074 .header("content-type", "application/json")
3075 .body(Body::from(body))
3076 .unwrap(),
3077 )
3078 .await
3079 .unwrap();
3080 assert_eq!(resp.status(), StatusCode::OK);
3081
3082 let resp = app
3083 .oneshot(
3084 Request::builder()
3085 .uri("/api/v1/palaces")
3086 .body(Body::empty())
3087 .unwrap(),
3088 )
3089 .await
3090 .unwrap();
3091 assert_eq!(resp.status(), StatusCode::OK);
3092 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3093 let v: Value = serde_json::from_slice(&bytes).unwrap();
3094 let arr = v.as_array().expect("array");
3095 let row = arr
3096 .iter()
3097 .find(|p| p["id"] == "graph-counts")
3098 .expect("created palace must appear in list");
3099 assert_eq!(row["node_count"].as_u64(), Some(0));
3100 assert_eq!(row["edge_count"].as_u64(), Some(0));
3101 assert_eq!(row["community_count"].as_u64(), Some(0));
3102 assert_eq!(row["is_compacting"].as_bool(), Some(false));
3103 }
3104
3105 /// Why: The enriched status payload backs the dashboard's top-row stats;
3106 /// it must always include the new total_* counters, even on an empty data
3107 /// root, so the UI can render zeros without special-casing missing fields.
3108 /// What: Hit `/api/v1/status` on a fresh state and assert the new fields
3109 /// are present and set to 0.
3110 /// Test: This test itself.
3111 #[tokio::test]
3112 async fn status_includes_total_counters() {
3113 let state = test_state();
3114 let app = router().with_state(state);
3115 let resp = app
3116 .oneshot(
3117 Request::builder()
3118 .uri("/api/v1/status")
3119 .body(Body::empty())
3120 .unwrap(),
3121 )
3122 .await
3123 .unwrap();
3124 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3125 let v: Value = serde_json::from_slice(&bytes).unwrap();
3126 assert_eq!(v["total_drawers"], 0);
3127 assert_eq!(v["total_vectors"], 0);
3128 assert_eq!(v["total_kg_triples"], 0);
3129 }
3130
3131 /// Why: `/api/v1/dream/status` must return a well-shaped payload even
3132 /// when no palace has ever run a dream cycle (so the dashboard's first
3133 /// load doesn't error).
3134 /// What: Hit the endpoint on a fresh state and assert `last_run_at` is
3135 /// null and the counters are zero.
3136 /// Test: This test itself.
3137 #[tokio::test]
3138 async fn dream_status_empty_returns_nulls() {
3139 let state = test_state();
3140 let app = router().with_state(state);
3141 let resp = app
3142 .oneshot(
3143 Request::builder()
3144 .uri("/api/v1/dream/status")
3145 .body(Body::empty())
3146 .unwrap(),
3147 )
3148 .await
3149 .unwrap();
3150 assert_eq!(resp.status(), StatusCode::OK);
3151 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3152 let v: Value = serde_json::from_slice(&bytes).unwrap();
3153 assert!(v["last_run_at"].is_null());
3154 assert_eq!(v["merged"], 0);
3155 assert_eq!(v["pruned"], 0);
3156 }
3157
3158 /// Why: `/api/v1/chat/providers` must return a well-shaped payload even
3159 /// when no provider is available, so the SPA can render disabled states
3160 /// without special-casing missing fields.
3161 /// What: Hit the endpoint on a fresh state; assert it returns `providers`
3162 /// (an array of length 2) and an `active` field (possibly null).
3163 /// Test: This test itself.
3164 #[tokio::test]
3165 async fn providers_endpoint_returns_payload() {
3166 let state = test_state();
3167 let app = router().with_state(state);
3168 let resp = app
3169 .oneshot(
3170 Request::builder()
3171 .uri("/api/v1/chat/providers")
3172 .body(Body::empty())
3173 .unwrap(),
3174 )
3175 .await
3176 .unwrap();
3177 assert_eq!(resp.status(), StatusCode::OK);
3178 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3179 let v: Value = serde_json::from_slice(&bytes).unwrap();
3180 let arr = v["providers"].as_array().expect("providers array");
3181 assert_eq!(arr.len(), 2);
3182 let names: Vec<&str> = arr.iter().filter_map(|p| p["name"].as_str()).collect();
3183 assert!(names.contains(&"ollama"));
3184 assert!(names.contains(&"openrouter"));
3185 // `active` may be null when no provider is configured/reachable.
3186 assert!(v.get("active").is_some());
3187 }
3188
3189 /// Why: Chat-session CRUD must round-trip end-to-end through the HTTP
3190 /// surface — create returns an id, list shows it, get returns the
3191 /// (empty) history, delete removes it.
3192 /// What: Create a palace, then exercise the four session endpoints
3193 /// sequentially, asserting JSON shapes at each step.
3194 /// Test: This test itself.
3195 #[tokio::test]
3196 async fn chat_session_crud_round_trip() {
3197 let state = test_state();
3198 // Pre-create a palace dir so session store has a place to live.
3199 let palace = trusty_common::memory_core::Palace {
3200 id: PalaceId::new("sess-test"),
3201 name: "sess-test".to_string(),
3202 description: None,
3203 created_at: chrono::Utc::now(),
3204 data_dir: state.data_root.join("sess-test"),
3205 };
3206 state
3207 .registry
3208 .create_palace(&state.data_root, palace)
3209 .expect("create_palace");
3210 let app = router().with_state(state);
3211
3212 // Create
3213 let resp = app
3214 .clone()
3215 .oneshot(
3216 Request::builder()
3217 .method("POST")
3218 .uri("/api/v1/palaces/sess-test/chat/sessions")
3219 .header("content-type", "application/json")
3220 .body(Body::from(json!({"title":"first chat"}).to_string()))
3221 .unwrap(),
3222 )
3223 .await
3224 .unwrap();
3225 assert_eq!(resp.status(), StatusCode::OK);
3226 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3227 let v: Value = serde_json::from_slice(&bytes).unwrap();
3228 let sid = v["id"].as_str().expect("session id").to_string();
3229
3230 // List
3231 let resp = app
3232 .clone()
3233 .oneshot(
3234 Request::builder()
3235 .uri("/api/v1/palaces/sess-test/chat/sessions")
3236 .body(Body::empty())
3237 .unwrap(),
3238 )
3239 .await
3240 .unwrap();
3241 assert_eq!(resp.status(), StatusCode::OK);
3242 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3243 let v: Value = serde_json::from_slice(&bytes).unwrap();
3244 let arr = v.as_array().expect("array");
3245 assert!(arr.iter().any(|s| s["id"] == sid));
3246
3247 // Get
3248 let resp = app
3249 .clone()
3250 .oneshot(
3251 Request::builder()
3252 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3253 .body(Body::empty())
3254 .unwrap(),
3255 )
3256 .await
3257 .unwrap();
3258 assert_eq!(resp.status(), StatusCode::OK);
3259
3260 // Delete
3261 let resp = app
3262 .clone()
3263 .oneshot(
3264 Request::builder()
3265 .method("DELETE")
3266 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3267 .body(Body::empty())
3268 .unwrap(),
3269 )
3270 .await
3271 .unwrap();
3272 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
3273
3274 // Get after delete -> 404
3275 let resp = app
3276 .oneshot(
3277 Request::builder()
3278 .uri(format!("/api/v1/palaces/sess-test/chat/sessions/{sid}"))
3279 .body(Body::empty())
3280 .unwrap(),
3281 )
3282 .await
3283 .unwrap();
3284 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3285 }
3286
3287 /// Why: issue #99 — the HTTP surface for inter-project messaging is what
3288 /// `trusty-memory send-message` and `trusty-memory inbox-check` both
3289 /// drive. We pin the round-trip (send → list-unread → mark-read →
3290 /// list-empty) so a future refactor cannot accidentally break either
3291 /// CLI without a failing test.
3292 /// What: pre-creates the recipient palace, POSTs a message, asserts
3293 /// `unread_only=true` returns exactly one entry with the right
3294 /// envelope fields, POSTs to mark_read, asserts the unread inbox is
3295 /// now empty, and confirms the audit view (`unread_only=false`) still
3296 /// surfaces the read message.
3297 /// Test: this test itself.
3298 #[tokio::test]
3299 async fn messages_endpoint_round_trip() {
3300 let state = test_state();
3301 let palace = trusty_common::memory_core::Palace {
3302 id: PalaceId::new("msg-test"),
3303 name: "msg-test".to_string(),
3304 description: None,
3305 created_at: chrono::Utc::now(),
3306 data_dir: state.data_root.join("msg-test"),
3307 };
3308 state
3309 .registry
3310 .create_palace(&state.data_root, palace)
3311 .expect("create_palace");
3312 let app = router().with_state(state);
3313
3314 // POST /api/v1/messages — send.
3315 let resp = app
3316 .clone()
3317 .oneshot(
3318 Request::builder()
3319 .method("POST")
3320 .uri("/api/v1/messages")
3321 .header("content-type", "application/json")
3322 .body(Body::from(
3323 json!({
3324 "to_palace": "msg-test",
3325 "from_palace": "sender-palace",
3326 "purpose": "task",
3327 "content": "please refresh schema"
3328 })
3329 .to_string(),
3330 ))
3331 .unwrap(),
3332 )
3333 .await
3334 .unwrap();
3335 assert_eq!(resp.status(), StatusCode::OK);
3336 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3337 let send_resp: Value = serde_json::from_slice(&bytes).unwrap();
3338 assert_eq!(send_resp["status"], "sent");
3339 let drawer_id = send_resp["drawer_id"]
3340 .as_str()
3341 .expect("drawer_id")
3342 .to_string();
3343
3344 // GET unread inbox.
3345 let resp = app
3346 .clone()
3347 .oneshot(
3348 Request::builder()
3349 .uri("/api/v1/messages?palace=msg-test&unread_only=true")
3350 .body(Body::empty())
3351 .unwrap(),
3352 )
3353 .await
3354 .unwrap();
3355 assert_eq!(resp.status(), StatusCode::OK);
3356 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
3357 let list: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3358 assert_eq!(list.len(), 1);
3359 assert_eq!(list[0]["id"], drawer_id);
3360 assert_eq!(list[0]["from_palace"], "sender-palace");
3361 assert_eq!(list[0]["to_palace"], "msg-test");
3362 assert_eq!(list[0]["purpose"], "task");
3363 assert_eq!(list[0]["content"], "please refresh schema");
3364 assert_eq!(list[0]["read"], false);
3365 assert!(list[0]["formatted"]
3366 .as_str()
3367 .unwrap()
3368 .contains("sender-palace"));
3369
3370 // POST mark_read.
3371 let resp = app
3372 .clone()
3373 .oneshot(
3374 Request::builder()
3375 .method("POST")
3376 .uri("/api/v1/messages/mark_read")
3377 .header("content-type", "application/json")
3378 .body(Body::from(
3379 json!({"palace": "msg-test", "drawer_id": drawer_id}).to_string(),
3380 ))
3381 .unwrap(),
3382 )
3383 .await
3384 .unwrap();
3385 assert_eq!(resp.status(), StatusCode::OK);
3386 let bytes = to_bytes(resp.into_body(), 1024).await.unwrap();
3387 let mark: Value = serde_json::from_slice(&bytes).unwrap();
3388 assert_eq!(mark["flipped"], true);
3389
3390 // GET unread again — empty.
3391 let resp = app
3392 .clone()
3393 .oneshot(
3394 Request::builder()
3395 .uri("/api/v1/messages?palace=msg-test&unread_only=true")
3396 .body(Body::empty())
3397 .unwrap(),
3398 )
3399 .await
3400 .unwrap();
3401 assert_eq!(resp.status(), StatusCode::OK);
3402 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3403 let list: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3404 assert!(list.is_empty(), "inbox cleared after mark_read");
3405
3406 // GET history (unread_only=false) — still has the message, now read.
3407 let resp = app
3408 .oneshot(
3409 Request::builder()
3410 .uri("/api/v1/messages?palace=msg-test&unread_only=false")
3411 .body(Body::empty())
3412 .unwrap(),
3413 )
3414 .await
3415 .unwrap();
3416 assert_eq!(resp.status(), StatusCode::OK);
3417 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
3418 let history: Vec<Value> = serde_json::from_slice(&bytes).unwrap();
3419 assert_eq!(history.len(), 1);
3420 assert_eq!(history[0]["read"], true);
3421 }
3422
3423 /// Why: The chat assistant's tool surface is part of the public API — any
3424 /// drift in tool names or required-argument lists is a breaking change for
3425 /// the UI and any external automation. Pin the shape here so a refactor
3426 /// has to acknowledge it.
3427 /// What: Snapshots the names + every tool's `required` array.
3428 /// Test: This test itself.
3429 #[test]
3430 fn all_tools_returns_expected_set() {
3431 let tools = crate::chat::all_tools();
3432 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
3433 assert_eq!(
3434 names,
3435 vec![
3436 "list_palaces",
3437 "get_palace",
3438 "recall_memories",
3439 "list_drawers",
3440 "kg_query",
3441 "get_config",
3442 "get_status",
3443 "get_dream_status",
3444 "get_palace_dream_status",
3445 "create_memory",
3446 "kg_assert",
3447 "memory_recall_all",
3448 ]
3449 );
3450 // Every tool's `parameters` must be a JSON Schema object with a
3451 // `required` array (possibly empty).
3452 for t in &tools {
3453 assert_eq!(
3454 t.parameters["type"], "object",
3455 "tool {} schema type",
3456 t.name
3457 );
3458 assert!(
3459 t.parameters["required"].is_array(),
3460 "tool {} required not array",
3461 t.name
3462 );
3463 }
3464 }
3465
3466 /// Why: `execute_tool` is the bridge between the model's tool_call
3467 /// arguments and the live Rust core. We exercise the happy path
3468 /// (`list_palaces` on an empty registry returns `[]`) and the unknown-
3469 /// tool path (returns `{"error": "..."}`) to lock down both branches.
3470 /// What: Calls execute_tool against a fresh `AppState`.
3471 /// Test: This test itself.
3472 #[tokio::test]
3473 async fn execute_tool_dispatches_known_tools() {
3474 let state = test_state();
3475 let result = crate::chat::execute_tool("list_palaces", "{}", &state).await;
3476 assert!(
3477 result.is_array(),
3478 "list_palaces should be array, got {result}"
3479 );
3480 assert_eq!(result.as_array().unwrap().len(), 0);
3481
3482 let unknown = crate::chat::execute_tool("not_a_tool", "{}", &state).await;
3483 assert!(
3484 unknown["error"]
3485 .as_str()
3486 .unwrap_or("")
3487 .contains("unknown tool"),
3488 "expected unknown-tool error, got {unknown}"
3489 );
3490
3491 let missing = crate::chat::execute_tool("get_palace", "{}", &state).await;
3492 assert!(
3493 missing["error"]
3494 .as_str()
3495 .unwrap_or("")
3496 .contains("palace_id"),
3497 "expected missing-arg error, got {missing}"
3498 );
3499 }
3500
3501 /// Why: The SSE event bus is the dashboard's live-update transport;
3502 /// regressing it would silently break the UI. Subscribing before the
3503 /// emit guarantees the broadcast channel has a receiver when the
3504 /// handler fires, so we can deterministically observe the event.
3505 /// What: Subscribes to `state.events`, calls the `create_palace`
3506 /// handler through the router, then asserts a `PalaceCreated` event
3507 /// (and a follow-up status event from drawer mutation) flow through.
3508 /// Test: `cargo test -p trusty-memory-mcp sse_broadcast_emits_palace_created`.
3509 #[tokio::test]
3510 async fn sse_broadcast_emits_palace_created() {
3511 let state = test_state();
3512 let mut rx = state.events.subscribe();
3513 let app = router().with_state(state.clone());
3514 let body = json!({"name": "sse-test"}).to_string();
3515 let resp = app
3516 .oneshot(
3517 Request::builder()
3518 .method("POST")
3519 .uri("/api/v1/palaces")
3520 .header("content-type", "application/json")
3521 .body(Body::from(body))
3522 .unwrap(),
3523 )
3524 .await
3525 .unwrap();
3526 assert_eq!(resp.status(), StatusCode::OK);
3527 // The handler should have emitted PalaceCreated before returning.
3528 let event = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
3529 .await
3530 .expect("event received within timeout")
3531 .expect("event channel still open");
3532 match event {
3533 DaemonEvent::PalaceCreated { id, name, source } => {
3534 assert_eq!(id, "sse-test");
3535 assert_eq!(name, "sse-test");
3536 assert_eq!(source, ActivitySource::Http);
3537 }
3538 other => panic!("expected PalaceCreated, got {other:?}"),
3539 }
3540 }
3541
3542 /// Why: Confirm the `/sse` endpoint speaks `text/event-stream` and emits
3543 /// the initial `connected` frame so dashboard clients can rely on a
3544 /// known greeting.
3545 /// What: Issues a GET against `/sse`, reads the response body chunk,
3546 /// asserts the content-type header and the first SSE frame shape.
3547 /// Test: `cargo test -p trusty-memory-mcp sse_endpoint_emits_connected_frame`.
3548 #[tokio::test]
3549 async fn sse_endpoint_emits_connected_frame() {
3550 use axum::routing::get;
3551 let state = test_state();
3552 let app = router()
3553 .route("/sse", get(crate::sse_handler))
3554 .with_state(state);
3555 let resp = app
3556 .oneshot(Request::builder().uri("/sse").body(Body::empty()).unwrap())
3557 .await
3558 .unwrap();
3559 assert_eq!(resp.status(), StatusCode::OK);
3560 assert_eq!(
3561 resp.headers()
3562 .get(header::CONTENT_TYPE)
3563 .and_then(|v| v.to_str().ok()),
3564 Some("text/event-stream")
3565 );
3566 // Read just the first chunk (the connected frame) — the stream stays
3567 // open otherwise, so we use a small read budget plus timeout.
3568 let body = resp.into_body();
3569 let bytes =
3570 tokio::time::timeout(std::time::Duration::from_millis(500), to_bytes(body, 4096))
3571 .await
3572 .ok()
3573 .and_then(|r| r.ok())
3574 .unwrap_or_default();
3575 let text = String::from_utf8_lossy(&bytes);
3576 assert!(
3577 text.contains("\"type\":\"connected\""),
3578 "expected connected frame, got: {text}"
3579 );
3580 }
3581
3582 /// Why: `/api/v1/dream/status` must sum per-palace `dream_stats.json`
3583 /// counters and surface the most recent `last_run_at`. A regression that
3584 /// returned only the first palace's stats would silently break the
3585 /// "global dream activity" dashboard panel.
3586 /// What: Pre-seeds two palace dirs under the AppState root, writes a
3587 /// distinct `PersistedDreamStats` JSON file into each, hits the endpoint,
3588 /// and asserts the integer fields are summed and `last_run_at` equals the
3589 /// newer of the two timestamps.
3590 /// Test: This test itself.
3591 #[tokio::test]
3592 async fn dream_status_aggregates_across_palaces() {
3593 use trusty_common::memory_core::dream::{DreamStats, PersistedDreamStats};
3594
3595 let state = test_state();
3596 // Two palace directories — each must contain a `palace.json` so
3597 // `PalaceRegistry::list_palaces` sees them, plus a `dream_stats.json`
3598 // with distinct counter values.
3599 for (id, stats, ts) in [
3600 (
3601 "palace-a",
3602 DreamStats {
3603 merged: 1,
3604 pruned: 2,
3605 compacted: 3,
3606 closets_updated: 4,
3607 duration_ms: 100,
3608 ..DreamStats::default()
3609 },
3610 chrono::Utc::now() - chrono::Duration::seconds(60),
3611 ),
3612 (
3613 "palace-b",
3614 DreamStats {
3615 merged: 10,
3616 pruned: 20,
3617 compacted: 30,
3618 closets_updated: 40,
3619 duration_ms: 200,
3620 ..DreamStats::default()
3621 },
3622 chrono::Utc::now(),
3623 ),
3624 ] {
3625 let palace = trusty_common::memory_core::Palace {
3626 id: PalaceId::new(id),
3627 name: id.to_string(),
3628 description: None,
3629 created_at: chrono::Utc::now(),
3630 data_dir: state.data_root.join(id),
3631 };
3632 state
3633 .registry
3634 .create_palace(&state.data_root, palace)
3635 .expect("create palace");
3636 let persisted = PersistedDreamStats {
3637 last_run_at: ts,
3638 stats,
3639 };
3640 persisted
3641 .save(&state.data_root.join(id))
3642 .expect("save dream stats");
3643 }
3644
3645 let later = chrono::Utc::now();
3646 let app = router().with_state(state);
3647 let resp = app
3648 .oneshot(
3649 Request::builder()
3650 .uri("/api/v1/dream/status")
3651 .body(Body::empty())
3652 .unwrap(),
3653 )
3654 .await
3655 .unwrap();
3656 assert_eq!(resp.status(), StatusCode::OK);
3657 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3658 let v: Value = serde_json::from_slice(&bytes).unwrap();
3659
3660 // Aggregated counters.
3661 assert_eq!(v["merged"], 11);
3662 assert_eq!(v["pruned"], 22);
3663 assert_eq!(v["compacted"], 33);
3664 assert_eq!(v["closets_updated"], 44);
3665 assert_eq!(v["duration_ms"], 300);
3666
3667 // `last_run_at` is the more-recent of the two timestamps.
3668 let last = v["last_run_at"].as_str().expect("last_run_at is string");
3669 let parsed: chrono::DateTime<chrono::Utc> = last
3670 .parse()
3671 .expect("last_run_at parses as RFC3339 timestamp");
3672 assert!(
3673 parsed <= later,
3674 "last_run_at ({parsed}) should not exceed wall clock ({later})"
3675 );
3676 // Must have picked palace-b's newer stamp, not palace-a's older one.
3677 let cutoff = chrono::Utc::now() - chrono::Duration::seconds(30);
3678 assert!(
3679 parsed >= cutoff,
3680 "expected the newer (palace-b) timestamp; got {parsed}"
3681 );
3682 }
3683
3684 /// Why: `POST /api/v1/dream/run` triggers a dream cycle across every
3685 /// palace and must return the aggregated stats. Even when no palace
3686 /// has work to do (empty registry) the endpoint must round-trip 200
3687 /// with the well-formed payload shape so the dashboard's "Run now"
3688 /// button never fails the UI.
3689 /// What: Pre-creates one palace via the registry, posts to the endpoint,
3690 /// and asserts the response is 200 with all expected fields present.
3691 /// Deeper assertions (specific merged/pruned counts) are skipped here
3692 /// because running a full dream cycle requires the ONNX embedder load
3693 /// path and we want this test to stay fast and embedder-free.
3694 /// Test: This test itself.
3695 #[tokio::test]
3696 async fn dream_run_aggregates_stats() {
3697 let state = test_state();
3698 let palace = trusty_common::memory_core::Palace {
3699 id: PalaceId::new("dream-run-test"),
3700 name: "dream-run-test".to_string(),
3701 description: None,
3702 created_at: chrono::Utc::now(),
3703 data_dir: state.data_root.join("dream-run-test"),
3704 };
3705 state
3706 .registry
3707 .create_palace(&state.data_root, palace)
3708 .expect("create palace");
3709
3710 let app = router().with_state(state);
3711 let resp = app
3712 .oneshot(
3713 Request::builder()
3714 .method("POST")
3715 .uri("/api/v1/dream/run")
3716 .body(Body::empty())
3717 .unwrap(),
3718 )
3719 .await
3720 .unwrap();
3721 assert_eq!(resp.status(), StatusCode::OK);
3722 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3723 let v: Value = serde_json::from_slice(&bytes).unwrap();
3724
3725 // Shape: every aggregated counter must be present (even if zero) and
3726 // `last_run_at` is set by the handler to "now".
3727 for key in [
3728 "merged",
3729 "pruned",
3730 "compacted",
3731 "closets_updated",
3732 "duration_ms",
3733 ] {
3734 assert!(
3735 v.get(key).is_some(),
3736 "missing key {key} in dream_run payload: {v}"
3737 );
3738 assert!(
3739 v[key].is_u64() || v[key].is_i64(),
3740 "{key} should be integer, got {}",
3741 v[key]
3742 );
3743 }
3744 assert!(
3745 v["last_run_at"].is_string(),
3746 "last_run_at must be set by dream_run; got {v}"
3747 );
3748 }
3749
3750 /// Why: Issue #53 — when the dream cycle has not yet run for a palace,
3751 /// `/api/v1/kg/gaps` must return an empty array (200 OK), not 404 or
3752 /// 500. The cache miss is a meaningful, non-error state.
3753 /// What: Creates a palace, queries `/api/v1/kg/gaps?palace=...`, asserts
3754 /// the response is `200` with body `[]`.
3755 /// Test: this test itself.
3756 #[tokio::test]
3757 async fn kg_gaps_endpoint_returns_empty_when_uncached() {
3758 let state = test_state();
3759 let palace = trusty_common::memory_core::Palace {
3760 id: PalaceId::new("gaps-empty"),
3761 name: "gaps-empty".to_string(),
3762 description: None,
3763 created_at: chrono::Utc::now(),
3764 data_dir: state.data_root.join("gaps-empty"),
3765 };
3766 state
3767 .registry
3768 .create_palace(&state.data_root, palace)
3769 .expect("create palace");
3770
3771 let app = router().with_state(state);
3772 let resp = app
3773 .oneshot(
3774 Request::builder()
3775 .uri("/api/v1/kg/gaps?palace=gaps-empty")
3776 .body(Body::empty())
3777 .unwrap(),
3778 )
3779 .await
3780 .unwrap();
3781 assert_eq!(resp.status(), StatusCode::OK);
3782 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3783 let v: Value = serde_json::from_slice(&bytes).unwrap();
3784 assert_eq!(v.as_array().expect("array").len(), 0);
3785 }
3786
3787 /// Why: Issue #53 — when the cache *has* been populated (by the dream
3788 /// cycle in production, or by direct seeding here), the endpoint must
3789 /// return each gap with the four wire fields.
3790 /// What: Seeds the registry cache via `set_gaps` directly, then GETs
3791 /// `/api/v1/kg/gaps?palace=...` and asserts the JSON shape.
3792 /// Test: this test itself.
3793 #[tokio::test]
3794 async fn kg_gaps_endpoint_returns_cached_gaps() {
3795 use trusty_common::memory_core::community::KnowledgeGap;
3796
3797 let state = test_state();
3798 let palace = trusty_common::memory_core::Palace {
3799 id: PalaceId::new("gaps-seed"),
3800 name: "gaps-seed".to_string(),
3801 description: None,
3802 created_at: chrono::Utc::now(),
3803 data_dir: state.data_root.join("gaps-seed"),
3804 };
3805 state
3806 .registry
3807 .create_palace(&state.data_root, palace)
3808 .expect("create palace");
3809
3810 state.registry.set_gaps(
3811 PalaceId::new("gaps-seed"),
3812 vec![KnowledgeGap {
3813 entities: vec!["foo".to_string(), "bar".to_string(), "baz".to_string()],
3814 internal_density: 0.15,
3815 external_bridges: 2,
3816 suggested_exploration: "Explore connections between foo and related concepts"
3817 .to_string(),
3818 }],
3819 );
3820
3821 let app = router().with_state(state);
3822 let resp = app
3823 .oneshot(
3824 Request::builder()
3825 .uri("/api/v1/kg/gaps?palace=gaps-seed")
3826 .body(Body::empty())
3827 .unwrap(),
3828 )
3829 .await
3830 .unwrap();
3831 assert_eq!(resp.status(), StatusCode::OK);
3832 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3833 let v: Value = serde_json::from_slice(&bytes).unwrap();
3834 let arr = v.as_array().expect("array");
3835 assert_eq!(arr.len(), 1);
3836 assert_eq!(arr[0]["entities"].as_array().unwrap().len(), 3);
3837 assert_eq!(arr[0]["external_bridges"], 2);
3838 assert!(arr[0]["suggested_exploration"]
3839 .as_str()
3840 .unwrap()
3841 .contains("foo"));
3842 }
3843
3844 /// Why: The KG Explorer UI calls `/api/v1/palaces/{id}/kg/subjects` to
3845 /// populate the left panel; the endpoint must return distinct active
3846 /// subjects as a JSON string array.
3847 /// What: Creates a palace, asserts two triples via the existing kg endpoint,
3848 /// then GETs the subjects route and asserts the shape.
3849 /// Test: this test itself.
3850 #[tokio::test]
3851 async fn kg_list_subjects_returns_distinct() {
3852 let state = test_state();
3853 let app = router().with_state(state.clone());
3854
3855 // Create palace.
3856 let resp = app
3857 .clone()
3858 .oneshot(
3859 Request::builder()
3860 .method("POST")
3861 .uri("/api/v1/palaces")
3862 .header("content-type", "application/json")
3863 .body(Body::from(json!({"name": "kg-list"}).to_string()))
3864 .unwrap(),
3865 )
3866 .await
3867 .unwrap();
3868 assert_eq!(resp.status(), StatusCode::OK);
3869
3870 // Assert two triples on distinct subjects.
3871 for subj in ["alpha", "beta"] {
3872 let body = json!({
3873 "subject": subj,
3874 "predicate": "is",
3875 "object": "thing",
3876 })
3877 .to_string();
3878 let r = app
3879 .clone()
3880 .oneshot(
3881 Request::builder()
3882 .method("POST")
3883 .uri("/api/v1/palaces/kg-list/kg")
3884 .header("content-type", "application/json")
3885 .body(Body::from(body))
3886 .unwrap(),
3887 )
3888 .await
3889 .unwrap();
3890 assert_eq!(r.status(), StatusCode::NO_CONTENT);
3891 }
3892
3893 let resp = app
3894 .oneshot(
3895 Request::builder()
3896 .uri("/api/v1/palaces/kg-list/kg/subjects?limit=10")
3897 .body(Body::empty())
3898 .unwrap(),
3899 )
3900 .await
3901 .unwrap();
3902 assert_eq!(resp.status(), StatusCode::OK);
3903 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3904 let v: Value = serde_json::from_slice(&bytes).unwrap();
3905 let arr = v.as_array().expect("subjects must be array");
3906 let subjects: Vec<String> = arr
3907 .iter()
3908 .filter_map(|x| x.as_str().map(String::from))
3909 .collect();
3910 assert_eq!(subjects, vec!["alpha".to_string(), "beta".to_string()]);
3911 }
3912
3913 /// Why: KG Explorer's "All" mode pages through every active triple via
3914 /// `/api/v1/palaces/{id}/kg/all`; the endpoint must return a JSON array of
3915 /// `Triple` rows ordered by `valid_from` DESC.
3916 /// What: Creates a palace, asserts a triple, then GETs the all route and
3917 /// asserts the response is an array with the expected shape.
3918 /// Test: this test itself.
3919 #[tokio::test]
3920 async fn kg_list_all_returns_paginated_triples() {
3921 let state = test_state();
3922 let app = router().with_state(state.clone());
3923
3924 let resp = app
3925 .clone()
3926 .oneshot(
3927 Request::builder()
3928 .method("POST")
3929 .uri("/api/v1/palaces")
3930 .header("content-type", "application/json")
3931 .body(Body::from(json!({"name": "kg-all"}).to_string()))
3932 .unwrap(),
3933 )
3934 .await
3935 .unwrap();
3936 assert_eq!(resp.status(), StatusCode::OK);
3937
3938 let body = json!({
3939 "subject": "alpha",
3940 "predicate": "is",
3941 "object": "thing",
3942 })
3943 .to_string();
3944 let r = app
3945 .clone()
3946 .oneshot(
3947 Request::builder()
3948 .method("POST")
3949 .uri("/api/v1/palaces/kg-all/kg")
3950 .header("content-type", "application/json")
3951 .body(Body::from(body))
3952 .unwrap(),
3953 )
3954 .await
3955 .unwrap();
3956 assert_eq!(r.status(), StatusCode::NO_CONTENT);
3957
3958 let resp = app
3959 .oneshot(
3960 Request::builder()
3961 .uri("/api/v1/palaces/kg-all/kg/all?limit=10&offset=0")
3962 .body(Body::empty())
3963 .unwrap(),
3964 )
3965 .await
3966 .unwrap();
3967 assert_eq!(resp.status(), StatusCode::OK);
3968 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
3969 let v: Value = serde_json::from_slice(&bytes).unwrap();
3970 let arr = v.as_array().expect("triples must be array");
3971 assert_eq!(arr.len(), 1);
3972 assert_eq!(arr[0]["subject"], "alpha");
3973 assert_eq!(arr[0]["predicate"], "is");
3974 assert_eq!(arr[0]["object"], "thing");
3975 }
3976
3977 /// Why (issue #97): The visual graph view fetches the entire active
3978 /// triple set in one call so d3-force can lay it out without paging.
3979 /// The endpoint must return the triple list plus the node/edge/
3980 /// community counts that drive the legend.
3981 /// What: Creates a palace, asserts a single triple, and confirms `GET
3982 /// /api/v1/palaces/{id}/kg/graph` returns `{ triples, node_count,
3983 /// edge_count, community_count }` with the right shape.
3984 /// Test: This test.
3985 #[tokio::test]
3986 async fn kg_graph_returns_active_triples() {
3987 let state = test_state();
3988 let app = router().with_state(state.clone());
3989
3990 let resp = app
3991 .clone()
3992 .oneshot(
3993 Request::builder()
3994 .method("POST")
3995 .uri("/api/v1/palaces")
3996 .header("content-type", "application/json")
3997 .body(Body::from(json!({"name": "kg-graph"}).to_string()))
3998 .unwrap(),
3999 )
4000 .await
4001 .unwrap();
4002 assert_eq!(resp.status(), StatusCode::OK);
4003
4004 let body = json!({
4005 "subject": "alpha",
4006 "predicate": "is",
4007 "object": "thing",
4008 })
4009 .to_string();
4010 let r = app
4011 .clone()
4012 .oneshot(
4013 Request::builder()
4014 .method("POST")
4015 .uri("/api/v1/palaces/kg-graph/kg")
4016 .header("content-type", "application/json")
4017 .body(Body::from(body))
4018 .unwrap(),
4019 )
4020 .await
4021 .unwrap();
4022 assert_eq!(r.status(), StatusCode::NO_CONTENT);
4023
4024 let resp = app
4025 .oneshot(
4026 Request::builder()
4027 .uri("/api/v1/palaces/kg-graph/kg/graph")
4028 .body(Body::empty())
4029 .unwrap(),
4030 )
4031 .await
4032 .unwrap();
4033 assert_eq!(resp.status(), StatusCode::OK);
4034 let bytes = to_bytes(resp.into_body(), 16_384).await.unwrap();
4035 let v: Value = serde_json::from_slice(&bytes).unwrap();
4036 let triples = v["triples"].as_array().expect("triples array");
4037 assert!(triples
4038 .iter()
4039 .any(|t| t["subject"] == "alpha" && t["predicate"] == "is" && t["object"] == "thing"));
4040 assert!(v["node_count"].as_u64().is_some());
4041 assert!(v["edge_count"].as_u64().is_some());
4042 assert!(v["community_count"].as_u64().is_some());
4043 }
4044
4045 /// Why (issue #97): The visual graph view's stated perf budget is
4046 /// "<1s for palaces with <500 triples". Seed 500 triples, time one
4047 /// `/kg/graph` round-trip, and assert the result stays well under that
4048 /// budget. The assertion uses a generous 10x ceiling so flaky CI
4049 /// hardware doesn't false-positive while still catching catastrophic
4050 /// regressions.
4051 /// What: Creates a palace, asserts 500 triples directly through the
4052 /// `KnowledgeGraph` handle (skipping the HTTP overhead of 500 separate
4053 /// `POST /kg` calls), then runs one `GET /kg/graph` and prints the
4054 /// elapsed time to stderr.
4055 /// Test: This test.
4056 #[tokio::test]
4057 async fn kg_graph_meets_perf_budget_for_500_triples() {
4058 let state = test_state();
4059 let app = router().with_state(state.clone());
4060
4061 let resp = app
4062 .clone()
4063 .oneshot(
4064 Request::builder()
4065 .method("POST")
4066 .uri("/api/v1/palaces")
4067 .header("content-type", "application/json")
4068 .body(Body::from(json!({"name": "kg-perf"}).to_string()))
4069 .unwrap(),
4070 )
4071 .await
4072 .unwrap();
4073 assert_eq!(resp.status(), StatusCode::OK);
4074
4075 let pid = trusty_common::memory_core::palace::PalaceId::new("kg-perf");
4076 let handle = state
4077 .registry
4078 .open_palace(&state.data_root, &pid)
4079 .expect("open palace");
4080 let now = chrono::Utc::now();
4081 for s in 0..10 {
4082 for o in 0..50 {
4083 handle
4084 .kg
4085 .assert(Triple {
4086 subject: format!("s{s}"),
4087 predicate: format!("p{o}"),
4088 object: format!("o{o}"),
4089 valid_from: now,
4090 valid_to: None,
4091 confidence: 1.0,
4092 provenance: Some("perf-test".to_string()),
4093 })
4094 .await
4095 .expect("kg.assert");
4096 }
4097 }
4098
4099 let started = std::time::Instant::now();
4100 let resp = app
4101 .oneshot(
4102 Request::builder()
4103 .uri("/api/v1/palaces/kg-perf/kg/graph")
4104 .body(Body::empty())
4105 .unwrap(),
4106 )
4107 .await
4108 .unwrap();
4109 let elapsed = started.elapsed();
4110 assert_eq!(resp.status(), StatusCode::OK);
4111 let bytes = to_bytes(resp.into_body(), 1_000_000).await.unwrap();
4112 let v: Value = serde_json::from_slice(&bytes).unwrap();
4113 let n = v["triples"].as_array().map(|a| a.len()).unwrap_or(0);
4114 assert_eq!(n, 500, "expected 500 triples in payload");
4115 assert!(
4116 elapsed.as_secs_f64() < 10.0,
4117 "graph endpoint should serve 500 triples in well under 10s; took {elapsed:?}"
4118 );
4119 eprintln!(
4120 "[perf] kg_graph endpoint served 500 triples in {:.3}ms",
4121 elapsed.as_secs_f64() * 1000.0
4122 );
4123 }
4124
4125 /// Why (issue #42): `GET /api/v1/kg/prompt-context` must serve the
4126 /// formatted Markdown block from the in-memory cache (or a placeholder
4127 /// when empty). Mirrors the MCP `get_prompt_context` tool but over HTTP.
4128 #[tokio::test]
4129 async fn prompt_context_endpoint_returns_formatted_block() {
4130 let state = test_state();
4131
4132 // Empty cache returns the placeholder text.
4133 let app = router().with_state(state.clone());
4134 let resp = app
4135 .oneshot(
4136 Request::builder()
4137 .uri("/api/v1/kg/prompt-context")
4138 .body(Body::empty())
4139 .unwrap(),
4140 )
4141 .await
4142 .unwrap();
4143 assert_eq!(resp.status(), StatusCode::OK);
4144 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4145 let text = String::from_utf8(bytes.to_vec()).unwrap();
4146 assert_eq!(text, "No prompt facts stored yet.");
4147
4148 // Populate the cache and re-fetch.
4149 {
4150 let mut guard = state.prompt_context_cache.write().await;
4151 let triples = vec![(
4152 "tga".to_string(),
4153 "is_alias_for".to_string(),
4154 "trusty-git-analytics".to_string(),
4155 )];
4156 let formatted = crate::prompt_facts::build_prompt_context(&triples);
4157 *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
4158 }
4159 let app = router().with_state(state);
4160 let resp = app
4161 .oneshot(
4162 Request::builder()
4163 .uri("/api/v1/kg/prompt-context")
4164 .body(Body::empty())
4165 .unwrap(),
4166 )
4167 .await
4168 .unwrap();
4169 assert_eq!(resp.status(), StatusCode::OK);
4170 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4171 let text = String::from_utf8(bytes.to_vec()).unwrap();
4172 assert!(text.contains("tga → trusty-git-analytics"), "got: {text}");
4173 }
4174
4175 /// Why (issue #42): `POST /api/v1/kg/aliases` must assert the alias as
4176 /// an `is_alias_for` triple AND refresh the prompt cache so subsequent
4177 /// reads see the new alias.
4178 #[tokio::test]
4179 async fn add_alias_endpoint_asserts_triple_and_refreshes_cache() {
4180 let tmp = tempfile::tempdir().expect("tempdir");
4181 let root = tmp.path().to_path_buf();
4182 std::mem::forget(tmp);
4183 let state = AppState::new(root).with_default_palace(Some("aliases".to_string()));
4184 let palace = trusty_common::memory_core::Palace {
4185 id: PalaceId::new("aliases"),
4186 name: "aliases".to_string(),
4187 description: None,
4188 created_at: chrono::Utc::now(),
4189 data_dir: state.data_root.join("aliases"),
4190 };
4191 state
4192 .registry
4193 .create_palace(&state.data_root, palace)
4194 .expect("create palace");
4195
4196 let body = json!({"short": "tm", "full": "trusty-memory"});
4197 let app = router().with_state(state.clone());
4198 let resp = app
4199 .oneshot(
4200 Request::builder()
4201 .method("POST")
4202 .uri("/api/v1/kg/aliases")
4203 .header("content-type", "application/json")
4204 .body(Body::from(serde_json::to_vec(&body).unwrap()))
4205 .unwrap(),
4206 )
4207 .await
4208 .unwrap();
4209 assert_eq!(resp.status(), StatusCode::OK);
4210 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4211 let v: Value = serde_json::from_slice(&bytes).unwrap();
4212 assert_eq!(v["subject"], "tm");
4213 assert_eq!(v["object"], "trusty-memory");
4214
4215 // The prompt cache must reflect the new alias.
4216 let guard = state.prompt_context_cache.read().await;
4217 assert!(
4218 guard.formatted.contains("tm → trusty-memory"),
4219 "cache missing alias; got: {}",
4220 guard.formatted
4221 );
4222 }
4223
4224 /// Why (issue #42): `GET /api/v1/kg/prompt-facts` returns the structured
4225 /// JSON array of every hot-predicate triple across the registry (so a
4226 /// dashboard can render its own table).
4227 #[tokio::test]
4228 async fn list_prompt_facts_endpoint_returns_hot_triples() {
4229 let tmp = tempfile::tempdir().expect("tempdir");
4230 let root = tmp.path().to_path_buf();
4231 std::mem::forget(tmp);
4232 let state = AppState::new(root).with_default_palace(Some("listfacts".to_string()));
4233 let palace = trusty_common::memory_core::Palace {
4234 id: PalaceId::new("listfacts"),
4235 name: "listfacts".to_string(),
4236 description: None,
4237 created_at: chrono::Utc::now(),
4238 data_dir: state.data_root.join("listfacts"),
4239 };
4240 let handle = state
4241 .registry
4242 .create_palace(&state.data_root, palace)
4243 .expect("create palace");
4244
4245 // Insert one hot triple and one non-hot triple; only the hot one
4246 // should surface.
4247 handle
4248 .kg
4249 .assert(Triple {
4250 subject: "ts".to_string(),
4251 predicate: "is_alias_for".to_string(),
4252 object: "trusty-search".to_string(),
4253 valid_from: chrono::Utc::now(),
4254 valid_to: None,
4255 confidence: 1.0,
4256 provenance: None,
4257 })
4258 .await
4259 .expect("assert alias");
4260 handle
4261 .kg
4262 .assert(Triple {
4263 subject: "alice".to_string(),
4264 predicate: "works_at".to_string(),
4265 object: "Acme".to_string(),
4266 valid_from: chrono::Utc::now(),
4267 valid_to: None,
4268 confidence: 1.0,
4269 provenance: None,
4270 })
4271 .await
4272 .expect("assert works_at");
4273
4274 let app = router().with_state(state);
4275 let resp = app
4276 .oneshot(
4277 Request::builder()
4278 .uri("/api/v1/kg/prompt-facts")
4279 .body(Body::empty())
4280 .unwrap(),
4281 )
4282 .await
4283 .unwrap();
4284 assert_eq!(resp.status(), StatusCode::OK);
4285 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4286 let v: Value = serde_json::from_slice(&bytes).unwrap();
4287 let arr = v.as_array().expect("array");
4288 assert!(
4289 arr.iter().any(|r| r["subject"] == "ts"
4290 && r["predicate"] == "is_alias_for"
4291 && r["object"] == "trusty-search"),
4292 "missing ts alias; got {arr:?}"
4293 );
4294 // The non-hot `works_at` triple must not be present.
4295 assert!(
4296 !arr.iter().any(|r| r["predicate"] == "works_at"),
4297 "non-hot triple leaked into prompt facts: {arr:?}"
4298 );
4299 }
4300
4301 /// Why (issue #42): `DELETE /api/v1/kg/prompt-facts` must retract the
4302 /// interval and refresh the cache; the next list call must omit it.
4303 #[tokio::test]
4304 async fn remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache() {
4305 let tmp = tempfile::tempdir().expect("tempdir");
4306 let root = tmp.path().to_path_buf();
4307 std::mem::forget(tmp);
4308 let state = AppState::new(root).with_default_palace(Some("rmfacts".to_string()));
4309 let palace = trusty_common::memory_core::Palace {
4310 id: PalaceId::new("rmfacts"),
4311 name: "rmfacts".to_string(),
4312 description: None,
4313 created_at: chrono::Utc::now(),
4314 data_dir: state.data_root.join("rmfacts"),
4315 };
4316 let handle = state
4317 .registry
4318 .create_palace(&state.data_root, palace)
4319 .expect("create palace");
4320
4321 handle
4322 .kg
4323 .assert(Triple {
4324 subject: "ta".to_string(),
4325 predicate: "is_alias_for".to_string(),
4326 object: "trusty-analyze".to_string(),
4327 valid_from: chrono::Utc::now(),
4328 valid_to: None,
4329 confidence: 1.0,
4330 provenance: None,
4331 })
4332 .await
4333 .expect("assert alias");
4334 // Prime the cache so we can observe the removal effect.
4335 crate::prompt_facts::rebuild_prompt_cache(&state)
4336 .await
4337 .expect("rebuild prompt cache");
4338
4339 let app = router().with_state(state.clone());
4340 let resp = app
4341 .oneshot(
4342 Request::builder()
4343 .method("DELETE")
4344 .uri("/api/v1/kg/prompt-facts?subject=ta&predicate=is_alias_for")
4345 .body(Body::empty())
4346 .unwrap(),
4347 )
4348 .await
4349 .unwrap();
4350 assert_eq!(resp.status(), StatusCode::OK);
4351 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4352 let v: Value = serde_json::from_slice(&bytes).unwrap();
4353 assert_eq!(v["removed"], true);
4354 assert!(v["closed"].as_u64().unwrap_or(0) >= 1);
4355
4356 // Cache must no longer contain the alias.
4357 {
4358 let guard = state.prompt_context_cache.read().await;
4359 assert!(
4360 !guard.formatted.contains("ta → trusty-analyze"),
4361 "alias still in cache after delete: {}",
4362 guard.formatted
4363 );
4364 }
4365
4366 // Removing a non-existent fact returns removed=false.
4367 let app = router().with_state(state);
4368 let resp = app
4369 .oneshot(
4370 Request::builder()
4371 .method("DELETE")
4372 .uri("/api/v1/kg/prompt-facts?subject=nope&predicate=is_alias_for")
4373 .body(Body::empty())
4374 .unwrap(),
4375 )
4376 .await
4377 .unwrap();
4378 assert_eq!(resp.status(), StatusCode::OK);
4379 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4380 let v: Value = serde_json::from_slice(&bytes).unwrap();
4381 assert_eq!(v["removed"], false);
4382 }
4383
4384 #[tokio::test]
4385 async fn serves_index_html_fallback() {
4386 let state = test_state();
4387 let app = router().with_state(state);
4388 let resp = app
4389 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4390 .await
4391 .unwrap();
4392 // Either OK with embedded HTML, or NOT_FOUND if assets not built.
4393 assert!(
4394 resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
4395 "got {}",
4396 resp.status()
4397 );
4398 }
4399
4400 /// Why (issue #96): `GET /api/v1/activity` must return the entries
4401 /// captured by the persistent log so the dashboard feed has history on
4402 /// page load. This drives the endpoint with a sequence of emits that
4403 /// model both HTTP- and MCP-origin writes, then asserts the response
4404 /// shape, ordering, total count, and that the source labels make it
4405 /// onto the wire.
4406 /// What: emits four `DaemonEvent`s with mixed sources, fetches
4407 /// `/api/v1/activity?limit=10`, and checks the structure of the
4408 /// returned JSON.
4409 /// Test: this test.
4410 #[tokio::test]
4411 async fn activity_endpoint_lists_recent_emits() {
4412 let state = test_state();
4413 // Three drawer_added (one MCP, two HTTP) and one palace_created.
4414 state.emit(DaemonEvent::PalaceCreated {
4415 id: "alpha".into(),
4416 name: "alpha".into(),
4417 source: ActivitySource::Http,
4418 });
4419 state.emit(DaemonEvent::DrawerAdded {
4420 palace_id: "alpha".into(),
4421 palace_name: "alpha".into(),
4422 drawer_count: 1,
4423 timestamp: chrono::Utc::now(),
4424 content_preview: "hello".into(),
4425 source: ActivitySource::Mcp,
4426 });
4427 state.emit(DaemonEvent::DrawerAdded {
4428 palace_id: "beta".into(),
4429 palace_name: "beta".into(),
4430 drawer_count: 1,
4431 timestamp: chrono::Utc::now(),
4432 content_preview: "hi there".into(),
4433 source: ActivitySource::Http,
4434 });
4435 state.emit(DaemonEvent::DrawerDeleted {
4436 palace_id: "alpha".into(),
4437 drawer_count: 0,
4438 source: ActivitySource::Http,
4439 });
4440 // Issue #232: emits now fire-and-forget the redb write on the
4441 // blocking pool; wait for the writes to settle before querying the
4442 // activity endpoint.
4443 state.flush_activity_writes().await;
4444
4445 let app = router().with_state(state);
4446 let resp = app
4447 .oneshot(
4448 Request::builder()
4449 .uri("/api/v1/activity?limit=10")
4450 .body(Body::empty())
4451 .unwrap(),
4452 )
4453 .await
4454 .unwrap();
4455 assert_eq!(resp.status(), StatusCode::OK);
4456 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4457 let v: Value = serde_json::from_slice(&bytes).unwrap();
4458 assert_eq!(v["limit"], 10);
4459 assert_eq!(v["offset"], 0);
4460 assert_eq!(v["total"], 4);
4461 let entries = v["entries"].as_array().expect("entries array");
4462 assert_eq!(entries.len(), 4);
4463 // Newest-first: drawer_deleted is the last event we pushed.
4464 assert_eq!(entries[0]["event_type"], "drawer_deleted");
4465 assert_eq!(entries[3]["event_type"], "palace_created");
4466 // Sources made it onto the wire as lowercase strings.
4467 let sources: Vec<&str> = entries
4468 .iter()
4469 .filter_map(|e| e["source"].as_str())
4470 .collect();
4471 assert!(sources.contains(&"http"));
4472 assert!(sources.contains(&"mcp"));
4473 // Payload is structured JSON, not an escaped string.
4474 assert!(entries[0]["payload"].is_object());
4475 }
4476
4477 /// Why: the handler must enforce a sane upper bound on `limit` so a
4478 /// curl with `?limit=1000000` cannot force a huge scan + response.
4479 /// What: asks for `limit=10000`, asserts the response advertises the
4480 /// clamped value.
4481 /// Test: this test.
4482 #[tokio::test]
4483 async fn activity_endpoint_clamps_limit() {
4484 let state = test_state();
4485 let app = router().with_state(state);
4486 let resp = app
4487 .oneshot(
4488 Request::builder()
4489 .uri("/api/v1/activity?limit=10000")
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 assert_eq!(v["limit"], ACTIVITY_MAX_LIMIT);
4499 }
4500
4501 /// Why: filters are how the dashboard scopes the feed to a single
4502 /// palace or to one origin (MCP vs HTTP). Confirm AND-semantics on
4503 /// `?palace=` and `?source=`.
4504 /// What: emits 3 events, queries with `?palace=alpha&source=mcp`, and
4505 /// asserts only the matching row is returned.
4506 /// Test: this test.
4507 #[tokio::test]
4508 async fn activity_endpoint_filters_by_source_and_palace() {
4509 let state = test_state();
4510 state.emit(DaemonEvent::DrawerAdded {
4511 palace_id: "alpha".into(),
4512 palace_name: "alpha".into(),
4513 drawer_count: 1,
4514 timestamp: chrono::Utc::now(),
4515 content_preview: "".into(),
4516 source: ActivitySource::Mcp,
4517 });
4518 state.emit(DaemonEvent::DrawerAdded {
4519 palace_id: "alpha".into(),
4520 palace_name: "alpha".into(),
4521 drawer_count: 2,
4522 timestamp: chrono::Utc::now(),
4523 content_preview: "".into(),
4524 source: ActivitySource::Http,
4525 });
4526 state.emit(DaemonEvent::DrawerAdded {
4527 palace_id: "beta".into(),
4528 palace_name: "beta".into(),
4529 drawer_count: 1,
4530 timestamp: chrono::Utc::now(),
4531 content_preview: "".into(),
4532 source: ActivitySource::Mcp,
4533 });
4534 // Issue #232: drain the spawn_blocking writes before querying.
4535 state.flush_activity_writes().await;
4536
4537 let app = router().with_state(state);
4538 let resp = app
4539 .oneshot(
4540 Request::builder()
4541 .uri("/api/v1/activity?palace=alpha&source=mcp&limit=50")
4542 .body(Body::empty())
4543 .unwrap(),
4544 )
4545 .await
4546 .unwrap();
4547 assert_eq!(resp.status(), StatusCode::OK);
4548 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4549 let v: Value = serde_json::from_slice(&bytes).unwrap();
4550 let entries = v["entries"].as_array().unwrap();
4551 assert_eq!(entries.len(), 1, "filter should leave one row, got {v}");
4552 assert_eq!(entries[0]["palace_id"], "alpha");
4553 assert_eq!(entries[0]["source"], "mcp");
4554 }
4555
4556 /// Why: unknown source values must produce a 400 so the caller sees the
4557 /// typo instead of silently getting "no rows".
4558 #[tokio::test]
4559 async fn activity_endpoint_rejects_unknown_source() {
4560 let state = test_state();
4561 let app = router().with_state(state);
4562 let resp = app
4563 .oneshot(
4564 Request::builder()
4565 .uri("/api/v1/activity?source=nope")
4566 .body(Body::empty())
4567 .unwrap(),
4568 )
4569 .await
4570 .unwrap();
4571 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
4572 }
4573
4574 /// Why (issue #96): MCP-side `memory_remember` must now emit a
4575 /// `DrawerAdded` event with `source = Mcp`. Confirm by driving the MCP
4576 /// dispatcher directly and reading the broadcast channel.
4577 /// What: pre-creates a palace, calls `dispatch_tool("memory_remember",
4578 /// ...)`, subscribes to the events channel before the call, and
4579 /// asserts the next event tag is `drawer_added` with the MCP source.
4580 /// Test: this test.
4581 #[tokio::test]
4582 async fn mcp_memory_remember_emits_drawer_added_with_mcp_source() {
4583 use crate::tools::dispatch_tool;
4584 let state = test_state();
4585 let mut rx = state.events.subscribe();
4586 // Create palace via the MCP tool so the activity log captures both
4587 // the palace_created and drawer_added events.
4588 let _ = dispatch_tool(&state, "palace_create", json!({"name": "p1"}))
4589 .await
4590 .expect("palace_create");
4591 // Drain the palace_created event.
4592 let first = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
4593 .await
4594 .expect("first event")
4595 .expect("channel open");
4596 assert!(
4597 matches!(first, DaemonEvent::PalaceCreated { ref source, .. } if *source == ActivitySource::Mcp)
4598 );
4599
4600 let _ = dispatch_tool(
4601 &state,
4602 "memory_remember",
4603 json!({
4604 "palace": "p1",
4605 "text": "the quick brown fox jumps over the lazy dog and more"
4606 }),
4607 )
4608 .await
4609 .expect("memory_remember");
4610
4611 // The next event from the channel should be DrawerAdded(Mcp).
4612 let next = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
4613 .await
4614 .expect("drawer_added event")
4615 .expect("channel open");
4616 match next {
4617 DaemonEvent::DrawerAdded {
4618 source, palace_id, ..
4619 } => {
4620 assert_eq!(source, ActivitySource::Mcp);
4621 assert_eq!(palace_id, "p1");
4622 }
4623 other => panic!("expected DrawerAdded, got {other:?}"),
4624 }
4625
4626 // The activity log should now hold ≥ 2 entries (palace_created +
4627 // drawer_added). Also confirm the HTTP endpoint surfaces them with
4628 // `mcp` sources.
4629 // Issue #232: drain fire-and-forget activity-log writes first.
4630 state.flush_activity_writes().await;
4631 let app = router().with_state(state);
4632 let resp = app
4633 .oneshot(
4634 Request::builder()
4635 .uri("/api/v1/activity?source=mcp&limit=10")
4636 .body(Body::empty())
4637 .unwrap(),
4638 )
4639 .await
4640 .unwrap();
4641 assert_eq!(resp.status(), StatusCode::OK);
4642 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4643 let v: Value = serde_json::from_slice(&bytes).unwrap();
4644 let entries = v["entries"].as_array().unwrap();
4645 let event_types: std::collections::HashSet<&str> = entries
4646 .iter()
4647 .filter_map(|e| e["event_type"].as_str())
4648 .collect();
4649 assert!(event_types.contains("drawer_added"));
4650 assert!(event_types.contains("palace_created"));
4651 }
4652
4653 // -----------------------------------------------------------------
4654 // Submission-logging tests (Part A: hook activity, Part B: drawer
4655 // attribution).
4656 // -----------------------------------------------------------------
4657
4658 /// Why (submission-logging Part A): every hook firing must produce an
4659 /// activity-feed entry tagged `source=hook` so a normal Claude Code
4660 /// session that only triggers hooks no longer leaves the TUI feed
4661 /// empty. The simplest direct check is to POST to the hook ingestion
4662 /// endpoint and confirm the new entry shows up in `GET /api/v1/activity`.
4663 /// What: posts a `HookEventPayload` to `/api/v1/activity/hook`, then
4664 /// queries `/api/v1/activity?source=hook&limit=1` and asserts a row
4665 /// exists with the matching event_type and source.
4666 /// Test: itself.
4667 #[tokio::test]
4668 async fn hook_fired_activity_emit_smoke() {
4669 let state = test_state();
4670 let app = router().with_state(state.clone());
4671
4672 let payload = serde_json::json!({
4673 "palace_id": "alpha",
4674 "palace_name": "alpha",
4675 "hook_type": "UserPromptSubmit",
4676 "injection_kind": "prompt-context",
4677 "injection_length": 256,
4678 "trigger_prompt_excerpt": "test prompt",
4679 "duration_ms": 12,
4680 });
4681 let resp = app
4682 .oneshot(
4683 Request::builder()
4684 .method("POST")
4685 .uri("/api/v1/activity/hook")
4686 .header("content-type", "application/json")
4687 .body(Body::from(payload.to_string()))
4688 .unwrap(),
4689 )
4690 .await
4691 .unwrap();
4692 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
4693 // Issue #232: the hook handler emits via the fire-and-forget
4694 // `spawn_blocking` path; wait for the write to settle before
4695 // reading the activity history endpoint.
4696 state.flush_activity_writes().await;
4697
4698 // Read it back through the activity history endpoint.
4699 let app = router().with_state(state);
4700 let resp = app
4701 .oneshot(
4702 Request::builder()
4703 .uri("/api/v1/activity?source=hook&limit=10")
4704 .body(Body::empty())
4705 .unwrap(),
4706 )
4707 .await
4708 .unwrap();
4709 assert_eq!(resp.status(), StatusCode::OK);
4710 let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
4711 let v: Value = serde_json::from_slice(&bytes).unwrap();
4712 let entries = v["entries"].as_array().expect("entries array");
4713 assert!(
4714 !entries.is_empty(),
4715 "expected at least one hook activity row, got {entries:?}"
4716 );
4717 let first = &entries[0];
4718 assert_eq!(first["source"], "hook");
4719 assert_eq!(first["event_type"], "hook_fired");
4720 assert_eq!(first["palace_id"], "alpha");
4721 let body = &first["payload"];
4722 assert_eq!(body["hook_type"], "UserPromptSubmit");
4723 assert_eq!(body["injection_kind"], "prompt-context");
4724 }
4725
4726 /// Why (submission-logging Part B): an HTTP drawer write with no
4727 /// client-identifying header must still produce a drawer carrying a
4728 /// `creator:client=unknown-http-client` tag so operators can recognise
4729 /// "writer didn't self-identify" as distinct from "writer is known".
4730 /// What: creates a palace via the registry, POSTs a drawer with no
4731 /// `X-Trusty-Client-Name` header, lists the palace drawers, asserts
4732 /// the new drawer carries the four creator tags with the default
4733 /// client name and `source=http`.
4734 /// Test: itself.
4735 #[tokio::test]
4736 async fn drawer_creator_attribution_http_default() {
4737 let tmp = tempfile::tempdir().expect("tempdir");
4738 let root = tmp.path().to_path_buf();
4739 std::mem::forget(tmp);
4740 let state = AppState::new(root);
4741 let palace = trusty_common::memory_core::Palace {
4742 id: PalaceId::new("cred-default"),
4743 name: "cred-default".to_string(),
4744 description: None,
4745 created_at: chrono::Utc::now(),
4746 data_dir: state.data_root.join("cred-default"),
4747 };
4748 state
4749 .registry
4750 .create_palace(&state.data_root, palace)
4751 .expect("create palace");
4752
4753 let app = router().with_state(state.clone());
4754 let body = serde_json::json!({
4755 "content": "hello world from anonymous client",
4756 "tags": ["user-tag"],
4757 });
4758 let resp = app
4759 .oneshot(
4760 Request::builder()
4761 .method("POST")
4762 .uri("/api/v1/palaces/cred-default/drawers")
4763 .header("content-type", "application/json")
4764 .body(Body::from(body.to_string()))
4765 .unwrap(),
4766 )
4767 .await
4768 .unwrap();
4769 assert_eq!(resp.status(), StatusCode::OK);
4770
4771 // Inspect the persisted drawer's tags.
4772 let app = router().with_state(state);
4773 let resp = app
4774 .oneshot(
4775 Request::builder()
4776 .uri("/api/v1/palaces/cred-default/drawers?limit=10")
4777 .body(Body::empty())
4778 .unwrap(),
4779 )
4780 .await
4781 .unwrap();
4782 assert_eq!(resp.status(), StatusCode::OK);
4783 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4784 let v: Value = serde_json::from_slice(&bytes).unwrap();
4785 let drawers = v.as_array().expect("drawers array");
4786 assert_eq!(drawers.len(), 1, "expected one drawer, got {drawers:?}");
4787 let tags: Vec<&str> = drawers[0]["tags"]
4788 .as_array()
4789 .expect("tags array")
4790 .iter()
4791 .filter_map(|t| t.as_str())
4792 .collect();
4793 assert!(
4794 tags.contains(&"user-tag"),
4795 "user-supplied tag must survive; got {tags:?}"
4796 );
4797 assert!(
4798 tags.contains(&"creator:client=unknown-http-client"),
4799 "expected default client tag; got {tags:?}"
4800 );
4801 assert!(
4802 tags.contains(&"creator:source=http"),
4803 "expected http source tag; got {tags:?}"
4804 );
4805 assert!(
4806 tags.iter().any(|t| t.starts_with("creator:version=")),
4807 "expected creator:version tag; got {tags:?}"
4808 );
4809 }
4810
4811 /// Why (submission-logging Part B): when an HTTP client *does* set
4812 /// `X-Trusty-Client-Name`, the drawer must carry that exact name in
4813 /// its `creator:client=` tag so operators can trace which client wrote
4814 /// which drawer.
4815 /// What: POST with `X-Trusty-Client-Name: qa-curl` and assert the
4816 /// rendered tag matches.
4817 /// Test: itself.
4818 #[tokio::test]
4819 async fn drawer_creator_attribution_http_header() {
4820 let tmp = tempfile::tempdir().expect("tempdir");
4821 let root = tmp.path().to_path_buf();
4822 std::mem::forget(tmp);
4823 let state = AppState::new(root);
4824 let palace = trusty_common::memory_core::Palace {
4825 id: PalaceId::new("cred-header"),
4826 name: "cred-header".to_string(),
4827 description: None,
4828 created_at: chrono::Utc::now(),
4829 data_dir: state.data_root.join("cred-header"),
4830 };
4831 state
4832 .registry
4833 .create_palace(&state.data_root, palace)
4834 .expect("create palace");
4835
4836 let app = router().with_state(state.clone());
4837 let body = serde_json::json!({
4838 "content": "this is enough content to pass the signal/noise filter applied by remember",
4839 "tags": [],
4840 });
4841 let resp = app
4842 .oneshot(
4843 Request::builder()
4844 .method("POST")
4845 .uri("/api/v1/palaces/cred-header/drawers")
4846 .header("content-type", "application/json")
4847 .header("x-trusty-client-name", "qa-curl")
4848 .header("x-trusty-client-cwd", "/tmp/qa")
4849 .body(Body::from(body.to_string()))
4850 .unwrap(),
4851 )
4852 .await
4853 .unwrap();
4854 assert_eq!(resp.status(), StatusCode::OK);
4855
4856 let app = router().with_state(state);
4857 let resp = app
4858 .oneshot(
4859 Request::builder()
4860 .uri("/api/v1/palaces/cred-header/drawers?limit=10")
4861 .body(Body::empty())
4862 .unwrap(),
4863 )
4864 .await
4865 .unwrap();
4866 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4867 let v: Value = serde_json::from_slice(&bytes).unwrap();
4868 let tags: Vec<&str> = v[0]["tags"]
4869 .as_array()
4870 .expect("tags")
4871 .iter()
4872 .filter_map(|t| t.as_str())
4873 .collect();
4874 assert!(
4875 tags.contains(&"creator:client=qa-curl"),
4876 "expected custom client tag; got {tags:?}"
4877 );
4878 assert!(
4879 tags.contains(&"creator:cwd=/tmp/qa"),
4880 "expected cwd tag from header; got {tags:?}"
4881 );
4882 }
4883
4884 /// Why (submission-logging Part B): drawers written through the MCP
4885 /// tool surface (`memory_remember`) must carry
4886 /// `creator:client=trusty-memory-mcp` and `creator:source=mcp` so
4887 /// operators can tell MCP-origin drawers apart from HTTP / CLI writes.
4888 /// What: dispatches `memory_remember` directly against an in-process
4889 /// `AppState` (no HTTP), then lists the palace drawers and asserts
4890 /// the MCP attribution tags landed.
4891 /// Test: itself.
4892 #[tokio::test]
4893 async fn drawer_creator_attribution_mcp_default() {
4894 let tmp = tempfile::tempdir().expect("tempdir");
4895 let root = tmp.path().to_path_buf();
4896 std::mem::forget(tmp);
4897 let state = AppState::new(root);
4898 let palace = trusty_common::memory_core::Palace {
4899 id: PalaceId::new("cred-mcp"),
4900 name: "cred-mcp".to_string(),
4901 description: None,
4902 created_at: chrono::Utc::now(),
4903 data_dir: state.data_root.join("cred-mcp"),
4904 };
4905 state
4906 .registry
4907 .create_palace(&state.data_root, palace)
4908 .expect("create palace");
4909
4910 let _ = crate::tools::dispatch_tool(
4911 &state,
4912 "memory_remember",
4913 json!({
4914 "palace": "cred-mcp",
4915 "text": "remember a sentence with enough tokens to pass filters please",
4916 "room": "General",
4917 "tags": ["from-test"],
4918 }),
4919 )
4920 .await
4921 .expect("memory_remember dispatch");
4922
4923 let app = router().with_state(state);
4924 let resp = app
4925 .oneshot(
4926 Request::builder()
4927 .uri("/api/v1/palaces/cred-mcp/drawers?limit=10")
4928 .body(Body::empty())
4929 .unwrap(),
4930 )
4931 .await
4932 .unwrap();
4933 let bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
4934 let v: Value = serde_json::from_slice(&bytes).unwrap();
4935 let drawers = v.as_array().expect("drawers array");
4936 assert!(!drawers.is_empty(), "expected at least one drawer");
4937 let tags: Vec<&str> = drawers[0]["tags"]
4938 .as_array()
4939 .expect("tags array")
4940 .iter()
4941 .filter_map(|t| t.as_str())
4942 .collect();
4943 assert!(
4944 tags.contains(&"creator:client=trusty-memory-mcp"),
4945 "expected MCP client tag; got {tags:?}"
4946 );
4947 assert!(
4948 tags.contains(&"creator:source=mcp"),
4949 "expected MCP source tag; got {tags:?}"
4950 );
4951 }
4952
4953 /// Why (submission-logging Part A, failure isolation): if the daemon
4954 /// is unreachable when the hook fires, the hook command MUST still
4955 /// return `Ok(())` so the user's prompt is not blocked. The activity
4956 /// emit failure is surfaced via a stderr warn-log only.
4957 /// What: pins a tempdir as the data dir (so `read_daemon_addr`
4958 /// returns `Ok(None)` — no http_addr file), runs `handle_prompt_context`,
4959 /// and asserts it returns `Ok(())`. Separately verifies the emit
4960 /// helper does not panic — covered by `post_hook_event_no_daemon_is_noop`
4961 /// in `hook_emit::tests`.
4962 /// Test: itself.
4963 #[tokio::test]
4964 async fn hook_emit_failure_isolated() {
4965 let _guard = crate::commands::env_test_lock().lock().await;
4966 let tmp = tempfile::tempdir().expect("tempdir");
4967 // SAFETY: test serialised via env_test_lock.
4968 unsafe {
4969 std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
4970 }
4971 let res = crate::commands::prompt_context::handle_prompt_context().await;
4972 unsafe {
4973 std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
4974 }
4975 assert!(
4976 res.is_ok(),
4977 "hook must complete even when daemon emit fails; got {res:?}"
4978 );
4979 }
4980
4981 /// Why: The base64url triple-ID round-trip is the core invariant for
4982 /// `DELETE /kg/triples/<id>` — if encode/decode aren't inverses, the
4983 /// handler will always 404 on valid IDs.
4984 /// What: Encodes a (subject, predicate) pair, decodes the result, and
4985 /// asserts exact equality with the originals. Also tests the null-byte
4986 /// separator and URL-safety.
4987 /// Test: This test.
4988 #[test]
4989 fn decode_triple_id_round_trips() {
4990 let cases = [
4991 ("drawer:some-uuid", "has_tag"),
4992 ("entity:alice", "works_at"),
4993 ("entity:project/foo", "depends_on"),
4994 // edge: empty predicate
4995 ("subject", ""),
4996 // edge: subject with slashes + predicate with colons
4997 ("path/to/node", "rel:type:sub"),
4998 ];
4999 for (subject, predicate) in cases {
5000 let encoded = encode_triple_id(subject, predicate);
5001 // Must be URL-safe: no +, /, or = characters.
5002 assert!(
5003 !encoded.contains('+') && !encoded.contains('/') && !encoded.contains('='),
5004 "encoded triple id {encoded:?} is not URL-safe"
5005 );
5006 let (s, p) = decode_triple_id(&encoded)
5007 .unwrap_or_else(|| panic!("decode_triple_id failed for {encoded:?}"));
5008 assert_eq!(s, subject, "subject mismatch for ({subject}, {predicate})");
5009 assert_eq!(
5010 p, predicate,
5011 "predicate mismatch for ({subject}, {predicate})"
5012 );
5013 }
5014 }
5015
5016 /// Why: `decode_triple_id` must return `None` on garbage input (not panic).
5017 /// What: Passes invalid base64 and base64 without a null separator; asserts None.
5018 /// Test: This test.
5019 #[test]
5020 fn decode_triple_id_returns_none_for_invalid_input() {
5021 assert!(decode_triple_id("not!!valid%%base64").is_none());
5022 // Valid base64url but no null separator → no split possible.
5023 use base64::Engine as _;
5024 let no_sep = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"no-separator");
5025 assert!(decode_triple_id(&no_sep).is_none());
5026 }
5027}