trusty_memory/lib.rs
1//! MCP server (HTTP/SSE + stdio) for trusty-memory.
2//!
3//! Why: Claude Code and other MCP-aware clients integrate with trusty-memory
4//! through the standardized Model Context Protocol; we expose memory + KG
5//! tools so they can be called by name. The canonical stdio integration is
6//! `trusty-memory serve --stdio` (PR1 #919 of the #914 cutover epic), a
7//! self-contained direct MCP server that binds no HTTP port or UDS socket.
8//! The former `trusty-memory-mcp-bridge` binary and Unix-domain-socket
9//! transport were removed in PR3 (#914) once `serve --stdio` made them dead
10//! code.
11//! What: Provides `run_http` / `run_http_dynamic` / `run_http_on` (axum
12//! HTTP/SSE + REST + UI) plus an `AppState` that carries the shared
13//! `PalaceRegistry`, on-disk data root, and a lazily-initialized embedder.
14//! Test: `cargo test -p trusty-memory` validates handshake + dispatch via
15//! the in-process `handle_message` unit tests and the
16//! `tests/serve_stdio_e2e.rs` end-to-end harness.
17
18use anyhow::Result;
19use serde_json::{json, Value};
20use std::net::SocketAddr;
21use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
23use std::sync::{Arc, OnceLock};
24use tokio::sync::{broadcast, OnceCell, RwLock};
25use trusty_common::bm25_client::Bm25Client;
26use trusty_common::mcp::initialize_response;
27use trusty_common::memory_core::embed::FastEmbedder;
28use trusty_common::memory_core::{store::ChatSessionStore, PalaceRegistry};
29use trusty_common::ChatProvider;
30
31// Why: `tracing::info` is only used by the axum HTTP-serving helpers
32// (`run_http_on`, `spawn_uds_listener`). Pulling it in unconditionally
33// would trigger `unused_imports` warnings when the `axum-server`
34// feature is disabled. `SocketAddr` is still used by `bound_addr` on
35// `AppState` so it stays unconditional.
36#[cfg(feature = "axum-server")]
37use tracing::info;
38
39/// Two-phase daemon readiness state (issues #910/#911).
40///
41/// Why: The embedder cold-init (CoreML compile, 30-120 s) blocks the first
42/// real `memory_remember`/`memory_recall` call if it arrives before warm-up
43/// completes. Advertising the state lets handlers return an explicit, fast
44/// error ("daemon is warming up, retry shortly") instead of blocking for
45/// minutes.
46/// What: Two stable values stored atomically. `Warming` (0) is the initial
47/// state; `Ready` (1) is set once the embedder has been successfully
48/// initialised by `spawn_startup_tasks`. The transition is one-way and
49/// lock-free: a single `AtomicU8` compare-and-swap.
50/// Test: `daemon_readiness_transitions_warming_to_ready` in this module;
51/// end-to-end warming-error path covered by
52/// `tools::tests::remember_returns_warming_error_while_state_is_warming`.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum DaemonReadiness {
55 /// Embedder cold-init (and/or pin scan) still in progress.
56 Warming = 0,
57 /// Embedder initialised; all handlers may proceed normally.
58 Ready = 1,
59}
60
61impl DaemonReadiness {
62 /// Decode the raw atomic value.
63 ///
64 /// Why: centralises the `0 → Warming, else Ready` mapping so every
65 /// caller loads a meaningful enum rather than comparing raw integers.
66 /// What: returns `Warming` for `0`, `Ready` for any other value (only
67 /// `1` is ever written).
68 /// Test: `daemon_readiness_from_u8` in this module.
69 pub fn from_u8(v: u8) -> Self {
70 if v == 0 {
71 Self::Warming
72 } else {
73 Self::Ready
74 }
75 }
76}
77
78pub mod activity;
79pub mod attribution;
80pub mod bm25_supervisor;
81pub mod bootstrap;
82/// File-descriptor usage and limit reporting for `/health`.
83///
84/// Why: expose `open_fds` / `fd_soft_limit` so operators can see the fd
85/// ceiling and current consumption without needing lsof or shell access.
86/// Test: `fd_metrics::tests::fd_metrics_returns_sane_values`.
87pub mod fd_metrics;
88// Why (issue #226): `chat` and `web` are pure axum HTTP/SSE handler
89// surfaces. Gating them behind the `axum-server` feature is what lets
90// library consumers (e.g. `open-mpm` linking only `MemoryMcpService`)
91// drop axum + tower-http entirely from their build graph.
92#[cfg(feature = "axum-server")]
93pub mod chat;
94pub mod commands;
95pub mod console_metrics;
96pub mod discovery;
97/// Supervised `serve --foreground` entry point (issue #787).
98///
99/// Why: launchd supervisors need loud failure on port collision, not silent
100/// port-walking to 7071+. Extracted to stay under the 500-line ratchet cap.
101/// What: exports `bind_foreground_port` (Fix C — abort on EADDRINUSE) and
102/// `run_http_foreground` (Fix A lock + Fix B http_addr + Fix C combined).
103/// Test: `foreground::tests::bind_foreground_port_refuses_collision`;
104/// `daemon_lock` module tests cover the lock-file logic.
105pub mod foreground;
106pub mod hook_emit;
107pub mod kg_extract;
108pub mod mcp_service;
109pub mod messaging;
110pub mod openrpc;
111/// Issue #1217: default palace-ID derivation from project identity.
112///
113/// Why: the default palace ID should reflect the project's identity
114/// (git `owner/repo`, else `parent/dir`) rather than the bare directory
115/// basename, so the same repo resolves to the same palace across checkouts.
116/// What: exports the pure `derive_palace_id` core plus
117/// `owner_repo_from_git_remote`, `parent_dir_slug`, and the
118/// `TRUSTY_MEMORY_PALACE` env-override helpers.
119/// Test: see unit tests inside this module.
120pub mod palace_id_derive;
121/// Issue #88: project-root detection and palace-slug enforcement.
122///
123/// Why: prevents unbounded palace creation by anchoring palace names to the
124/// canonical slug of the project directory that contains the CWD, or to the
125/// `personal` sentinel for non-project contexts.
126/// What: exports `find_project_root`, `project_slug_at`, `project_slug`,
127/// `validate_palace_name`, `PERSONAL_PALACE`, and `PROJECT_MARKERS`.
128/// Test: see unit tests inside this module.
129pub mod project_root;
130pub mod prompt_facts;
131pub mod prompt_log;
132pub mod service;
133pub mod startup_scan;
134pub mod tools;
135pub mod transport;
136#[cfg(feature = "axum-server")]
137pub mod web;
138
139pub use activity::{ActivityEntry, ActivityFilter, ActivityLog, ActivitySource};
140pub use attribution::{CreatorInfo, CreatorSource};
141
142/// Maximum bytes retained in the trigger-prompt excerpt embedded on a
143/// `HookFired` event.
144///
145/// Why: the full triggering prompt is sensitive and already lives in the
146/// JSONL prompt log; the activity feed only needs enough text to give an
147/// operator a glance — a single-line ~80 char preview matches the existing
148/// `drawer_content_preview` convention so dashboard rows render uniformly.
149/// What: 80 characters; longer prompts are truncated with a trailing `…`.
150/// Test: `hook_excerpt_truncates_long_prompts`.
151pub const HOOK_PROMPT_EXCERPT_CHARS: usize = 80;
152
153/// Reduce a triggering prompt to the short excerpt embedded on a
154/// `HookFired` activity event.
155///
156/// Why: see [`HOOK_PROMPT_EXCERPT_CHARS`]. Centralising the truncation rule
157/// keeps every emitter (HTTP, hook CLI handlers, future tests) producing
158/// the same preview shape so UI rendering is uniform.
159/// What: whitespace-collapses `prompt` and trims to
160/// [`HOOK_PROMPT_EXCERPT_CHARS`] chars with `…` when cut. Empty input
161/// returns an empty string.
162/// Test: `hook_excerpt_truncates_long_prompts`,
163/// `hook_excerpt_collapses_whitespace`.
164pub fn hook_prompt_excerpt(prompt: &str) -> String {
165 let normalised: String = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
166 if normalised.chars().count() <= HOOK_PROMPT_EXCERPT_CHARS {
167 normalised
168 } else {
169 let kept: String = normalised
170 .chars()
171 .take(HOOK_PROMPT_EXCERPT_CHARS.saturating_sub(1))
172 .collect();
173 format!("{kept}…")
174 }
175}
176
177pub use mcp_service::MemoryMcpService;
178pub use tools::MemoryMcpServer;
179
180/// Resolve the directory that actually holds the per-palace subdirectories.
181///
182/// Why: there are two on-disk layouts in the wild. The current monorepo code
183/// treats the registry directory *itself* as the parent of per-palace dirs
184/// (`<dir>/<id>/palace.json`). The legacy standalone `trusty-memory` repo
185/// nested everything one level deeper under a `palaces/` subdirectory
186/// (`<data_dir>/palaces/<id>/palace.json`) — and that is where existing
187/// installs' data lives (e.g. 88 palaces under
188/// `~/Library/Application Support/trusty-memory/palaces/`). A daemon that uses
189/// the bare data dir as its registry root finds zero palaces because every
190/// `palace.json` sits one level below where it looked — the "palaces lost on
191/// restart" bug.
192/// What: given the standard data dir, returns `<data_dir>/palaces` when that
193/// subdirectory exists, otherwise `<data_dir>` itself. Resolving this once in
194/// `main.rs` and using the result as `AppState::data_root` keeps every call
195/// site (`status`, `palace_list`, `open_palace`, `palace_create`,
196/// `load_palaces_from_disk`) consistent without forcing a data migration.
197/// Test: `tests::resolve_palace_registry_dir_prefers_palaces_subdir` and
198/// `resolve_palace_registry_dir_falls_back_to_data_dir`.
199pub fn resolve_palace_registry_dir(data_dir: PathBuf) -> PathBuf {
200 let nested = data_dir.join("palaces");
201 if nested.is_dir() {
202 nested
203 } else {
204 data_dir
205 }
206}
207
208/// Hook type — labels the Claude Code hook that triggered a submission.
209///
210/// Why: every hook firing produces an activity-feed entry tagged with the
211/// originating hook so operators can tell whether activity came from a user
212/// prompt (`UserPromptSubmit`), a new session (`SessionStart`), or a future
213/// hook variant. Threading this through `DaemonEvent::HookFired` lets the
214/// dashboard badge each row with the hook label.
215/// What: serde-serialised in PascalCase so the wire format matches Claude
216/// Code's own hook-name strings exactly (e.g. `"UserPromptSubmit"`).
217/// Test: `hook_type_serde_round_trips`.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
219pub enum HookType {
220 /// Claude Code's `UserPromptSubmit` hook — fires on every user prompt.
221 UserPromptSubmit,
222 /// Claude Code's `SessionStart` hook — fires once at session open.
223 SessionStart,
224}
225
226impl HookType {
227 /// Stable string label used for the wire format.
228 pub fn as_str(&self) -> &'static str {
229 match self {
230 Self::UserPromptSubmit => "UserPromptSubmit",
231 Self::SessionStart => "SessionStart",
232 }
233 }
234}
235
236/// Injection kind — labels what the hook actually injected (or attempted).
237///
238/// Why: distinct from `HookType` because one hook could in principle render
239/// more than one kind of injection (e.g. SessionStart can deliver both an
240/// inbox check and bootstrap context). Tagging the rendered kind explicitly
241/// keeps the activity log searchable when that fan-out lands.
242/// What: serde-serialised as kebab-case so it matches the labels already
243/// used in the JSONL prompt log (`prompt-context-facts`,
244/// `inbox-check-messages`).
245/// Test: `injection_kind_serde_round_trips`.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
247#[serde(rename_all = "kebab-case")]
248pub enum InjectionKind {
249 /// `prompt-context` hook rendered the prompt-facts block.
250 PromptContext,
251 /// `inbox-check` hook delivered unread messages.
252 InboxCheck,
253}
254
255impl InjectionKind {
256 /// Stable string label used for the wire format.
257 pub fn as_str(&self) -> &'static str {
258 match self {
259 Self::PromptContext => "prompt-context",
260 Self::InboxCheck => "inbox-check",
261 }
262 }
263}
264
265/// Live daemon events broadcast to connected SSE subscribers.
266///
267/// Why: The dashboard needs push-driven updates so palace creation, drawer
268/// add/delete, dream cycles, and aggregate status changes are visible without
269/// polling. A single broadcast channel fans out to every connected browser.
270/// What: Tagged enum serialized as `{"type": "...", ...fields}` over SSE.
271/// Test: `web::tests::sse_stream_emits_events` subscribes, triggers a
272/// mutation, and asserts the frame arrives.
273#[derive(Clone, Debug, serde::Serialize)]
274#[serde(tag = "type", rename_all = "snake_case")]
275pub enum DaemonEvent {
276 PalaceCreated {
277 id: String,
278 name: String,
279 /// Originating subsystem (HTTP, MCP, Hook). Why (issue #96): the
280 /// UI badges each row with its source so operators can tell at a
281 /// glance whether a write came from the dashboard form, an MCP
282 /// tool call, or a hook-driven path. The wire-format key is
283 /// `source` (lower-case strings via serde rename_all on
284 /// `ActivitySource`).
285 source: ActivitySource,
286 },
287 DrawerAdded {
288 palace_id: String,
289 /// Friendly palace name (Palace.name) at write time. Why: lets SSE
290 /// consumers (the dashboard activity feed) render the human-readable
291 /// label without a separate id→name lookup. Empty string if the
292 /// emitter could not resolve the name.
293 #[serde(default)]
294 palace_name: String,
295 drawer_count: usize,
296 /// Wall-clock timestamp when the drawer was added. Why: SSE
297 /// receivers want to render "just now / 2m ago" relative to the
298 /// daemon's clock, not the time the SSE frame happens to arrive.
299 timestamp: chrono::DateTime<chrono::Utc>,
300 /// Short preview of the drawer's content (whitespace-collapsed,
301 /// truncated to ~80 chars with an ellipsis when cut). Why: the TUI
302 /// activity feed and dashboard ticker want to show *what* was
303 /// stored, not just the running drawer count. Empty when the
304 /// emitter could not resolve the content (legacy clients tolerate
305 /// the missing field via `#[serde(default)]`).
306 #[serde(default)]
307 content_preview: String,
308 /// Originating subsystem (issue #96).
309 source: ActivitySource,
310 },
311 DrawerDeleted {
312 palace_id: String,
313 drawer_count: usize,
314 /// Originating subsystem (issue #96).
315 source: ActivitySource,
316 },
317 DreamCompleted {
318 palace_id: Option<String>,
319 merged: usize,
320 pruned: usize,
321 compacted: usize,
322 closets_updated: usize,
323 duration_ms: u64,
324 /// Originating subsystem (issue #96).
325 source: ActivitySource,
326 },
327 StatusChanged {
328 total_drawers: usize,
329 total_vectors: usize,
330 total_kg_triples: usize,
331 },
332 /// A Claude Code hook completed and rendered (or attempted to render) an
333 /// injection block.
334 ///
335 /// Why: pre-#XXX the activity feed only fired on drawer / palace / dream
336 /// writes, which meant a normal Claude Code session — whose only daemon
337 /// traffic is hook invocations — left the feed empty. Surfacing every
338 /// hook firing answers the user complaint "no activity in the TUI" and
339 /// gives operators a way to see how often each project palace is
340 /// actually picking up prompt-context / inbox-check work.
341 /// What: carries the resolved palace (or `None` if cwd resolution
342 /// failed), the [`HookType`] label, the [`InjectionKind`] label, the
343 /// rendered injection byte length, a short excerpt of the triggering
344 /// prompt (capped at ~80 chars; the full content stays in the JSONL
345 /// prompt log only), the timestamp, the hook's wall-clock duration,
346 /// and the [`ActivitySource`] tag (always `Hook` for this variant).
347 /// Backwards-compatible: SSE clients that do not recognise the
348 /// `hook_fired` `type` tag can safely ignore the frame.
349 HookFired {
350 /// Resolved palace id (slug) — `None` if cwd resolution failed.
351 #[serde(default)]
352 palace_id: Option<String>,
353 /// Friendly palace name at hook time — `None` if the registry
354 /// could not be consulted (HTTP path uses `palace_id` here when
355 /// no separate name is known).
356 #[serde(default)]
357 palace_name: Option<String>,
358 hook_type: HookType,
359 injection_kind: InjectionKind,
360 /// Rendered injection size in bytes (`0` when no injection was
361 /// emitted, e.g. SessionStart with an empty inbox).
362 injection_length: u64,
363 /// Short excerpt of the triggering prompt for the activity feed
364 /// display. Capped at ~80 chars with a trailing `…` when cut.
365 /// Why: the activity feed renders this directly; full prompt
366 /// content (which may be sensitive) stays in the JSONL log.
367 #[serde(default)]
368 trigger_prompt_excerpt: String,
369 timestamp: chrono::DateTime<chrono::Utc>,
370 /// Hook wall-clock duration in milliseconds.
371 duration_ms: u64,
372 /// Always `ActivitySource::Hook` for this variant; encoded explicitly
373 /// so the same dispatch path (`emit`) can persist + broadcast it.
374 source: ActivitySource,
375 },
376}
377
378/// Open the activity log under `data_root`, falling back to a per-process
379/// tempdir and finally to a no-op `Discard` variant when no writable
380/// directory is available.
381///
382/// Why (issues #96, #225): the activity log is a best-effort feature — if
383/// the data root is on a read-only mount, missing, or locked by another
384/// process, the daemon should still come up and serve every other endpoint.
385/// The first fallback is a `std::env::temp_dir()`-anchored subdirectory
386/// keyed by the daemon's process id. Issue #225: a previous version called
387/// `expect()` on the tempdir fallback, which crashed the daemon on hosts
388/// where neither `data_root` nor `std::env::temp_dir()` is writable
389/// (read-only containers, locked-down sandboxes). The contract is
390/// "best-effort", so the final fallback is now `ActivityLog::discard()` —
391/// a no-op variant that drops every append and returns empty reads. The
392/// dashboard's activity feed simply shows up empty in that degraded state.
393/// What: tries `ActivityLog::open(data_root)`; on error logs a warning and
394/// retries against `<temp>/trusty-memory-activity-<pid>/`. If both fail,
395/// emits a final warning and returns `ActivityLog::discard()`.
396/// Test: `open_activity_log_with_fallback_returns_discard_when_unwritable`
397/// covers the discard branch; existing `AppState` construction tests cover
398/// the happy and tempdir-fallback paths.
399fn open_activity_log_with_fallback(data_root: &Path) -> Arc<ActivityLog> {
400 match ActivityLog::open(data_root) {
401 Ok(log) => Arc::new(log),
402 Err(primary_err) => {
403 tracing::warn!(
404 "could not open activity log at {}: {primary_err:#}; falling back to per-process tempdir",
405 data_root.display()
406 );
407 let fallback =
408 std::env::temp_dir().join(format!("trusty-memory-activity-{}", std::process::id()));
409 match ActivityLog::open(&fallback) {
410 Ok(log) => Arc::new(log),
411 Err(fallback_err) => {
412 tracing::warn!(
413 "activity log tempdir fallback at {} also failed: {fallback_err:#}; \
414 activity feed disabled for this process (no-op log)",
415 fallback.display()
416 );
417 Arc::new(ActivityLog::discard())
418 }
419 }
420 }
421 }
422}
423
424impl DaemonEvent {
425 /// Short discriminant label matching the SSE `type` field.
426 ///
427 /// Why: the persisted activity log stores `event_type` as a string so
428 /// the UI can render the row without re-parsing the payload. Sharing
429 /// the same labels the SSE serializer uses keeps the wire and the
430 /// stored history consistent.
431 /// What: returns one of `palace_created`, `drawer_added`,
432 /// `drawer_deleted`, `dream_completed`, `status_changed`.
433 /// Test: `daemon_event_type_str_matches_sse_tag` in the lib tests.
434 pub fn type_str(&self) -> &'static str {
435 match self {
436 Self::PalaceCreated { .. } => "palace_created",
437 Self::DrawerAdded { .. } => "drawer_added",
438 Self::DrawerDeleted { .. } => "drawer_deleted",
439 Self::DreamCompleted { .. } => "dream_completed",
440 Self::StatusChanged { .. } => "status_changed",
441 Self::HookFired { .. } => "hook_fired",
442 }
443 }
444
445 /// `palace_id` if the event is scoped to a single palace.
446 ///
447 /// Why: the activity log indexes entries by palace id so the UI can
448 /// filter by palace; daemon-wide events (`status_changed`,
449 /// dream-across-all-palaces) return `None`.
450 /// What: returns a borrowed string when the variant carries a palace
451 /// id, otherwise `None`.
452 /// Test: `daemon_event_palace_id_extraction`.
453 pub fn palace_id(&self) -> Option<&str> {
454 match self {
455 Self::PalaceCreated { id, .. } => Some(id),
456 Self::DrawerAdded { palace_id, .. } | Self::DrawerDeleted { palace_id, .. } => {
457 Some(palace_id)
458 }
459 Self::DreamCompleted { palace_id, .. } => palace_id.as_deref(),
460 Self::HookFired { palace_id, .. } => palace_id.as_deref(),
461 Self::StatusChanged { .. } => None,
462 }
463 }
464
465 /// Originating subsystem if the event carries one.
466 ///
467 /// Why: only mutation events carry a `source`; the aggregate
468 /// `StatusChanged` is recomputed by the daemon and has no caller, so
469 /// it returns `None`.
470 /// What: returns the variant's `source` field where present.
471 /// Test: `daemon_event_source_extraction`.
472 pub fn source(&self) -> Option<ActivitySource> {
473 match self {
474 Self::PalaceCreated { source, .. }
475 | Self::DrawerAdded { source, .. }
476 | Self::DrawerDeleted { source, .. }
477 | Self::DreamCompleted { source, .. }
478 | Self::HookFired { source, .. } => Some(*source),
479 Self::StatusChanged { .. } => None,
480 }
481 }
482}
483
484/// Shared application state passed to every request handler.
485///
486/// Why: The stdio loop and HTTP server need the same handles to the registry,
487/// data root, and embedder so MCP tools can perform real reads/writes against
488/// the live trusty-memory core. The embedder is heavy (loads ONNX weights) so
489/// we hold it behind a `OnceCell` and initialize lazily on first use.
490/// What: `Clone`-able via `Arc` fields. The registry / data root are eager;
491/// `embedder` is `Arc<OnceCell<Arc<FastEmbedder>>>` so concurrent first-use
492/// races resolve to a single shared instance.
493/// Test: `app_state_default_constructs` confirms construction without panic.
494#[derive(Clone)]
495pub struct AppState {
496 pub version: String,
497 pub registry: Arc<PalaceRegistry>,
498 pub data_root: PathBuf,
499 pub embedder: Arc<OnceCell<Arc<FastEmbedder>>>,
500 /// Optional default palace applied to MCP tool calls when the caller
501 /// omits the `palace` argument. Set via `trusty-memory serve --palace`.
502 pub default_palace: Option<String>,
503 /// Active chat provider selected at startup. `None` means no upstream is
504 /// configured (no Ollama detected and no OpenRouter key) — callers must
505 /// degrade gracefully (chat endpoint returns 412).
506 pub chat_provider: Arc<OnceCell<Option<Arc<dyn ChatProvider>>>>,
507 /// Per-palace chat-session stores, opened lazily so cold-start cost is
508 /// paid only when chat-history endpoints are hit.
509 pub session_stores: Arc<dashmap::DashMap<String, Arc<ChatSessionStore>>>,
510 /// Broadcast sender for live `DaemonEvent` pushes to SSE subscribers.
511 ///
512 /// Why: Lets mutating handlers emit events that any connected dashboard
513 /// receives instantly. Cap of 128 buffers transient slow readers; if a
514 /// receiver lags it gets `RecvError::Lagged` and we emit a `lag` frame.
515 pub events: Arc<broadcast::Sender<DaemonEvent>>,
516 /// Instant the daemon started, used to compute `uptime_secs` on `/health`.
517 ///
518 /// Why (issue #35): `GET /health` reports how long the daemon has been
519 /// up. Capturing a monotonic `Instant` at `AppState` construction lets the
520 /// handler compute the elapsed seconds cheaply and without a clock-skew
521 /// hazard.
522 /// What: a wall-monotonic `Instant`; `AppState::new` stamps it at startup.
523 /// Test: `health_endpoint_includes_resource_fields`.
524 pub started_at: std::time::Instant,
525 /// In-memory ring buffer of recent tracing log lines (issue #35).
526 ///
527 /// Why: the `GET /api/v1/logs/tail` endpoint serves the last N log lines
528 /// so operators can inspect a running daemon without tailing a file. The
529 /// buffer is shared between the tracing `LogBufferLayer` (writer) and the
530 /// HTTP handler (reader).
531 /// What: a cheap `Arc`-backed clone of the buffer the subscriber writes
532 /// to. Defaults to an empty buffer for states that never install the
533 /// layer (tests, the stdio path).
534 /// Test: `logs_tail_returns_recent_lines`.
535 pub log_buffer: trusty_common::log_buffer::LogBuffer,
536 /// Bug-capture ERROR store (bug-reporting #478, Phase 1).
537 ///
538 /// Why: Phase 2 MCP / HTTP endpoints need to query captured errors; stashing
539 /// the `ErrorStore` handle here lets any handler reach it cheaply without
540 /// a second global or per-request construction.
541 /// What: populated by `run_serve` from the `init_tracing_with_buffer_and_capture`
542 /// result; the layer writes to this store automatically so every
543 /// `tracing::error!` call site contributes without any changes to call
544 /// sites. `None` in states that do not install the layer (tests, the
545 /// stdio path).
546 /// Test: compile-presence is verified by the `trusty-memory` build; Phase 2
547 /// will add query tests in `web.rs`.
548 pub error_store: Option<trusty_common::error_capture::ErrorStore>,
549 /// Most recent on-disk footprint of `data_root`, in bytes (issue #35).
550 ///
551 /// Why: `GET /health` reports `disk_bytes`. Walking the data directory on
552 /// every health request would make a frequent health poll do unbounded
553 /// I/O; a background task recomputes it every 10 s and stores it here so
554 /// the handler reads it lock-free.
555 /// What: an `AtomicU64` updated by the ticker spawned in `run_http_on`.
556 /// `0` until the first walk completes.
557 /// Test: `health_endpoint_includes_resource_fields`.
558 pub disk_bytes: Arc<std::sync::atomic::AtomicU64>,
559 /// Per-process RSS + CPU sampler, refreshed on each `/health` request
560 /// (issue #35).
561 ///
562 /// Why: CPU usage is a delta between two `sysinfo` refreshes, so the
563 /// sampler must persist between requests — hence the shared `Mutex`.
564 /// What: a `tokio::sync::Mutex<SysMetrics>` so the async health handler
565 /// can sample without blocking the runtime.
566 /// Test: `health_endpoint_includes_resource_fields`.
567 pub sys_metrics: Arc<tokio::sync::Mutex<trusty_common::sys_metrics::SysMetrics>>,
568 /// HTTP listener address the daemon bound to, once `run_http_on` is running.
569 ///
570 /// Why: clients (and `/health` responses) need to advertise the live
571 /// `host:port` even though port selection happens dynamically (7070–7079
572 /// walk + OS fallback). Stashing it on `AppState` lets request handlers
573 /// surface the discovery value without re-querying the listener.
574 /// What: a `OnceLock<SocketAddr>` so `run_http_on` writes it exactly once
575 /// at bind time and every handler reads it lock-free thereafter. Empty
576 /// (`None` from `get()`) on the stdio path where no listener exists.
577 /// Test: `health_endpoint_reports_bound_addr` (added below).
578 pub bound_addr: Arc<OnceLock<SocketAddr>>,
579 /// Cached prompt-facts surface served by the MCP `get_prompt_context`
580 /// tool (issue #42).
581 ///
582 /// Why: The original session-init `prompts/get` design loaded context
583 /// once per connection; switching to a per-message tool lets the model
584 /// pull fresh, query-filtered context on demand. The cache holds both
585 /// the raw triples (for filtered lookups) and a pre-formatted Markdown
586 /// block (for the unfiltered hot path) so neither code path re-walks
587 /// the KG. The cache is rebuilt by
588 /// `prompt_facts::rebuild_prompt_cache` after any write that touches a
589 /// hot predicate (`kg_assert`, `add_alias`, `remove_prompt_fact`).
590 /// What: An `Arc<tokio::sync::RwLock<PromptFactsCache>>` so the hot
591 /// read path takes a brief read lock and clones the cache; rebuilds
592 /// take a write lock for the assignment only. The async-aware lock
593 /// (issue #229) yields to the tokio runtime instead of blocking a
594 /// runtime thread for the rebuild duration. An empty `triples` vec ↔
595 /// "no context stored yet" (the tool handler renders a hint).
596 /// Test: `get_prompt_context_returns_cached_or_hint`,
597 /// `get_prompt_context_filters_by_query`.
598 pub prompt_context_cache: Arc<RwLock<prompt_facts::PromptFactsCache>>,
599 /// Persistent activity log (issue #96).
600 ///
601 /// Why: the dashboard activity feed used to be a pure live-stream over
602 /// `/sse` — opening the UI showed an empty feed and any mutation from
603 /// the MCP path was invisible. Holding an `ActivityLog` on `AppState`
604 /// lets `emit` record an entry on every push so the
605 /// `GET /api/v1/activity` handler can return historical rows on mount
606 /// and the live SSE stream can continue prepending events on top of
607 /// the loaded history. `None` on builds that opt out (tests that use
608 /// `AppState::new` get a real log under their tempdir so behaviour
609 /// matches production).
610 /// What: an `Arc<ActivityLog>` shared with every emitter.
611 /// Test: `web::tests::activity_endpoint_lists_recent_emits`.
612 pub activity_log: Arc<ActivityLog>,
613 /// Optional per-palace BM25 lexical search lane (issue #156).
614 ///
615 /// Why: in-process BM25 would serialise the recall hot path on disk
616 /// I/O during writes and contend with the redb/usearch locks. Delegating
617 /// to the `trusty-bm25-daemon` subprocess (one socket per palace) keeps
618 /// BM25 ingestion and search off the critical path while still feeding
619 /// hits into the recall RRF fusion.
620 /// What: `Some(client)` only when `TRUSTY_BM25_DAEMON=1` at startup —
621 /// every code path that uses this field is gated on `is_some()` and
622 /// falls back to vector-only behaviour otherwise so existing deployments
623 /// see zero behavioural change.
624 /// Test: `bm25_client_disabled_by_default`,
625 /// `bm25_client_enabled_when_env_set`.
626 pub bm25_client: Option<Arc<Bm25Client>>,
627 /// Optional per-palace BM25 daemon spawn supervisor (issue #193).
628 ///
629 /// Why: without an in-process supervisor the BM25 daemon must be
630 /// launched out-of-band (launchd, manual `trusty-bm25-daemon`), which
631 /// is the same UX trap PR #190 fixed for trusty-embedderd. Holding a
632 /// supervisor here lets us spawn the daemon on first BM25 use for a
633 /// palace, restart it if it dies, and reap it on clean shutdown.
634 /// `Some` only when `TRUSTY_BM25_DAEMON=1` at startup — the same gate
635 /// that enables `bm25_client`. When set but `TRUSTY_BM25_EXTERNAL=1`,
636 /// the supervisor's `ensure_running` becomes a no-op that just returns
637 /// the canonical socket path so operators can keep using their own
638 /// process manager.
639 /// Test: covered by `bm25_supervisor_present_when_env_set` and the
640 /// `bm25_supervisor::tests` unit tests.
641 pub bm25_supervisor: Option<Arc<bm25_supervisor::Bm25Supervisor>>,
642 /// Per-palace write serialisation locks (issue #230).
643 ///
644 /// Why: the dedup gate in `tools.rs` previously read a snapshot of
645 /// existing drawers, checked for near-duplicates via Jaro-Winkler, and
646 /// then issued the write — a classic time-of-check/time-of-use race.
647 /// Two concurrent `memory_remember` calls with the same content could
648 /// both see the pre-write snapshot, both pass the gate, and both land
649 /// duplicate drawers. Serialising the gate-then-write sequence per
650 /// palace closes the window: while one task holds the mutex, any
651 /// concurrent writer for the same palace blocks until the first write
652 /// finishes and is visible to `list_drawers`. The lock is **per
653 /// palace** (not global) so writes to different palaces continue to
654 /// run in parallel.
655 /// What: a `DashMap` keyed by palace id, where each entry is an
656 /// `Arc<tokio::sync::Mutex<()>>`. The mutex is constructed lazily by
657 /// `palace_write_lock` on first access. `Arc` lets callers hold a
658 /// clone of the lock past the lifetime of the `DashMap` entry so the
659 /// map never needs to be held across an `.await`.
660 /// Test: `tools::tests::dedup_gate_blocks_concurrent_duplicate_writes`.
661 pub palace_write_locks: Arc<dashmap::DashMap<String, Arc<tokio::sync::Mutex<()>>>>,
662 /// Counter of in-flight activity-log writes spawned by `emit`
663 /// (issue #232).
664 ///
665 /// Why: `emit` offloads the synchronous redb append to the tokio blocking
666 /// pool via `spawn_blocking` so the async runtime is never parked waiting
667 /// on fsync. The write is fire-and-forget — `emit` returns immediately
668 /// after spawning. Tests that observe the activity log right after a
669 /// burst of `emit` calls need a deterministic synchronization point;
670 /// holding an in-flight counter lets `flush_activity_writes` poll until
671 /// every spawned append has settled, which keeps the assertions
672 /// race-free without forcing every caller to `.await`.
673 /// What: an `Arc<AtomicUsize>` incremented before each `spawn_blocking`
674 /// and decremented inside the closure (after the append completes, even
675 /// if it errored). The counter is cheap (one atomic add per emit) and
676 /// stays at zero in steady-state production traffic.
677 /// Test: `web::tests::activity_endpoint_lists_recent_emits` and
678 /// `tests::emit_persists_mutations_but_skips_status_changed` call
679 /// `flush_activity_writes` to drain the counter before reading the log.
680 pub pending_activity_writes: Arc<AtomicUsize>,
681 /// In-memory cache mapping palace id → `Palace.name` (issue #228).
682 ///
683 /// Why: every `memory_remember` / `memory_note` write used to call
684 /// `PalaceRegistry::list_palaces` (a synchronous filesystem walk of the
685 /// data root) just to resolve a friendly palace name for the SSE
686 /// `DrawerAdded` event. With N palaces on disk the cost was O(N) opendirs
687 /// plus `palace.json` reads on every write, blocking the async runtime.
688 /// Caching the name in-memory turns the lookup into a `DashMap::get`.
689 /// What: `DashMap<String, String>` populated by `create_palace` and
690 /// `load_palaces_from_disk`, kept in sync by rename / delete paths.
691 /// Missing entries are treated as "name unknown" so callers fall back to
692 /// the palace id and the emit path never fails.
693 /// Test: `palace_name_cache_populated_after_hydration` and
694 /// `palace_name_cache_updates_on_create`.
695 pub palace_names: Arc<dashmap::DashMap<String, String>>,
696 /// Single-pass startup pin-file map: palace id → project root path (issue #470).
697 ///
698 /// Why: after daemon startup we have no record of which on-disk project
699 /// directories correspond to which palace ids — that information only
700 /// existed inside the pin files on disk. Eager-opening every palace on
701 /// startup is too expensive. This field captures the scan-only result of
702 /// `startup_scan::scan_pin_map` so handlers that want to locate a project
703 /// by its palace id (e.g. future cwd-inference, project-health checks)
704 /// can do a single `DashMap::get` instead of a filesystem walk.
705 /// Populated once, shortly after `load_palaces_from_disk` returns, by
706 /// `spawn_startup_tasks`. Never mutated after population — it is a
707 /// snapshot of what the filesystem looked like at startup.
708 /// What: `DashMap<String (palace_id), PathBuf (project root)>`.
709 /// The outer `Arc` lets `spawn_startup_tasks` (which holds only a clone
710 /// of `AppState`) write to the same backing map that request handlers
711 /// read. Population is asynchronous so callers must treat an absent entry
712 /// as "not yet scanned" (or "no pin found"), never as "palace unknown".
713 /// Test: `startup_scan::tests::scan_pin_map_*` validate the underlying
714 /// scanner function; the wiring in `spawn_startup_tasks` is covered by
715 /// the integration-test daemon start path.
716 pub pin_project_map: Arc<dashmap::DashMap<String, PathBuf>>,
717 /// Bounded sender for the BM25 index worker (issue #231).
718 ///
719 /// Why: the previous fire-and-forget design `tokio::spawn`ed one task per
720 /// `memory_remember` / `memory_note` call, so a write burst against a slow
721 /// or unreachable BM25 daemon grew an unbounded in-flight task queue. A
722 /// single long-lived worker draining a bounded mpsc channel caps that
723 /// back-pressure: writers `try_send` (never block), full-queue requests
724 /// are dropped with a `warn!`, and the worker exits cleanly when the last
725 /// sender is dropped on shutdown.
726 /// What: an `mpsc::Sender` cloned to every `AppState` clone (cheap). The
727 /// matching receiver is consumed by the worker spawned in
728 /// [`AppState::new`] via [`tools::spawn_bm25_index_worker`]. Capacity is
729 /// [`tools::BM25_INDEX_QUEUE_CAPACITY`] (256).
730 /// Test: `bm25_index_queue_drops_when_full` exercises the full-queue
731 /// branch via `bm25_index_enqueue`.
732 pub bm25_index_tx: tokio::sync::mpsc::Sender<tools::Bm25IndexRequest>,
733 /// Cached result of the startup update check (issue #537).
734 ///
735 /// Why: `/health` should report `update_available` without hitting crates.io
736 /// on every probe. A single background check at daemon startup stores the
737 /// result here; the health handler reads it lock-free (well, a brief mutex
738 /// lock) without a network call.
739 /// What: `None` = up-to-date or check not yet done; `Some("x.y.z")` = newer
740 /// version available. The field is populated by a `tokio::spawn` in
741 /// `spawn_startup_tasks` (main.rs) after the daemon binds.
742 /// Test: indirectly by the `/health` endpoint tests in `web.rs`.
743 pub update_available: Arc<std::sync::Mutex<Option<String>>>,
744 /// Two-phase readiness state — `Warming` until the embedder is initialised,
745 /// then `Ready` (issues #910 / #911).
746 ///
747 /// Why: `AppState::embedder()` used to call `FastEmbedder::new()` without
748 /// any timeout, so the first `memory_recall`/`memory_remember` that arrived
749 /// before CoreML finished compiling would block for 5–11 hours until the
750 /// OnceCell resolved (issue #910). Exposing this state lets the preflight
751 /// guards in `tools.rs` return an explicit fast error immediately —
752 /// `"trusty-memory is warming up, retry shortly"` — instead of queueing
753 /// behind an open-ended init.
754 /// What: An `AtomicU8` starting at `DaemonReadiness::Warming` (0) and flipped
755 /// to `DaemonReadiness::Ready` (1) by `spawn_startup_tasks` after the embedder
756 /// warm-up succeeds. The transition is one-way and lock-free.
757 /// Test: `daemon_readiness_transitions_warming_to_ready`.
758 pub daemon_readiness: Arc<AtomicU8>,
759}
760
761impl AppState {
762 /// Construct an `AppState` rooted at the given on-disk data directory.
763 ///
764 /// Why: The CLI (`serve`) and integration tests need to point the MCP
765 /// server at different roots — production at `dirs::data_dir`, tests at a
766 /// `tempfile::tempdir()`.
767 /// What: Builds an empty `PalaceRegistry`, captures the version, and
768 /// allocates an empty `OnceCell` for the embedder. `default_palace` is
769 /// `None`; use `with_default_palace` to set it.
770 /// Test: `tools::tests::dispatch_palace_create_persists` constructs an
771 /// AppState pointed at a tempdir and round-trips a palace through it.
772 pub fn new(data_root: PathBuf) -> Self {
773 let (events_tx, _) = broadcast::channel::<DaemonEvent>(128);
774 // Issue #96: open (or create) the persistent activity log under the
775 // daemon data root. Open failure is logged but never crashes the
776 // daemon — we fall back to a per-process tempdir so emits remain
777 // best-effort and the rest of the daemon keeps working.
778 let activity_log = open_activity_log_with_fallback(&data_root);
779 // Issue #231: bounded mpsc channel + single long-lived worker
780 // replaces the per-write `tokio::spawn` fire-and-forget pattern so
781 // BM25 indexing back-pressure is capped. The worker is spawned here
782 // unconditionally so the channel always has a drain — even when
783 // `bm25_client` is `None`, the worker just consumes and discards
784 // each request so senders never block on a full queue.
785 let (bm25_index_tx, bm25_index_rx) =
786 tokio::sync::mpsc::channel::<tools::Bm25IndexRequest>(tools::BM25_INDEX_QUEUE_CAPACITY);
787 // `bm25_client` / `bm25_supervisor` start as `None`; the builder
788 // `with_bm25_client_from_env` rebuilds the worker with the real
789 // client + supervisor once env-gated opt-in is resolved.
790 tools::spawn_bm25_index_worker(bm25_index_rx, None, None);
791 Self {
792 version: env!("CARGO_PKG_VERSION").to_string(),
793 registry: Arc::new(PalaceRegistry::new()),
794 data_root,
795 embedder: Arc::new(OnceCell::new()),
796 default_palace: None,
797 chat_provider: Arc::new(OnceCell::new()),
798 session_stores: Arc::new(dashmap::DashMap::new()),
799 events: Arc::new(events_tx),
800 started_at: std::time::Instant::now(),
801 // Default to an empty buffer — `with_log_buffer` overrides this
802 // when the daemon installs the `LogBufferLayer` (HTTP mode).
803 log_buffer: trusty_common::log_buffer::LogBuffer::new(
804 trusty_common::log_buffer::DEFAULT_LOG_CAPACITY,
805 ),
806 // Bug-reporting #478: `None` until `with_error_store` is called
807 // during daemon startup (HTTP mode). Tests keep `None` so no
808 // unexpected files are written to the OS data dir.
809 error_store: None,
810 disk_bytes: Arc::new(std::sync::atomic::AtomicU64::new(0)),
811 sys_metrics: Arc::new(tokio::sync::Mutex::new(
812 trusty_common::sys_metrics::SysMetrics::new(),
813 )),
814 bound_addr: Arc::new(OnceLock::new()),
815 prompt_context_cache: Arc::new(RwLock::new(prompt_facts::PromptFactsCache::default())),
816 activity_log,
817 bm25_client: None,
818 bm25_supervisor: None,
819 palace_write_locks: Arc::new(dashmap::DashMap::new()),
820 pending_activity_writes: Arc::new(AtomicUsize::new(0)),
821 palace_names: Arc::new(dashmap::DashMap::new()),
822 pin_project_map: Arc::new(dashmap::DashMap::new()),
823 bm25_index_tx,
824 update_available: Arc::new(std::sync::Mutex::new(None)),
825 // Start in Warming state; flipped to Ready by spawn_startup_tasks
826 // once the embedder warm-up succeeds (issues #910/#911).
827 daemon_readiness: Arc::new(AtomicU8::new(DaemonReadiness::Warming as u8)),
828 }
829 }
830
831 /// Acquire (lazily, then clone) the per-palace write mutex.
832 ///
833 /// Why (issue #230): the dedup-check + `remember_with_options` write
834 /// sequence in `tools.rs` must be atomic per palace to prevent two
835 /// concurrent identical writes from both passing the dedup gate.
836 /// Callers hold the returned `Arc<Mutex<()>>`'s guard across the gate
837 /// check and the write so the second writer blocks until the first
838 /// write is visible to `list_drawers`. Returning a clone of the `Arc`
839 /// rather than a borrow into the `DashMap` lets the caller `.await`
840 /// while holding the lock without risking a deadlock against any
841 /// future map mutation (DashMap shards are sync mutexes).
842 /// What: looks up the palace id in `palace_write_locks` and returns
843 /// a clone of the existing mutex; on the first call for a palace,
844 /// inserts a freshly-constructed `tokio::sync::Mutex<()>` first. The
845 /// `DashMap::entry().or_insert_with` API guarantees the lazy
846 /// construction is racy-safe — only one mutex is ever inserted per
847 /// palace id.
848 /// Test: `tools::tests::dedup_gate_blocks_concurrent_duplicate_writes`.
849 pub fn palace_write_lock(&self, palace_id: &str) -> Arc<tokio::sync::Mutex<()>> {
850 if let Some(existing) = self.palace_write_locks.get(palace_id) {
851 return existing.clone();
852 }
853 self.palace_write_locks
854 .entry(palace_id.to_string())
855 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
856 .clone()
857 }
858
859 /// Look up a project root path by palace id in the startup pin-scan map.
860 ///
861 /// Why: provides a stable, cheap accessor so handlers do not reach directly
862 /// into the `DashMap` field and so the accessor can be mocked in future
863 /// tests without touching `AppState` internals. The map is populated
864 /// asynchronously by `spawn_startup_tasks` — an absent entry means either
865 /// the scan has not completed yet or no pin file claimed that id.
866 /// What: returns `Some(project_path)` when the palace id was found during
867 /// startup scan; `None` otherwise.
868 /// Test: covered indirectly via the startup-scan integration path; the
869 /// underlying map data is validated by `startup_scan::tests`.
870 pub fn pinned_project_path(&self, palace_id: &str) -> Option<PathBuf> {
871 self.pin_project_map.get(palace_id).map(|e| e.clone())
872 }
873
874 /// Builder-style: opt-in to the BM25 lexical lane (issue #156).
875 ///
876 /// Why: the BM25 subprocess is gated behind `TRUSTY_BM25_DAEMON=1` so
877 /// the default `cargo install trusty-memory` / launchd plist deployment
878 /// stays vector-only and existing test fixtures keep passing without
879 /// having to provision a daemon. Reading the env var here keeps the
880 /// gating logic in one place (the helper in `main.rs` just plumbs the
881 /// result through).
882 /// What: when `TRUSTY_BM25_DAEMON=1`, constructs one `Bm25Client` per
883 /// palace by lazy-resolving the socket path the first time the palace
884 /// id is observed. Currently we install a shared `default` client up
885 /// front and re-key on the palace id at the call site — palaces with no
886 /// daemon socket simply see search/index errors which we log + ignore.
887 /// Returns `self` unchanged when the env var is unset or set to anything
888 /// other than `1`.
889 /// Test: `bm25_client_disabled_by_default`,
890 /// `bm25_client_enabled_when_env_set`.
891 #[must_use]
892 pub fn with_bm25_client_from_env(mut self) -> Self {
893 if std::env::var("TRUSTY_BM25_DAEMON").as_deref() == Ok("1") {
894 // Install the default-palace client; per-palace clients are
895 // constructed on demand via `Bm25Client::for_palace`.
896 let default_palace = self.default_palace.as_deref().unwrap_or("default");
897 self.bm25_client = Some(Arc::new(Bm25Client::for_palace(default_palace)));
898 // Issue #193: hand-in-hand with the client, attach a spawn
899 // supervisor so the BM25 daemon is auto-started on first use
900 // for any palace. Operators who want to manage daemons
901 // out-of-band (launchd, systemd, manual) set
902 // TRUSTY_BM25_EXTERNAL=1 which makes the supervisor a no-op.
903 self.bm25_supervisor = Some(Arc::new(bm25_supervisor::Bm25Supervisor::new()));
904 // Issue #231: rebuild the bounded indexer channel + worker so
905 // the worker holds the now-populated client + supervisor. The
906 // placeholder worker installed by `AppState::new` (with `None`
907 // / `None`) drained the channel into the void — replacing the
908 // sender here closes the placeholder receiver and the
909 // placeholder worker exits cleanly. The new worker takes over
910 // as the sole drain for the indexer queue.
911 let (tx, rx) = tokio::sync::mpsc::channel::<tools::Bm25IndexRequest>(
912 tools::BM25_INDEX_QUEUE_CAPACITY,
913 );
914 tools::spawn_bm25_index_worker(
915 rx,
916 self.bm25_client.clone(),
917 self.bm25_supervisor.clone(),
918 );
919 self.bm25_index_tx = tx;
920 tracing::info!(
921 palace = default_palace,
922 "BM25 daemon client + spawn supervisor enabled (TRUSTY_BM25_DAEMON=1)"
923 );
924 }
925 self
926 }
927
928 /// Scan the palace registry directory and re-register every persisted
929 /// palace into the in-memory [`PalaceRegistry`].
930 ///
931 /// Why: `AppState::new` builds an *empty* registry, so after a daemon
932 /// restart `palace_list` / the dashboard reported zero palaces even though
933 /// dozens existed on disk — palace metadata was persisted by
934 /// `palace_create` but never re-hydrated on startup. This method closes
935 /// that gap by walking the on-disk layout (each subdirectory holding a
936 /// `palace.json` is one palace) and rebuilding a live `PalaceHandle` for
937 /// each, so recall paths see the full set immediately after a restart.
938 /// What: runs the blocking filesystem walk + per-palace `PalaceHandle::open`
939 /// on a `spawn_blocking` thread (so it never stalls the async runtime),
940 /// registers each successfully opened palace via `register_arc`, logs every
941 /// load at `debug!`, and returns the count loaded. A palace that fails to
942 /// open (corrupt index, unreadable `kg.db`, etc.) is logged at `warn!` and
943 /// skipped — one bad palace must not abort startup or crash the daemon.
944 /// `data_root` is expected to already be the palace registry directory —
945 /// `main.rs` resolves it via [`resolve_palace_registry_dir`] before
946 /// constructing the `AppState`, so the flat / legacy-`palaces/` layout
947 /// difference is handled exactly once.
948 /// Test: `tests::load_palaces_from_disk_rehydrates_registry` writes two
949 /// palaces into a tempdir, constructs an `AppState`, calls this method, and
950 /// asserts the returned count and registry contents.
951 pub async fn load_palaces_from_disk(&self) -> Result<usize> {
952 let registry_dir = self.data_root.clone();
953 let registry = self.registry.clone();
954 let palace_names = self.palace_names.clone();
955 // The directory walk and each `PalaceHandle::open` perform blocking
956 // filesystem + redb/usearch I/O — run the whole hydration on the
957 // blocking pool so it never parks an async worker thread.
958 let count = tokio::task::spawn_blocking(move || -> Result<usize> {
959 let palaces = PalaceRegistry::list_palaces(®istry_dir)?;
960 let total = palaces.len();
961 let mut loaded = 0usize;
962 let mut skipped = 0usize;
963 for palace in palaces {
964 match trusty_common::memory_core::PalaceHandle::open(&palace) {
965 Ok(handle) => {
966 tracing::debug!(
967 palace = %palace.id,
968 data_dir = %palace.data_dir.display(),
969 "loaded palace from disk"
970 );
971 // Issue #228: seed the in-memory name cache so write
972 // hot paths (memory_remember / memory_note) can resolve
973 // the friendly palace name without re-walking the data
974 // root. Insert here (during hydration) is the single
975 // point of truth for restart-time population.
976 palace_names.insert(palace.id.0.clone(), palace.name.clone());
977 registry.register_arc(handle);
978 loaded += 1;
979 }
980 Err(e) => {
981 // Why (issue #467): a single bad palace (corrupt kg.db,
982 // stale WAL, EMFILE — "Too many open files", permissions)
983 // must never abort startup or block the HTTP server from
984 // binding. Log per-palace and keep going; the summary
985 // below tells operators how many were skipped without
986 // trawling the log.
987 // The palace is NOT registered in the in-memory registry,
988 // so the next `open_palace` call for this id will attempt
989 // a fresh open from disk — the lazy-reopen path. If the
990 // root cause was EMFILE and the fd-limit fix (#462) raised
991 // the soft limit to 8192, that first request will succeed.
992 tracing::warn!(
993 palace = %palace.id,
994 data_dir = %palace.data_dir.display(),
995 "skipping palace during startup hydration: {e:#}; \
996 will retry lazily on first access"
997 );
998 skipped += 1;
999 }
1000 }
1001 }
1002 tracing::info!(
1003 "palace hydration summary: loaded {loaded}/{total} ({skipped} skipped due to errors)"
1004 );
1005 Ok(loaded)
1006 })
1007 .await
1008 .map_err(|e| anyhow::anyhow!("join load_palaces_from_disk: {e}"))??;
1009 Ok(count)
1010 }
1011
1012 /// Builder-style: attach the daemon's shared [`LogBuffer`] so the
1013 /// `GET /api/v1/logs/tail` endpoint serves the same lines the tracing
1014 /// subscriber captures (issue #35).
1015 ///
1016 /// Why: `main` builds the buffer (via `init_tracing_with_buffer`) before
1017 /// constructing the `AppState`, then hands a clone here so the HTTP
1018 /// handler and the tracing layer observe the same ring.
1019 /// What: replaces the empty default buffer with the supplied one.
1020 /// Test: `logs_tail_returns_recent_lines`.
1021 #[must_use]
1022 pub fn with_log_buffer(mut self, buffer: trusty_common::log_buffer::LogBuffer) -> Self {
1023 self.log_buffer = buffer;
1024 self
1025 }
1026
1027 /// Builder-style: attach the bug-capture `ErrorStore` handle (bug-reporting #478).
1028 ///
1029 /// Why: Phase 2 MCP / HTTP endpoints need a handle to the in-memory error
1030 /// ring so they can serve `recent_errors` / `errors_by_fingerprint`
1031 /// without disk I/O on the hot path. Installing it here — rather than
1032 /// adding it as a separate global — keeps the state graph explicit and
1033 /// lets tests skip it by never calling this method.
1034 /// What: stores `Some(store)` in `AppState::error_store`; the `BugCaptureLayer`
1035 /// that writes to this store is already installed in the tracing
1036 /// subscriber by `init_tracing_with_buffer_and_capture`. The store is
1037 /// `Clone` (cheap `Arc` clone internally) so both the layer and this
1038 /// field share the same underlying ring.
1039 /// Test: Phase 2 will add `error_store_captures_and_queries` in `web.rs`.
1040 #[must_use]
1041 pub fn with_error_store(mut self, store: trusty_common::error_capture::ErrorStore) -> Self {
1042 self.error_store = Some(store);
1043 self
1044 }
1045
1046 /// Send a `DaemonEvent` to all connected SSE subscribers and persist
1047 /// it to the activity log when the variant carries a source.
1048 ///
1049 /// Why: Mutating handlers call this after a successful write so the
1050 /// dashboard can update without polling. The send is best-effort —
1051 /// `broadcast::Sender::send` returns `Err` only when there are no live
1052 /// receivers, which is fine (no listeners == no work to do). Issue
1053 /// #96 additionally writes the entry to the persistent activity log
1054 /// so the feed can serve historical rows on page load and so MCP /
1055 /// HTTP / Hook origins are visible to the operator. Persistence is
1056 /// also best-effort — a write failure is logged but never blocks the
1057 /// SSE broadcast.
1058 ///
1059 /// Issue #232: the activity-log append is a synchronous redb write +
1060 /// fsync. Calling it directly on the async caller's task parked a tokio
1061 /// worker thread on disk I/O for every SSE event. We now offload the
1062 /// append to the blocking thread pool via `spawn_blocking` and return
1063 /// immediately — `emit` stays synchronous so every existing caller
1064 /// (including the sync `dispatch_hook_fired` JSON-RPC handler) keeps
1065 /// compiling unchanged. The fire-and-forget pattern matches the
1066 /// pre-fix semantics (best-effort, never blocks the SSE broadcast)
1067 /// while freeing the async runtime to do real work during the write.
1068 /// What: serialises the event for the log (skipping `StatusChanged`
1069 /// which is a recomputed aggregate, not a mutation), spawns the redb
1070 /// append on `tokio::task::spawn_blocking` keyed by a clone of the
1071 /// `Arc<ActivityLog>` and the cloned event, then sends the event over
1072 /// the broadcast channel. A `pending_activity_writes` counter is bumped
1073 /// before the spawn and decremented inside the closure so
1074 /// [`Self::flush_activity_writes`] can drain in tests.
1075 /// Test: `web::tests::sse_stream_receives_palace_created` confirms a
1076 /// subscriber observes the emitted event;
1077 /// `activity_endpoint_lists_recent_emits` confirms persistence via
1078 /// `flush_activity_writes`.
1079 pub fn emit(&self, event: DaemonEvent) {
1080 if let Some(source) = event.source() {
1081 let event_type = event.type_str();
1082 let palace_id = event.palace_id().map(|s| s.to_string());
1083 let log = Arc::clone(&self.activity_log);
1084 let event_for_log = event.clone();
1085 let pending = Arc::clone(&self.pending_activity_writes);
1086 // Pre-allocate the sequence id in the emitting thread so the
1087 // persisted order matches the emission order even when blocking-pool
1088 // workers execute the writes concurrently (issue #247). Without
1089 // this, four rapid emits would assign IDs inside their respective
1090 // `spawn_blocking` closures in a non-deterministic order.
1091 let id = log.alloc_id();
1092 pending.fetch_add(1, Ordering::SeqCst);
1093 // Why: the synchronous redb append + fsync must not park an
1094 // async worker thread (issue #232). Spawn the write on the
1095 // blocking pool; the JoinHandle is intentionally dropped —
1096 // the write is best-effort and any failure is logged below.
1097 tokio::task::spawn_blocking(move || {
1098 let result = log.append_with_id(id, source, palace_id, event_type, &event_for_log);
1099 if let Err(e) = result {
1100 tracing::warn!("activity_log.append failed for {event_type}: {e:#}");
1101 }
1102 pending.fetch_sub(1, Ordering::SeqCst);
1103 });
1104 }
1105 let _ = self.events.send(event);
1106 }
1107
1108 /// Block (asynchronously) until every in-flight activity-log write
1109 /// spawned by [`Self::emit`] has settled.
1110 ///
1111 /// Why: `emit` offloads its redb append to `tokio::task::spawn_blocking`
1112 /// and returns immediately (issue #232). Tests that observe the
1113 /// activity log right after a burst of emits would otherwise race the
1114 /// blocking-pool worker; this helper gives them a deterministic
1115 /// synchronization point. Production code never needs to call this —
1116 /// the dashboard reads through `GET /api/v1/activity`, which already
1117 /// tolerates writes settling asynchronously.
1118 /// What: spins on `pending_activity_writes` with a 1 ms yield until the
1119 /// counter is zero. Cheap: tests typically emit a handful of events
1120 /// and the loop exits within a single scheduler tick.
1121 /// Test: covered indirectly by `emit_persists_mutations_but_skips_status_changed`
1122 /// and `web::tests::activity_endpoint_lists_recent_emits`.
1123 pub async fn flush_activity_writes(&self) {
1124 while self.pending_activity_writes.load(Ordering::SeqCst) > 0 {
1125 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1126 }
1127 }
1128
1129 /// Open (or return cached) the chat-session store for a palace.
1130 ///
1131 /// Why: Chat session persistence lives in a dedicated SQLite file under
1132 /// the palace's data dir (`chat_sessions.db`) so it doesn't intermingle
1133 /// with the KG's transactional load. The store is cheap to clone via
1134 /// `Arc` but the underlying r2d2 pool should be reused, so cache by id.
1135 /// What: Creates the palace data dir if missing, opens (or reuses) a
1136 /// `ChatSessionStore` and stashes an `Arc` in the DashMap.
1137 /// Test: Indirectly via the session HTTP handlers in `web::tests`.
1138 pub fn session_store(&self, palace_id: &str) -> Result<Arc<ChatSessionStore>> {
1139 if let Some(entry) = self.session_stores.get(palace_id) {
1140 return Ok(entry.clone());
1141 }
1142 let dir = self.data_root.join(palace_id);
1143 std::fs::create_dir_all(&dir)
1144 .map_err(|e| anyhow::anyhow!("create palace dir {}: {e}", dir.display()))?;
1145 let store = Arc::new(ChatSessionStore::open(&dir.join("chat_sessions.db"))?);
1146 self.session_stores
1147 .insert(palace_id.to_string(), store.clone());
1148 Ok(store)
1149 }
1150
1151 /// Builder-style setter for the default palace name.
1152 ///
1153 /// Why: `serve --palace <name>` wants to bind every tool call to a
1154 /// project-scoped namespace without forcing every MCP request to repeat
1155 /// the palace argument.
1156 /// What: Returns `self` with `default_palace = Some(name)`.
1157 /// Test: `default_palace_used_when_arg_omitted` covers the resolution
1158 /// path; this setter is exercised there.
1159 pub fn with_default_palace(mut self, name: Option<String>) -> Self {
1160 self.default_palace = name;
1161 self
1162 }
1163
1164 /// Resolve (or initialize) the shared embedder.
1165 ///
1166 /// Why: FastEmbedder load is expensive — we share one instance across all
1167 /// tool calls; the `OnceCell` ensures concurrent first-use races collapse
1168 /// to a single load.
1169 /// What: Returns `Arc<FastEmbedder>` on success. Errors propagate from the
1170 /// underlying ONNX load.
1171 /// Test: Indirectly via `dispatch_remember_then_recall`.
1172 /// Resolve the active chat provider, auto-detecting on first call.
1173 ///
1174 /// Why: Provider selection depends on filesystem-loaded config plus a
1175 /// network probe (Ollama liveness), so it must be lazily initialised at
1176 /// runtime. Caching the choice in a `OnceCell` keeps it stable across
1177 /// concurrent requests without re-probing on every chat call.
1178 /// What: On first use loads `~/.trusty-memory/config.toml`, prefers an
1179 /// auto-detected Ollama instance (when `local_model.enabled`), and falls
1180 /// back to OpenRouter when an API key is set. Returns `Ok(None)` when
1181 /// neither is available so the caller can emit a 412.
1182 /// Test: `web::tests::providers_endpoint_returns_payload` covers the
1183 /// detection path indirectly through `/api/v1/chat/providers`.
1184 pub async fn chat_provider(&self) -> Option<Arc<dyn ChatProvider>> {
1185 self.chat_provider
1186 .get_or_init(|| async {
1187 // Why (issue #226): `service::load_user_config` is the
1188 // axum-free home of the loader; the `web::load_user_config`
1189 // re-export only exists for the HTTP handlers. Going
1190 // direct to `service` keeps this method usable when
1191 // the `axum-server` feature is disabled.
1192 let cfg = crate::service::load_user_config().unwrap_or_default();
1193 if cfg.local_model.enabled {
1194 if let Some(mut p) =
1195 trusty_common::auto_detect_local_provider(&cfg.local_model.base_url).await
1196 {
1197 // auto_detect returns an empty model id; callers must
1198 // set the configured model name themselves.
1199 p.model = cfg.local_model.model.clone();
1200 return Some(Arc::new(p) as Arc<dyn ChatProvider>);
1201 }
1202 }
1203 if !cfg.openrouter_api_key.is_empty() {
1204 return Some(Arc::new(trusty_common::OpenRouterProvider::new(
1205 cfg.openrouter_api_key,
1206 cfg.openrouter_model,
1207 )) as Arc<dyn ChatProvider>);
1208 }
1209 None
1210 })
1211 .await
1212 .clone()
1213 }
1214
1215 /// Spawn a fire-and-forget background task that auto-discovers project
1216 /// aliases under `project_root` and asserts new ones into `palace`.
1217 ///
1218 /// Why (issue #42): Projects carry implicit shorthand — cargo package
1219 /// names that differ from their directory, binary names that differ
1220 /// from packages, first-letter abbreviations — that should be surfaced
1221 /// without a user ever calling `add_alias`. Running discovery as a
1222 /// detached task on palace-open keeps startup latency unchanged: the
1223 /// daemon binds and starts serving immediately while the discovery scan
1224 /// completes in the background, and any newly-asserted aliases land in
1225 /// the prompt cache before the model's next `get_prompt_context` call.
1226 /// What: clones `self` (cheap; `Arc`-backed), spawns a tokio task that
1227 /// invokes the `discover_aliases` tool handler directly so the
1228 /// dedup + cache-rebuild logic runs exactly the same path as the MCP
1229 /// tool call. Errors are logged at `warn!`; one failed discovery never
1230 /// destabilises the daemon.
1231 /// Test: not unit-tested (timing-dependent fire-and-forget); the
1232 /// underlying `discover_aliases` dispatch is covered by
1233 /// `dispatch_discover_aliases_inserts_new_and_dedupes` in `tools::tests`.
1234 pub fn spawn_alias_discovery(&self, palace: String, project_root: PathBuf) {
1235 let state = self.clone();
1236 tokio::spawn(async move {
1237 let args = serde_json::json!({
1238 "palace": palace,
1239 "project_root": project_root.to_string_lossy(),
1240 });
1241 match tools::dispatch_tool(&state, "discover_aliases", args).await {
1242 Ok(result) => tracing::info!(
1243 new = ?result.get("new"),
1244 already_known = ?result.get("already_known"),
1245 "alias discovery complete"
1246 ),
1247 Err(e) => tracing::warn!("alias discovery failed: {e:#}"),
1248 }
1249 });
1250 }
1251
1252 /// Return the current readiness state.
1253 ///
1254 /// Why: tool handlers and the `/health` endpoint need a cheap, lock-free
1255 /// way to check whether the embedder has been initialised yet.
1256 /// What: loads `daemon_readiness` with `Acquire` ordering so the caller
1257 /// sees all writes the startup task made before setting the state.
1258 /// Test: `daemon_readiness_transitions_warming_to_ready`.
1259 pub fn readiness(&self) -> DaemonReadiness {
1260 DaemonReadiness::from_u8(self.daemon_readiness.load(Ordering::Acquire))
1261 }
1262
1263 /// Flip the readiness state from `Warming` to `Ready`.
1264 ///
1265 /// Why: called by `spawn_startup_tasks` in `main.rs` once the embedder
1266 /// warm-up succeeds — this is the single state-transition site.
1267 /// What: `store(Ready, Release)` so subsequent `Acquire` loads in handlers
1268 /// observe a consistent state. Idempotent: calling it multiple times is
1269 /// harmless.
1270 /// Test: `daemon_readiness_transitions_warming_to_ready`.
1271 pub fn set_ready(&self) {
1272 self.daemon_readiness
1273 .store(DaemonReadiness::Ready as u8, Ordering::Release);
1274 }
1275
1276 /// Return `Ok(())` when `Ready`, or an explicit `Err` with the warming
1277 /// message when still `Warming`.
1278 ///
1279 /// Why: the preflight in every bounded handler calls this and returns the
1280 /// error immediately so no embedding / redb I/O is attempted while the
1281 /// daemon is still initialising (tracks #911 internally).
1282 /// What: cheaply reads `daemon_readiness`; returns the fast error string
1283 /// on `Warming`. Zero allocation on the happy path.
1284 /// Test: covered by `tools::tests::remember_returns_warming_error_while_state_is_warming`.
1285 pub fn readiness_check(&self) -> Result<()> {
1286 if self.readiness() == DaemonReadiness::Warming {
1287 return Err(anyhow::anyhow!(
1288 "trusty-memory is warming up (embedder initialising); \
1289 please retry in a few seconds"
1290 ));
1291 }
1292 Ok(())
1293 }
1294
1295 /// Obtain the shared `FastEmbedder` instance, initialising it on first call.
1296 ///
1297 /// Why: centralises lazy embedder access so every tool handler goes through
1298 /// one bounded init path (tracks #910 internally).
1299 /// What: wraps `OnceCell::get_or_try_init` with a timeout so a slow
1300 /// CoreML/CUDA first-compile cannot block a handler indefinitely. On
1301 /// timeout the `OnceCell` is left unresolved and the next caller retries.
1302 ///
1303 /// **Callers on the request path MUST call `readiness_check()` before
1304 /// this method.** The four guarded handlers (`memory_remember`,
1305 /// `memory_recall`, `memory_recall_deep`, `memory_note`) do so; any new
1306 /// handler that calls `embedder()` must follow the same pattern.
1307 /// Reaching this method while still `Warming` is not a bug — the warm-up
1308 /// task itself calls `embedder()` while in `Warming` state — but request
1309 /// handlers should have short-circuited before here via `readiness_check()`.
1310 ///
1311 /// The `readiness_check()` preflight is the PRIMARY guard (fast rejection
1312 /// with no I/O). This timeout is the last-resort backstop in case a
1313 /// handler bypasses the preflight or the warm-up task itself hits a
1314 /// pathological init delay. If this timeout fires the `OnceCell` is left
1315 /// in the unresolved state and the next call retries from scratch.
1316 pub async fn embedder(&self) -> Result<Arc<FastEmbedder>> {
1317 use trusty_common::memory_core::timeouts;
1318 let cell = self.embedder.clone();
1319 let timeout = timeouts::embedder_init_timeout();
1320 // `readiness_check()` is the PRIMARY guard — handlers return a fast
1321 // warming error before reaching here. This timeout is the last-resort
1322 // backstop: if the embedder init races past the preflight (e.g. in the
1323 // warm-up task itself, which calls embedder() while still Warming) or
1324 // the CoreML/CUDA compile stalls, we fail fast rather than blocking
1325 // indefinitely. On timeout the OnceCell stays unresolved; the next
1326 // caller will retry the init from scratch.
1327 let embedder = tokio::time::timeout(
1328 timeout,
1329 cell.get_or_try_init(|| async {
1330 let e = FastEmbedder::new().await?;
1331 Ok::<Arc<FastEmbedder>, anyhow::Error>(Arc::new(e))
1332 }),
1333 )
1334 .await
1335 .map_err(|_| {
1336 anyhow::anyhow!(
1337 "AppState::embedder() timed out after {:?}; \
1338 the CoreML/CUDA model is taking unusually long to compile — \
1339 increase TRUSTY_EMBEDDER_INIT_TIMEOUT_SECS if needed",
1340 timeout
1341 )
1342 })??
1343 .clone();
1344 Ok(embedder)
1345 }
1346}
1347
1348impl std::fmt::Debug for AppState {
1349 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1350 f.debug_struct("AppState")
1351 .field("version", &self.version)
1352 .field("data_root", &self.data_root)
1353 .field("registry_len", &self.registry.len())
1354 .finish()
1355 }
1356}
1357
1358/// Handle a single MCP JSON-RPC message and produce its response.
1359///
1360/// Why: Pulled out of the stdio loop so unit tests can drive every method
1361/// without touching real stdin/stdout.
1362/// What: Routes `initialize`, `tools/list`, `tools/call`, `ping`, and the
1363/// `notifications/initialized` notification (which returns `Value::Null`).
1364/// Test: See unit tests below — initialize/list/call all return expected
1365/// JSON-RPC envelopes; notifications return `Null` (no response written).
1366pub async fn handle_message(state: &AppState, msg: Value) -> Value {
1367 let id = msg.get("id").cloned().unwrap_or(Value::Null);
1368 let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
1369
1370 match method {
1371 "initialize" => {
1372 let extra = state
1373 .default_palace
1374 .as_ref()
1375 .map(|dp| json!({ "default_palace": dp }));
1376 let result = initialize_response("trusty-memory", &state.version, extra);
1377 // Why (issue #42): prompt-facts now flow through the
1378 // per-message `get_prompt_context` tool rather than MCP
1379 // prompts, so we no longer advertise the `prompts` capability.
1380 json!({
1381 "jsonrpc": "2.0",
1382 "id": id,
1383 "result": result,
1384 })
1385 }
1386 // Notifications must NOT receive a response.
1387 "notifications/initialized" | "notifications/cancelled" => Value::Null,
1388 "tools/list" => json!({
1389 "jsonrpc": "2.0",
1390 "id": id,
1391 "result": tools::tool_definitions_with(state.default_palace.is_some())
1392 }),
1393 // OpenRPC 1.3.2 discovery — see `openrpc.rs`. Returns the full
1394 // service description so orchestrators (open-mpm, etc.) can
1395 // introspect every tool and its required `memory.read`/`memory.write`
1396 // scope without bespoke per-server adapters.
1397 "rpc.discover" => json!({
1398 "jsonrpc": "2.0",
1399 "id": id,
1400 "result": openrpc::build_discover_response(
1401 &state.version,
1402 state.default_palace.is_some(),
1403 ),
1404 }),
1405 "tools/call" => {
1406 let params = msg.get("params").cloned().unwrap_or_default();
1407 let tool_name = params
1408 .get("name")
1409 .and_then(|n| n.as_str())
1410 .unwrap_or("")
1411 .to_string();
1412 let args = params.get("arguments").cloned().unwrap_or_default();
1413 match tools::dispatch_tool(state, &tool_name, args).await {
1414 Ok(content) => {
1415 // Why: tools that return a bare JSON string (e.g.
1416 // `get_prompt_context` returning the formatted
1417 // Markdown block) should surface as plain text in the
1418 // MCP `content[0].text` field — wrapping in
1419 // `Value::to_string()` would re-quote the payload and
1420 // force every caller to strip outer quotes.
1421 let text = match &content {
1422 Value::String(s) => s.clone(),
1423 other => other.to_string(),
1424 };
1425 json!({
1426 "jsonrpc": "2.0",
1427 "id": id,
1428 "result": {
1429 "content": [{"type": "text", "text": text}]
1430 }
1431 })
1432 }
1433 Err(e) => json!({
1434 "jsonrpc": "2.0",
1435 "id": id,
1436 // Why: anyhow's `{:#}` alternate format walks the full
1437 // `Caused by:` chain so MCP clients see actionable
1438 // detail (e.g. "PalaceHandle::remember_with_options:
1439 // filter rejected: too short") instead of just the
1440 // outermost context label.
1441 "error": {"code": -32603, "message": format!("{e:#}")}
1442 }),
1443 }
1444 }
1445 "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
1446 _ => json!({
1447 "jsonrpc": "2.0",
1448 "id": id,
1449 "error": {
1450 "code": -32601,
1451 "message": format!("Method not found: {method}")
1452 }
1453 }),
1454 }
1455}
1456
1457/// Preferred starting port for the trusty-memory HTTP daemon.
1458///
1459/// Why: keeps the well-known default stable for clients that have hard-coded
1460/// `127.0.0.1:7070` in their configuration, while still allowing dynamic
1461/// walking when the port is in use (`DYNAMIC_PORT_RANGE` ports starting here).
1462/// What: `7070` — historic default, matches the launchd plist's prior value.
1463/// Test: covered indirectly by `bind_dynamic_port_returns_listener`.
1464pub const DEFAULT_HTTP_PORT: u16 = 7070;
1465
1466/// Number of consecutive ports `bind_dynamic_port` walks before falling back
1467/// to the OS-assigned port. Matches the trusty-search convention.
1468const DYNAMIC_PORT_RANGE: u16 = 10;
1469
1470/// Path to the canonical address-discovery file for the trusty-memory daemon.
1471///
1472/// Why: clients (CLI, MCP tools, dashboards) need to find the running daemon
1473/// without configuration when the port was selected dynamically. Using
1474/// `trusty_common::resolve_data_dir` aligns this path with the location
1475/// that `trusty_common::read_daemon_addr("trusty-memory")` reads from, so
1476/// `prompt-context`, `doctor`, and `start`'s probe all find the running daemon.
1477/// The old `~/.trusty-memory/http_addr` path and the new
1478/// `~/Library/Application Support/trusty-memory/http_addr` (macOS) path were
1479/// divergent — the daemon wrote one; readers expected the other.
1480/// What: returns `{resolve_data_dir("trusty-memory")}/http_addr`, or `None` if
1481/// the data dir cannot be resolved (locked-down container, no passwd entry).
1482/// Test: `http_addr_path_uses_resolve_data_dir`.
1483pub fn http_addr_path() -> Option<PathBuf> {
1484 trusty_common::resolve_data_dir("trusty-memory")
1485 .ok()
1486 .map(|d| d.join("http_addr"))
1487}
1488
1489/// Bind a `TcpListener` to `127.0.0.1`, dynamically selecting a port.
1490///
1491/// Why: the historic default `7070` is convenient for clients but a stale
1492/// process or a second daemon must not produce a noisy failure. Walking
1493/// `DEFAULT_HTTP_PORT..DEFAULT_HTTP_PORT+DYNAMIC_PORT_RANGE` first preserves
1494/// backwards compatibility for the common case; OS-assigned fallback (`:0`)
1495/// guarantees the daemon always comes up even when every preferred port is
1496/// busy.
1497/// What: returns the first successful `TcpListener` (7070..=7079, then
1498/// OS-assigned); caller inspects `local_addr()` to learn the chosen port.
1499/// Test: `bind_dynamic_port_returns_listener` confirms it always binds *some*
1500/// port even after another listener occupies the preferred one.
1501pub async fn bind_dynamic_port() -> Result<tokio::net::TcpListener> {
1502 let preferred: SocketAddr = SocketAddr::from(([127, 0, 0, 1], DEFAULT_HTTP_PORT));
1503 // First: walk the preferred range (7070..=7079).
1504 if let Ok(listener) =
1505 trusty_common::bind_with_auto_port(preferred, DYNAMIC_PORT_RANGE - 1).await
1506 {
1507 return Ok(listener);
1508 }
1509 // Last resort: ask the kernel for any free port. `bind_with_auto_port`
1510 // with `:0` resolves immediately to the OS-assigned port.
1511 tracing::warn!(
1512 "all ports {DEFAULT_HTTP_PORT}..{} in use; requesting OS-assigned port",
1513 DEFAULT_HTTP_PORT + DYNAMIC_PORT_RANGE - 1
1514 );
1515 let any: SocketAddr = SocketAddr::from(([127, 0, 0, 1], 0));
1516 trusty_common::bind_with_auto_port(any, 0).await
1517}
1518
1519/// Write the bound `host:port` to `~/.trusty-memory/http_addr` atomically.
1520///
1521/// Why: clients must read the file mid-write without observing a partial
1522/// value. Writing to a `.tmp` sibling and renaming over the target gives
1523/// POSIX atomicity, matching the trusty-search implementation.
1524/// What: creates the parent directory if missing; writes `addr` followed by a
1525/// trailing newline (avoids the "no newline at end of file" warnings from
1526/// `cat`); renames `.tmp` → `http_addr`. Best-effort: I/O errors are
1527/// returned to the caller so `run_http_on` can log without panicking.
1528/// Test: `http_addr_file_round_trip_via_helpers`.
1529#[cfg(feature = "axum-server")]
1530fn write_http_addr_file(path: &Path, addr: &SocketAddr) -> std::io::Result<()> {
1531 use std::io::Write;
1532 if let Some(parent) = path.parent() {
1533 std::fs::create_dir_all(parent)?;
1534 }
1535 let tmp = path.with_extension("addr.tmp");
1536 {
1537 let mut f = std::fs::File::create(&tmp)?;
1538 writeln!(f, "{addr}")?;
1539 f.sync_all()?;
1540 }
1541 std::fs::rename(&tmp, path)?;
1542 Ok(())
1543}
1544
1545/// Return `true` when a non-default data directory is in effect.
1546///
1547/// Why (issue #880): two startup side-effects must be suppressed when the
1548/// daemon runs with an isolated/overridden data root:
1549/// 1. The legacy `~/.trusty-memory/http_addr` dotfile write — it would
1550/// overwrite the real production daemon's discovery file with the isolated
1551/// instance's throwaway address.
1552/// 2. The startup pin-scan — it reads project pin files from the **real**
1553/// user environment (~/Projects, ~/Developer, …) and imports palaces from
1554/// the real environment into the isolated data root, defeating isolation.
1555///
1556/// A "non-default data dir" means `TRUSTY_DATA_DIR_OVERRIDE` is set to a
1557/// non-empty, non-whitespace value. Empty or whitespace-only values are
1558/// treated as unset (same rule as `resolve_data_dir`), so an accidental blank
1559/// env var does not suppress the dotfile write on real production instances.
1560/// What: reads `TRUSTY_DATA_DIR_OVERRIDE`; returns `true` when it contains a
1561/// non-empty, non-whitespace string. Returns `false` otherwise.
1562/// Test: `is_data_dir_override_active_when_set`,
1563/// `is_data_dir_override_inactive_when_unset`,
1564/// `is_data_dir_override_inactive_when_blank`.
1565#[inline]
1566pub fn is_data_dir_override_active() -> bool {
1567 matches!(
1568 std::env::var(trusty_common::DATA_DIR_OVERRIDE_ENV),
1569 Ok(v) if !v.trim().is_empty()
1570 )
1571}
1572
1573/// Resolve the dotfile discovery path `~/.trusty-memory/http_addr`.
1574///
1575/// Why (issue #498): external tooling such as claude-mpm's `migrate_trusty_autodetect`
1576/// reads `~/.trusty-memory/http_addr` to find the running daemon's port. On
1577/// macOS, `resolve_data_dir("trusty-memory")` returns
1578/// `~/Library/Application Support/trusty-memory/`, not `~/.trusty-memory/`,
1579/// so the daemon was writing to the OS-standard location while readers expected
1580/// the dotfile location. Writing to both locations keeps every reader happy
1581/// regardless of which convention they follow.
1582///
1583/// Fix #880: returns `None` when `TRUSTY_DATA_DIR_OVERRIDE` is active so an
1584/// isolated instance (test rig, CI, parallel run) never overwrites the real
1585/// production daemon's discovery dotfile.
1586///
1587/// What: returns `$HOME/.trusty-memory/http_addr` in the default (production)
1588/// case, or `None` when `dirs::home_dir()` is unavailable OR when a data-dir
1589/// override is active (see `is_data_dir_override_active`).
1590/// Test: `dotfile_http_addr_path_uses_home_dir`,
1591/// `dotfile_suppressed_when_override_active`.
1592#[cfg(feature = "axum-server")]
1593fn dotfile_http_addr_path() -> Option<PathBuf> {
1594 // Fix #880: never write to the shared dotfile when an override is active.
1595 if is_data_dir_override_active() {
1596 return None;
1597 }
1598 dirs::home_dir().map(|h| h.join(".trusty-memory").join("http_addr"))
1599}
1600
1601/// Run the optional HTTP/SSE + web admin server.
1602///
1603/// Why: A long-running daemon mode lets non-stdio clients (browsers, curl,
1604/// future remote agents) hit `/health`, the `/api/v1/*` REST surface, and the
1605/// embedded admin SPA. The Unix-domain-socket transport and the
1606/// `trusty-memory-mcp-bridge` binary were removed in PR3 of the #914
1607/// stdio-cutover epic; the canonical MCP integration is now
1608/// `trusty-memory serve --stdio` (PR1 #919).
1609/// What: axum router built from `web::router()` plus a `/sse` stub for the
1610/// existing MCP-over-SSE clients. Caller provides a pre-bound listener so
1611/// port auto-detection lives at the call site. Before accepting connections
1612/// the daemon stamps the bound `host:port` onto `AppState.bound_addr` and
1613/// writes `~/.trusty-memory/http_addr` so clients can discover the live port.
1614/// On shutdown the file is removed best-effort (a stale file with the wrong
1615/// port is worse than a missing one).
1616/// Test: `cargo test -p trusty-memory web::tests` exercises the router shape;
1617/// manual: `curl http://127.0.0.1:<port>/health` returns `ok` with `addr`.
1618#[cfg(feature = "axum-server")]
1619pub async fn run_http_on(state: AppState, listener: tokio::net::TcpListener) -> Result<()> {
1620 use axum::routing::get;
1621
1622 // Issue #35: recompute the `data_root` disk footprint every 10 s on a
1623 // background task so `GET /health` reports `disk_bytes` without doing a
1624 // recursive directory walk on the request path.
1625 spawn_disk_size_ticker(state.clone());
1626
1627 // Issue #228: emit aggregate `StatusChanged` on a fixed cadence rather
1628 // than on every drawer write. The previous design called
1629 // `aggregate_status_event` from every `memory_remember` / `memory_note`
1630 // / `memory_forget` (and the matching HTTP handlers), each of which
1631 // walked the data root + opened every palace handle. Coalescing the
1632 // emit to a 30 s ticker keeps dashboards live without dragging an
1633 // O(N palaces) recompute onto the write hot path.
1634 spawn_status_event_ticker(state.clone());
1635
1636 // Capture and advertise the bound address BEFORE serving so the first
1637 // request handler — and the http_addr discovery file — see the real port
1638 // even if `local_addr()` would otherwise be racy.
1639 let local = listener.local_addr().ok();
1640 let (written_path, written_dotfile_path) = if let Some(a) = local {
1641 // Stash on state for handlers (e.g. /health) to surface.
1642 let _ = state.bound_addr.set(a);
1643 info!("HTTP server listening on http://{a}");
1644 eprintln!("HTTP server listening on http://{a}");
1645 // Primary: write to the OS-standard data dir (`~/Library/Application
1646 // Support/trusty-memory/http_addr` on macOS, `~/.local/share/…` on
1647 // Linux). This is what `trusty_common::read_daemon_addr` reads.
1648 // Best-effort: a missing $HOME or read-only fs is non-fatal.
1649 let primary = match http_addr_path() {
1650 Some(p) => match write_http_addr_file(&p, &a) {
1651 Ok(()) => {
1652 info!("wrote daemon address to {}", p.display());
1653 Some(p)
1654 }
1655 Err(e) => {
1656 tracing::warn!("could not write {}: {e}", p.display());
1657 None
1658 }
1659 },
1660 None => {
1661 tracing::warn!("no $HOME — skipping http_addr discovery file");
1662 None
1663 }
1664 };
1665 // Issue #498: also write to `~/.trusty-memory/http_addr` so external
1666 // tools (e.g. claude-mpm's `migrate_trusty_autodetect`) that read the
1667 // dotfile path can discover the daemon's port. On macOS the OS-standard
1668 // path differs from the dotfile path; writing both ensures consumers
1669 // using either convention find the file. Best-effort: failures are
1670 // logged but do not block startup.
1671 let dotfile = match dotfile_http_addr_path() {
1672 Some(p) => match write_http_addr_file(&p, &a) {
1673 Ok(()) => {
1674 info!("wrote daemon address to dotfile {}", p.display());
1675 Some(p)
1676 }
1677 Err(e) => {
1678 tracing::warn!("could not write dotfile {}: {e}", p.display());
1679 None
1680 }
1681 },
1682 None => None,
1683 };
1684 (primary, dotfile)
1685 } else {
1686 (None, None)
1687 };
1688
1689 // Keep a handle to the BM25 supervisor (if any) so we can call
1690 // `shutdown()` on the exit path. Cloning here is cheap (`Arc`) and
1691 // detaches the lifetime of the supervisor from the `state` move into
1692 // the router below.
1693 let bm25_supervisor = state.bm25_supervisor.clone();
1694
1695 let app = web::router()
1696 .route("/sse", get(sse_handler))
1697 .with_state(state);
1698
1699 // Why (issue #534): bare axum::serve exits only on an internal error; SIGTERM
1700 // (launchctl bootout) would kill the process before the cleanup below had a
1701 // chance to run, leaving stale addr/socket files behind and dropping any
1702 // in-flight request without draining. `with_graceful_shutdown` installs a
1703 // SIGTERM + SIGINT watcher; when either fires axum stops accepting new
1704 // connections, drains active requests, then returns here so cleanup runs.
1705 let serve_result = axum::serve(listener, app)
1706 .with_graceful_shutdown(trusty_common::shutdown_signal())
1707 .await;
1708
1709 // Best-effort cleanup: remove `http_addr` files so stale clients fail fast
1710 // instead of timing out against a dead port. Remove both the OS-standard
1711 // path and the dotfile path (#498).
1712 if let Some(p) = written_path.as_ref() {
1713 let _ = std::fs::remove_file(p);
1714 }
1715 if let Some(p) = written_dotfile_path.as_ref() {
1716 let _ = std::fs::remove_file(p);
1717 }
1718
1719 // Issue #193: gracefully reap every spawned BM25 daemon before the
1720 // process exits so each one gets a chance to flush its snapshot and
1721 // unlink its socket. `kill_on_drop=true` on the children would
1722 // SIGKILL them on Drop anyway, but that skips the daemon's own
1723 // shutdown sequence and leaves stale sockets behind.
1724 if let Some(supervisor) = bm25_supervisor {
1725 supervisor.shutdown().await;
1726 }
1727
1728 serve_result?;
1729 Ok(())
1730}
1731
1732/// Convenience: bind `addr` and serve via [`run_http_on`].
1733#[cfg(feature = "axum-server")]
1734pub async fn run_http(state: AppState, addr: std::net::SocketAddr) -> Result<()> {
1735 let listener = tokio::net::TcpListener::bind(addr).await?;
1736 run_http_on(state, listener).await
1737}
1738
1739/// Convenience: bind dynamically (7070..=7079, OS fallback) and serve.
1740///
1741/// Why: `trusty-memory serve` with no `--http` flag is the canonical
1742/// launchd-managed daemon entry point. Dynamic binding lets a stale daemon
1743/// or a hand-spawned `serve --http 127.0.0.1:7070` coexist without breaking
1744/// the launchd-managed instance.
1745/// What: calls [`bind_dynamic_port`] then [`run_http_on`].
1746/// Test: integration via `trusty-memory serve` + `cat ~/.trusty-memory/http_addr`.
1747#[cfg(feature = "axum-server")]
1748pub async fn run_http_dynamic(state: AppState) -> Result<()> {
1749 let listener = bind_dynamic_port().await?;
1750 run_http_on(state, listener).await
1751}
1752
1753/// Spawn a background ticker that recomputes the `data_root` disk footprint
1754/// every 10 seconds and stores it in `state.disk_bytes` (issue #35).
1755///
1756/// Why: `GET /health` reports `disk_bytes`. Walking the data directory on
1757/// every health request would turn a frequent health poll into unbounded
1758/// recursive I/O. Computing it off the request path on a fixed cadence keeps
1759/// `/health` cheap and bounds the staleness to ~10 s — fine for an
1760/// at-a-glance footprint figure.
1761/// What: spawns a detached tokio task. `AppState` is cheap to `Clone` (all
1762/// `Arc` fields), so the task holds a full clone; the daemon process lives
1763/// for the lifetime of the server anyway, so no `Weak` downgrade is needed.
1764/// Each tick runs the blocking directory walk on `spawn_blocking` so it never
1765/// stalls the async runtime, then stores the byte total atomically.
1766/// Test: `health_endpoint_includes_resource_fields` asserts the field shape;
1767/// the ticker cadence is not unit-tested (timing-dependent).
1768#[cfg(feature = "axum-server")]
1769fn spawn_disk_size_ticker(state: AppState) {
1770 tokio::spawn(async move {
1771 let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
1772 loop {
1773 interval.tick().await;
1774 let dir = state.data_root.clone();
1775 // The directory walk is blocking filesystem I/O — run it on the
1776 // blocking pool so it never parks an async worker thread.
1777 let bytes = tokio::task::spawn_blocking(move || {
1778 trusty_common::sys_metrics::dir_size_bytes(&dir)
1779 })
1780 .await
1781 .unwrap_or(0);
1782 state
1783 .disk_bytes
1784 .store(bytes, std::sync::atomic::Ordering::Relaxed);
1785 }
1786 });
1787}
1788
1789/// Interval between aggregate-status snapshot emits on the SSE bus.
1790///
1791/// Why (issue #228): mutations used to fire `StatusChanged` synchronously on
1792/// the write path, which forced an O(N palaces) sum of drawer / vector / KG
1793/// counts on every `memory_remember`. Coalescing into a fixed-cadence ticker
1794/// lets dashboards stay current (a 30 s lag is invisible at human scale)
1795/// while keeping the write path free of aggregate work.
1796/// What: 30 seconds — short enough that the operator UI doesn't feel stale
1797/// between manual writes, long enough that the recompute cost (in-memory
1798/// registry walk plus the redb `count_active_triples` per palace) is a
1799/// rounding error on the daemon's CPU budget.
1800/// Test: covered indirectly — the math has not changed, only the cadence.
1801#[allow(dead_code)]
1802const STATUS_EVENT_TICK_SECS: u64 = 30;
1803
1804/// Spawn a background ticker that emits `DaemonEvent::StatusChanged` every
1805/// [`STATUS_EVENT_TICK_SECS`] seconds (issue #228).
1806///
1807/// Why: replaces the per-write `state.emit(self.aggregate_status_event())`
1808/// call sites that used to recompute the aggregate every time a drawer was
1809/// created or deleted. Walking N palaces on every write blocks the async
1810/// runtime; coalescing the emit onto a ticker keeps dashboards up-to-date
1811/// without that cost.
1812/// What: spawns a detached tokio task that holds a full `AppState` clone
1813/// (cheap — every field is `Arc`-backed) and ticks every
1814/// [`STATUS_EVENT_TICK_SECS`] seconds. Each tick computes
1815/// `MemoryService::aggregate_status_event` (which now iterates the
1816/// in-memory registry, not disk) and broadcasts it via `state.emit`. If
1817/// no SSE subscribers are connected the broadcast `send` is a cheap no-op,
1818/// so the ticker imposes no cost when nobody is listening.
1819/// Test: not unit-tested (timing-dependent fire-and-forget); the underlying
1820/// `aggregate_status_event` math is exercised by the existing
1821/// `status_endpoint_returns_payload` path.
1822#[allow(dead_code)]
1823fn spawn_status_event_ticker(state: AppState) {
1824 tokio::spawn(async move {
1825 let mut interval =
1826 tokio::time::interval(std::time::Duration::from_secs(STATUS_EVENT_TICK_SECS));
1827 // The first tick fires immediately, which is fine: it gives SSE
1828 // subscribers a baseline `StatusChanged` shortly after they connect.
1829 loop {
1830 interval.tick().await;
1831 let event = service::MemoryService::new(state.clone()).aggregate_status_event();
1832 state.emit(event);
1833 }
1834 });
1835}
1836
1837/// Live SSE event stream — pushes `DaemonEvent` frames to dashboard clients.
1838///
1839/// Why: The dashboard subscribes once and reacts to live pushes (palace
1840/// created, drawer added/deleted, dream completed, status changed) instead of
1841/// polling `/api/v1/*` endpoints.
1842/// What: Subscribes to `state.events`, emits an initial `connected` frame,
1843/// then forwards every `DaemonEvent` as `data: <json>\n\n`. Lagged
1844/// subscribers receive a `lag` frame indicating skipped events; channel
1845/// closure ends the stream.
1846/// Test: `web::tests::sse_stream_emits_palace_created` (covers subscribe +
1847/// emit + receive); manual: `curl -N http://.../sse`.
1848#[cfg(feature = "axum-server")]
1849pub(crate) async fn sse_handler(
1850 axum::extract::State(state): axum::extract::State<AppState>,
1851) -> impl axum::response::IntoResponse {
1852 use futures::StreamExt;
1853 use tokio_stream::wrappers::BroadcastStream;
1854
1855 let rx = state.events.subscribe();
1856 let initial = futures::stream::once(async {
1857 Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(
1858 "data: {\"type\":\"connected\"}\n\n",
1859 ))
1860 });
1861 let events = BroadcastStream::new(rx).map(|res| {
1862 let frame = match res {
1863 Ok(event) => match serde_json::to_string(&event) {
1864 Ok(json) => format!("data: {json}\n\n"),
1865 Err(e) => format!("data: {{\"type\":\"error\",\"message\":\"{e}\"}}\n\n"),
1866 },
1867 Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => {
1868 format!("data: {{\"type\":\"lag\",\"skipped\":{n}}}\n\n")
1869 }
1870 };
1871 Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(frame))
1872 });
1873 let stream = initial.chain(events);
1874
1875 axum::response::Response::builder()
1876 .header("Content-Type", "text/event-stream")
1877 .header("Cache-Control", "no-cache")
1878 .header("X-Accel-Buffering", "no")
1879 .body(axum::body::Body::from_stream(stream))
1880 .expect("valid SSE response") // Why: invariant — SSE headers are compile-time constants; builder cannot fail
1881}
1882
1883#[cfg(test)]
1884mod lib_tests;