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