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