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