pub struct AppState {Show 21 fields
pub version: String,
pub registry: Arc<PalaceRegistry>,
pub data_root: PathBuf,
pub embedder: Arc<OnceCell<Arc<FastEmbedder>>>,
pub default_palace: Option<String>,
pub chat_provider: Arc<OnceCell<Option<Arc<dyn ChatProvider>>>>,
pub session_stores: Arc<DashMap<String, Arc<ChatSessionStore>>>,
pub events: Arc<Sender<DaemonEvent>>,
pub started_at: Instant,
pub log_buffer: LogBuffer,
pub disk_bytes: Arc<AtomicU64>,
pub sys_metrics: Arc<Mutex<SysMetrics>>,
pub bound_addr: Arc<OnceLock<SocketAddr>>,
pub prompt_context_cache: Arc<RwLock<PromptFactsCache>>,
pub activity_log: Arc<ActivityLog>,
pub bm25_client: Option<Arc<Bm25Client>>,
pub bm25_supervisor: Option<Arc<Bm25Supervisor>>,
pub palace_write_locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
pub pending_activity_writes: Arc<AtomicUsize>,
pub palace_names: Arc<DashMap<String, String>>,
pub bm25_index_tx: Sender<Bm25IndexRequest>,
}Expand description
Shared application state passed to every request handler.
Why: The stdio loop and HTTP server need the same handles to the registry,
data root, and embedder so MCP tools can perform real reads/writes against
the live trusty-memory core. The embedder is heavy (loads ONNX weights) so
we hold it behind a OnceCell and initialize lazily on first use.
What: Clone-able via Arc fields. The registry / data root are eager;
embedder is Arc<OnceCell<Arc<FastEmbedder>>> so concurrent first-use
races resolve to a single shared instance.
Test: app_state_default_constructs confirms construction without panic.
Fields§
§version: String§registry: Arc<PalaceRegistry>§data_root: PathBuf§embedder: Arc<OnceCell<Arc<FastEmbedder>>>§default_palace: Option<String>Optional default palace applied to MCP tool calls when the caller
omits the palace argument. Set via trusty-memory serve --palace.
chat_provider: Arc<OnceCell<Option<Arc<dyn ChatProvider>>>>Active chat provider selected at startup. None means no upstream is
configured (no Ollama detected and no OpenRouter key) — callers must
degrade gracefully (chat endpoint returns 412).
session_stores: Arc<DashMap<String, Arc<ChatSessionStore>>>Per-palace chat-session stores, opened lazily so cold-start cost is paid only when chat-history endpoints are hit.
events: Arc<Sender<DaemonEvent>>Broadcast sender for live DaemonEvent pushes to SSE subscribers.
Why: Lets mutating handlers emit events that any connected dashboard
receives instantly. Cap of 128 buffers transient slow readers; if a
receiver lags it gets RecvError::Lagged and we emit a lag frame.
started_at: InstantInstant the daemon started, used to compute uptime_secs on /health.
Why (issue #35): GET /health reports how long the daemon has been
up. Capturing a monotonic Instant at AppState construction lets the
handler compute the elapsed seconds cheaply and without a clock-skew
hazard.
What: a wall-monotonic Instant; AppState::new stamps it at startup.
Test: health_endpoint_includes_resource_fields.
log_buffer: LogBufferIn-memory ring buffer of recent tracing log lines (issue #35).
Why: the GET /api/v1/logs/tail endpoint serves the last N log lines
so operators can inspect a running daemon without tailing a file. The
buffer is shared between the tracing LogBufferLayer (writer) and the
HTTP handler (reader).
What: a cheap Arc-backed clone of the buffer the subscriber writes
to. Defaults to an empty buffer for states that never install the
layer (tests, the stdio path).
Test: logs_tail_returns_recent_lines.
disk_bytes: Arc<AtomicU64>Most recent on-disk footprint of data_root, in bytes (issue #35).
Why: GET /health reports disk_bytes. Walking the data directory on
every health request would make a frequent health poll do unbounded
I/O; a background task recomputes it every 10 s and stores it here so
the handler reads it lock-free.
What: an AtomicU64 updated by the ticker spawned in run_http_on.
0 until the first walk completes.
Test: health_endpoint_includes_resource_fields.
sys_metrics: Arc<Mutex<SysMetrics>>Per-process RSS + CPU sampler, refreshed on each /health request
(issue #35).
Why: CPU usage is a delta between two sysinfo refreshes, so the
sampler must persist between requests — hence the shared Mutex.
What: a tokio::sync::Mutex<SysMetrics> so the async health handler
can sample without blocking the runtime.
Test: health_endpoint_includes_resource_fields.
bound_addr: Arc<OnceLock<SocketAddr>>HTTP listener address the daemon bound to, once run_http_on is running.
Why: clients (and /health responses) need to advertise the live
host:port even though port selection happens dynamically (7070–7079
walk + OS fallback). Stashing it on AppState lets request handlers
surface the discovery value without re-querying the listener.
What: a OnceLock<SocketAddr> so run_http_on writes it exactly once
at bind time and every handler reads it lock-free thereafter. Empty
(None from get()) on the stdio path where no listener exists.
Test: health_endpoint_reports_bound_addr (added below).
prompt_context_cache: Arc<RwLock<PromptFactsCache>>Cached prompt-facts surface served by the MCP get_prompt_context
tool (issue #42).
Why: The original session-init prompts/get design loaded context
once per connection; switching to a per-message tool lets the model
pull fresh, query-filtered context on demand. The cache holds both
the raw triples (for filtered lookups) and a pre-formatted Markdown
block (for the unfiltered hot path) so neither code path re-walks
the KG. The cache is rebuilt by
prompt_facts::rebuild_prompt_cache after any write that touches a
hot predicate (kg_assert, add_alias, remove_prompt_fact).
What: An Arc<tokio::sync::RwLock<PromptFactsCache>> so the hot
read path takes a brief read lock and clones the cache; rebuilds
take a write lock for the assignment only. The async-aware lock
(issue #229) yields to the tokio runtime instead of blocking a
runtime thread for the rebuild duration. An empty triples vec ↔
“no context stored yet” (the tool handler renders a hint).
Test: get_prompt_context_returns_cached_or_hint,
get_prompt_context_filters_by_query.
activity_log: Arc<ActivityLog>Persistent activity log (issue #96).
Why: the dashboard activity feed used to be a pure live-stream over
/sse — opening the UI showed an empty feed and any mutation from
the MCP path was invisible. Holding an ActivityLog on AppState
lets emit record an entry on every push so the
GET /api/v1/activity handler can return historical rows on mount
and the live SSE stream can continue prepending events on top of
the loaded history. None on builds that opt out (tests that use
AppState::new get a real log under their tempdir so behaviour
matches production).
What: an Arc<ActivityLog> shared with every emitter.
Test: web::tests::activity_endpoint_lists_recent_emits.
bm25_client: Option<Arc<Bm25Client>>Optional per-palace BM25 lexical search lane (issue #156).
Why: in-process BM25 would serialise the recall hot path on disk
I/O during writes and contend with the redb/usearch locks. Delegating
to the trusty-bm25-daemon subprocess (one socket per palace) keeps
BM25 ingestion and search off the critical path while still feeding
hits into the recall RRF fusion.
What: Some(client) only when TRUSTY_BM25_DAEMON=1 at startup —
every code path that uses this field is gated on is_some() and
falls back to vector-only behaviour otherwise so existing deployments
see zero behavioural change.
Test: bm25_client_disabled_by_default,
bm25_client_enabled_when_env_set.
bm25_supervisor: Option<Arc<Bm25Supervisor>>Optional per-palace BM25 daemon spawn supervisor (issue #193).
Why: without an in-process supervisor the BM25 daemon must be
launched out-of-band (launchd, manual trusty-bm25-daemon), which
is the same UX trap PR #190 fixed for trusty-embedderd. Holding a
supervisor here lets us spawn the daemon on first BM25 use for a
palace, restart it if it dies, and reap it on clean shutdown.
Some only when TRUSTY_BM25_DAEMON=1 at startup — the same gate
that enables bm25_client. When set but TRUSTY_BM25_EXTERNAL=1,
the supervisor’s ensure_running becomes a no-op that just returns
the canonical socket path so operators can keep using their own
process manager.
Test: covered by bm25_supervisor_present_when_env_set and the
bm25_supervisor::tests unit tests.
palace_write_locks: Arc<DashMap<String, Arc<Mutex<()>>>>Per-palace write serialisation locks (issue #230).
Why: the dedup gate in tools.rs previously read a snapshot of
existing drawers, checked for near-duplicates via Jaro-Winkler, and
then issued the write — a classic time-of-check/time-of-use race.
Two concurrent memory_remember calls with the same content could
both see the pre-write snapshot, both pass the gate, and both land
duplicate drawers. Serialising the gate-then-write sequence per
palace closes the window: while one task holds the mutex, any
concurrent writer for the same palace blocks until the first write
finishes and is visible to list_drawers. The lock is per
palace (not global) so writes to different palaces continue to
run in parallel.
What: a DashMap keyed by palace id, where each entry is an
Arc<tokio::sync::Mutex<()>>. The mutex is constructed lazily by
palace_write_lock on first access. Arc lets callers hold a
clone of the lock past the lifetime of the DashMap entry so the
map never needs to be held across an .await.
Test: tools::tests::dedup_gate_blocks_concurrent_duplicate_writes.
pending_activity_writes: Arc<AtomicUsize>Counter of in-flight activity-log writes spawned by emit
(issue #232).
Why: emit offloads the synchronous redb append to the tokio blocking
pool via spawn_blocking so the async runtime is never parked waiting
on fsync. The write is fire-and-forget — emit returns immediately
after spawning. Tests that observe the activity log right after a
burst of emit calls need a deterministic synchronization point;
holding an in-flight counter lets flush_activity_writes poll until
every spawned append has settled, which keeps the assertions
race-free without forcing every caller to .await.
What: an Arc<AtomicUsize> incremented before each spawn_blocking
and decremented inside the closure (after the append completes, even
if it errored). The counter is cheap (one atomic add per emit) and
stays at zero in steady-state production traffic.
Test: web::tests::activity_endpoint_lists_recent_emits and
tests::emit_persists_mutations_but_skips_status_changed call
flush_activity_writes to drain the counter before reading the log.
palace_names: Arc<DashMap<String, String>>In-memory cache mapping palace id → Palace.name (issue #228).
Why: every memory_remember / memory_note write used to call
PalaceRegistry::list_palaces (a synchronous filesystem walk of the
data root) just to resolve a friendly palace name for the SSE
DrawerAdded event. With N palaces on disk the cost was O(N) opendirs
plus palace.json reads on every write, blocking the async runtime.
Caching the name in-memory turns the lookup into a DashMap::get.
What: DashMap<String, String> populated by create_palace and
load_palaces_from_disk, kept in sync by rename / delete paths.
Missing entries are treated as “name unknown” so callers fall back to
the palace id and the emit path never fails.
Test: palace_name_cache_populated_after_hydration and
palace_name_cache_updates_on_create.
bm25_index_tx: Sender<Bm25IndexRequest>Bounded sender for the BM25 index worker (issue #231).
Why: the previous fire-and-forget design tokio::spawned one task per
memory_remember / memory_note call, so a write burst against a slow
or unreachable BM25 daemon grew an unbounded in-flight task queue. A
single long-lived worker draining a bounded mpsc channel caps that
back-pressure: writers try_send (never block), full-queue requests
are dropped with a warn!, and the worker exits cleanly when the last
sender is dropped on shutdown.
What: an mpsc::Sender cloned to every AppState clone (cheap). The
matching receiver is consumed by the worker spawned in
AppState::new via tools::spawn_bm25_index_worker. Capacity is
tools::BM25_INDEX_QUEUE_CAPACITY (256).
Test: bm25_index_queue_drops_when_full exercises the full-queue
branch via bm25_index_enqueue.
Implementations§
Source§impl AppState
impl AppState
Sourcepub fn new(data_root: PathBuf) -> Self
pub fn new(data_root: PathBuf) -> Self
Construct an AppState rooted at the given on-disk data directory.
Why: The CLI (serve) and integration tests need to point the MCP
server at different roots — production at dirs::data_dir, tests at a
tempfile::tempdir().
What: Builds an empty PalaceRegistry, captures the version, and
allocates an empty OnceCell for the embedder. default_palace is
None; use with_default_palace to set it.
Test: tools::tests::dispatch_palace_create_persists constructs an
AppState pointed at a tempdir and round-trips a palace through it.
Sourcepub fn palace_write_lock(&self, palace_id: &str) -> Arc<Mutex<()>>
pub fn palace_write_lock(&self, palace_id: &str) -> Arc<Mutex<()>>
Acquire (lazily, then clone) the per-palace write mutex.
Why (issue #230): the dedup-check + remember_with_options write
sequence in tools.rs must be atomic per palace to prevent two
concurrent identical writes from both passing the dedup gate.
Callers hold the returned Arc<Mutex<()>>’s guard across the gate
check and the write so the second writer blocks until the first
write is visible to list_drawers. Returning a clone of the Arc
rather than a borrow into the DashMap lets the caller .await
while holding the lock without risking a deadlock against any
future map mutation (DashMap shards are sync mutexes).
What: looks up the palace id in palace_write_locks and returns
a clone of the existing mutex; on the first call for a palace,
inserts a freshly-constructed tokio::sync::Mutex<()> first. The
DashMap::entry().or_insert_with API guarantees the lazy
construction is racy-safe — only one mutex is ever inserted per
palace id.
Test: tools::tests::dedup_gate_blocks_concurrent_duplicate_writes.
Sourcepub fn with_bm25_client_from_env(self) -> Self
pub fn with_bm25_client_from_env(self) -> Self
Builder-style: opt-in to the BM25 lexical lane (issue #156).
Why: the BM25 subprocess is gated behind TRUSTY_BM25_DAEMON=1 so
the default cargo install trusty-memory / launchd plist deployment
stays vector-only and existing test fixtures keep passing without
having to provision a daemon. Reading the env var here keeps the
gating logic in one place (the helper in main.rs just plumbs the
result through).
What: when TRUSTY_BM25_DAEMON=1, constructs one Bm25Client per
palace by lazy-resolving the socket path the first time the palace
id is observed. Currently we install a shared default client up
front and re-key on the palace id at the call site — palaces with no
daemon socket simply see search/index errors which we log + ignore.
Returns self unchanged when the env var is unset or set to anything
other than 1.
Test: bm25_client_disabled_by_default,
bm25_client_enabled_when_env_set.
Sourcepub async fn load_palaces_from_disk(&self) -> Result<usize>
pub async fn load_palaces_from_disk(&self) -> Result<usize>
Scan the palace registry directory and re-register every persisted
palace into the in-memory PalaceRegistry.
Why: AppState::new builds an empty registry, so after a daemon
restart palace_list / the dashboard reported zero palaces even though
dozens existed on disk — palace metadata was persisted by
palace_create but never re-hydrated on startup. This method closes
that gap by walking the on-disk layout (each subdirectory holding a
palace.json is one palace) and rebuilding a live PalaceHandle for
each, so recall paths see the full set immediately after a restart.
What: runs the blocking filesystem walk + per-palace PalaceHandle::open
on a spawn_blocking thread (so it never stalls the async runtime),
registers each successfully opened palace via register_arc, logs every
load at debug!, and returns the count loaded. A palace that fails to
open (corrupt index, unreadable kg.db, etc.) is logged at warn! and
skipped — one bad palace must not abort startup or crash the daemon.
data_root is expected to already be the palace registry directory —
main.rs resolves it via resolve_palace_registry_dir before
constructing the AppState, so the flat / legacy-palaces/ layout
difference is handled exactly once.
Test: tests::load_palaces_from_disk_rehydrates_registry writes two
palaces into a tempdir, constructs an AppState, calls this method, and
asserts the returned count and registry contents.
Sourcepub fn with_log_buffer(self, buffer: LogBuffer) -> Self
pub fn with_log_buffer(self, buffer: LogBuffer) -> Self
Builder-style: attach the daemon’s shared [LogBuffer] so the
GET /api/v1/logs/tail endpoint serves the same lines the tracing
subscriber captures (issue #35).
Why: main builds the buffer (via init_tracing_with_buffer) before
constructing the AppState, then hands a clone here so the HTTP
handler and the tracing layer observe the same ring.
What: replaces the empty default buffer with the supplied one.
Test: logs_tail_returns_recent_lines.
Sourcepub fn emit(&self, event: DaemonEvent)
pub fn emit(&self, event: DaemonEvent)
Send a DaemonEvent to all connected SSE subscribers and persist
it to the activity log when the variant carries a source.
Why: Mutating handlers call this after a successful write so the
dashboard can update without polling. The send is best-effort —
broadcast::Sender::send returns Err only when there are no live
receivers, which is fine (no listeners == no work to do). Issue
#96 additionally writes the entry to the persistent activity log
so the feed can serve historical rows on page load and so MCP /
HTTP / Hook origins are visible to the operator. Persistence is
also best-effort — a write failure is logged but never blocks the
SSE broadcast.
Issue #232: the activity-log append is a synchronous redb write +
fsync. Calling it directly on the async caller’s task parked a tokio
worker thread on disk I/O for every SSE event. We now offload the
append to the blocking thread pool via spawn_blocking and return
immediately — emit stays synchronous so every existing caller
(including the sync dispatch_hook_fired JSON-RPC handler) keeps
compiling unchanged. The fire-and-forget pattern matches the
pre-fix semantics (best-effort, never blocks the SSE broadcast)
while freeing the async runtime to do real work during the write.
What: serialises the event for the log (skipping StatusChanged
which is a recomputed aggregate, not a mutation), spawns the redb
append on tokio::task::spawn_blocking keyed by a clone of the
Arc<ActivityLog> and the cloned event, then sends the event over
the broadcast channel. A pending_activity_writes counter is bumped
before the spawn and decremented inside the closure so
Self::flush_activity_writes can drain in tests.
Test: web::tests::sse_stream_receives_palace_created confirms a
subscriber observes the emitted event;
activity_endpoint_lists_recent_emits confirms persistence via
flush_activity_writes.
Sourcepub async fn flush_activity_writes(&self)
pub async fn flush_activity_writes(&self)
Block (asynchronously) until every in-flight activity-log write
spawned by Self::emit has settled.
Why: emit offloads its redb append to tokio::task::spawn_blocking
and returns immediately (issue #232). Tests that observe the
activity log right after a burst of emits would otherwise race the
blocking-pool worker; this helper gives them a deterministic
synchronization point. Production code never needs to call this —
the dashboard reads through GET /api/v1/activity, which already
tolerates writes settling asynchronously.
What: spins on pending_activity_writes with a 1 ms yield until the
counter is zero. Cheap: tests typically emit a handful of events
and the loop exits within a single scheduler tick.
Test: covered indirectly by emit_persists_mutations_but_skips_status_changed
and web::tests::activity_endpoint_lists_recent_emits.
Sourcepub fn session_store(&self, palace_id: &str) -> Result<Arc<ChatSessionStore>>
pub fn session_store(&self, palace_id: &str) -> Result<Arc<ChatSessionStore>>
Open (or return cached) the chat-session store for a palace.
Why: Chat session persistence lives in a dedicated SQLite file under
the palace’s data dir (chat_sessions.db) so it doesn’t intermingle
with the KG’s transactional load. The store is cheap to clone via
Arc but the underlying r2d2 pool should be reused, so cache by id.
What: Creates the palace data dir if missing, opens (or reuses) a
ChatSessionStore and stashes an Arc in the DashMap.
Test: Indirectly via the session HTTP handlers in web::tests.
Sourcepub fn with_default_palace(self, name: Option<String>) -> Self
pub fn with_default_palace(self, name: Option<String>) -> Self
Builder-style setter for the default palace name.
Why: serve --palace <name> wants to bind every tool call to a
project-scoped namespace without forcing every MCP request to repeat
the palace argument.
What: Returns self with default_palace = Some(name).
Test: default_palace_used_when_arg_omitted covers the resolution
path; this setter is exercised there.
Sourcepub async fn chat_provider(&self) -> Option<Arc<dyn ChatProvider>>
pub async fn chat_provider(&self) -> Option<Arc<dyn ChatProvider>>
Resolve (or initialize) the shared embedder.
Why: FastEmbedder load is expensive — we share one instance across all
tool calls; the OnceCell ensures concurrent first-use races collapse
to a single load.
What: Returns Arc<FastEmbedder> on success. Errors propagate from the
underlying ONNX load.
Test: Indirectly via dispatch_remember_then_recall.
Resolve the active chat provider, auto-detecting on first call.
Why: Provider selection depends on filesystem-loaded config plus a
network probe (Ollama liveness), so it must be lazily initialised at
runtime. Caching the choice in a OnceCell keeps it stable across
concurrent requests without re-probing on every chat call.
What: On first use loads ~/.trusty-memory/config.toml, prefers an
auto-detected Ollama instance (when local_model.enabled), and falls
back to OpenRouter when an API key is set. Returns Ok(None) when
neither is available so the caller can emit a 412.
Test: web::tests::providers_endpoint_returns_payload covers the
detection path indirectly through /api/v1/chat/providers.
Sourcepub fn spawn_alias_discovery(&self, palace: String, project_root: PathBuf)
pub fn spawn_alias_discovery(&self, palace: String, project_root: PathBuf)
Spawn a fire-and-forget background task that auto-discovers project
aliases under project_root and asserts new ones into palace.
Why (issue #42): Projects carry implicit shorthand — cargo package
names that differ from their directory, binary names that differ
from packages, first-letter abbreviations — that should be surfaced
without a user ever calling add_alias. Running discovery as a
detached task on palace-open keeps startup latency unchanged: the
daemon binds and starts serving immediately while the discovery scan
completes in the background, and any newly-asserted aliases land in
the prompt cache before the model’s next get_prompt_context call.
What: clones self (cheap; Arc-backed), spawns a tokio task that
invokes the discover_aliases tool handler directly so the
dedup + cache-rebuild logic runs exactly the same path as the MCP
tool call. Errors are logged at warn!; one failed discovery never
destabilises the daemon.
Test: not unit-tested (timing-dependent fire-and-forget); the
underlying discover_aliases dispatch is covered by
dispatch_discover_aliases_inserts_new_and_dedupes in tools::tests.
pub async fn embedder(&self) -> Result<Arc<FastEmbedder>>
Trait Implementations§
Auto Trait Implementations§
impl Freeze for AppState
impl !RefUnwindSafe for AppState
impl Send for AppState
impl Sync for AppState
impl Unpin for AppState
impl UnsafeUnpin for AppState
impl !UnwindSafe for AppState
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self>
fn instrument(self, span: Span) -> Instrumented<Self>
Source§fn in_current_span(self) -> Instrumented<Self>
fn in_current_span(self) -> Instrumented<Self>
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self>
fn into_either(self, into_left: bool) -> Either<Self, Self>
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more