trusty_memory/web/mod.rs
1//! HTTP API + embedded SPA shell for the trusty-memory admin UI.
2//!
3//! Why: The web admin panel is the primary GUI for non-MCP clients. Bundling
4//! the Svelte build via `rust-embed` keeps deployment to "drop the binary on
5//! a host"; the JSON API surface mirrors the MCP tool set so anything
6//! trusty-memory can do via Claude Code can also be done via curl or browser.
7//! What: All `/api/v1/*` handlers (status, palaces, drawers, recall, KG,
8//! config, chat) plus an embedded-asset fallback that serves `ui/dist/`.
9//! Test: `cargo test -p trusty-memory web::tests` covers the asset
10//! fallback and JSON shape of every read endpoint against an in-memory
11//! palace built on a `tempdir`.
12
13use axum::{
14 routing::{delete, get, post},
15 Router,
16};
17
18use crate::AppState;
19
20pub(crate) mod activity;
21pub(crate) mod admin;
22pub(crate) mod error;
23pub(crate) mod health;
24pub(crate) mod kg_routes;
25pub(crate) mod palace_routes;
26pub(crate) mod prompt_context;
27pub(crate) mod recall_routes;
28pub(crate) mod rpc;
29pub(crate) mod static_assets;
30
31// Re-export the pub(crate) items that other modules (primarily `chat.rs`)
32// reference via `crate::web::`. Only items that are actually imported from
33// outside the `web/` module are listed here; submodule-internal items are
34// accessed directly from their source module within the `web/` hierarchy.
35pub(crate) use error::{open_handle, ApiError};
36pub(crate) use kg_routes::DreamStatusPayload;
37pub(crate) use palace_routes::{load_user_config, palace_info_from};
38pub(crate) use rpc::creator_info_from_http;
39
40/// Dedicated palace id used by the `/health` round-trip probe (issue #185).
41///
42/// Why: Earlier revisions of `run_health_round_trip` picked whichever palace
43/// happened to be first on disk (APFS creation order on macOS), which meant
44/// the probe always wrote — and, if recall failed, *leaked* — a drawer in a
45/// real user-facing palace. Routing the probe to a dedicated palace whose id
46/// starts with the reserved `__` prefix means leaked drawers are confined to a
47/// palace the user never sees (filtered by `MemoryService::list_palaces`) and
48/// real palaces stay clean.
49/// What: A constant `&str` reused by the probe and tests. The leading double
50/// underscore is the project-wide convention for "system" palaces hidden from
51/// user listings.
52/// Test: `health_probe_palace_is_invisible`, `health_probe_cleans_up_on_success`,
53/// `health_probe_cleans_up_on_recall_miss`.
54pub(crate) const HEALTH_PROBE_PALACE: &str = "__health_probe__";
55
56/// Build the public router with API routes + SPA asset fallback.
57///
58/// Why: `run_http` calls this so the same router shape is used in tests.
59/// What: All API routes under `/api/v1`, fallback to the SPA shell.
60/// Test: `serves_index_html_fallback` and `status_endpoint_returns_payload`.
61pub fn router() -> Router<AppState> {
62 // axum 0.8 path syntax uses `{param}` instead of `:param`. The shared
63 // `trusty_common::server::with_standard_middleware` layer brings in CORS,
64 // tracing, and gzip (with SSE excluded) so we don't drift from sibling
65 // trusty-* daemons.
66 let router = Router::new()
67 .route("/api/v1/status", get(palace_routes::status))
68 .route("/api/v1/config", get(palace_routes::config))
69 .route(
70 "/api/v1/palaces",
71 get(palace_routes::list_palaces).post(palace_routes::create_palace),
72 )
73 .route(
74 "/api/v1/palaces/{id}",
75 get(palace_routes::get_palace_handler)
76 .delete(palace_routes::delete_palace_handler)
77 .patch(palace_routes::update_palace_handler),
78 )
79 .route(
80 "/api/v1/palaces/{id}/drawers",
81 get(palace_routes::list_drawers).post(palace_routes::create_drawer),
82 )
83 .route(
84 "/api/v1/palaces/{id}/drawers/{drawer_id}",
85 delete(palace_routes::delete_drawer),
86 )
87 // Issue #70 — `/memories` is a backward-compatible alias for `/drawers`.
88 // Some clients (and earlier docs) POST/GET against `…/memories`, which
89 // 404'd because only `/drawers` was registered. Aliasing here keeps
90 // both vocabularies working against the same handlers without breaking
91 // existing `/drawers` callers.
92 .route(
93 "/api/v1/palaces/{id}/memories",
94 get(palace_routes::list_drawers).post(palace_routes::create_drawer),
95 )
96 .route(
97 "/api/v1/palaces/{id}/memories/{drawer_id}",
98 delete(palace_routes::delete_drawer),
99 )
100 .route(
101 "/api/v1/palaces/{id}/recall",
102 get(recall_routes::recall_handler),
103 )
104 .route("/api/v1/recall", get(recall_routes::recall_all_handler))
105 .route(
106 "/api/v1/palaces/{id}/kg",
107 get(kg_routes::kg_query).post(kg_routes::kg_assert),
108 )
109 .route(
110 "/api/v1/palaces/{id}/kg/subjects",
111 get(kg_routes::kg_list_subjects),
112 )
113 .route(
114 "/api/v1/palaces/{id}/kg/subjects_with_counts",
115 get(kg_routes::kg_list_subjects_with_counts),
116 )
117 .route("/api/v1/palaces/{id}/kg/all", get(kg_routes::kg_list_all))
118 .route("/api/v1/palaces/{id}/kg/graph", get(kg_routes::kg_graph))
119 .route("/api/v1/palaces/{id}/kg/count", get(kg_routes::kg_count))
120 .route(
121 "/api/v1/palaces/{id}/kg/triples/{triple_id}",
122 delete(kg_routes::kg_delete_triple),
123 )
124 .route(
125 "/api/v1/palaces/{id}/dream/status",
126 get(kg_routes::palace_dream_status),
127 )
128 .route("/api/v1/dream/status", get(kg_routes::dream_status))
129 .route("/api/v1/dream/run", post(kg_routes::dream_run))
130 .route("/api/v1/kg/gaps", get(prompt_context::kg_gaps_handler))
131 .route(
132 "/api/v1/kg/prompt-context",
133 get(prompt_context::prompt_context_handler),
134 )
135 .route(
136 "/api/v1/kg/aliases",
137 post(prompt_context::add_alias_handler),
138 )
139 .route(
140 "/api/v1/kg/prompt-facts",
141 get(prompt_context::list_prompt_facts_handler)
142 .delete(prompt_context::remove_prompt_fact_handler),
143 )
144 .route("/api/v1/chat", post(crate::chat::chat_handler))
145 .route("/api/v1/chat/providers", get(crate::chat::list_providers))
146 .route(
147 "/api/v1/palaces/{id}/chat/sessions",
148 get(crate::chat::list_chat_sessions).post(crate::chat::create_chat_session),
149 )
150 .route(
151 "/api/v1/palaces/{id}/chat/sessions/{session_id}",
152 get(crate::chat::get_chat_session).delete(crate::chat::delete_chat_session),
153 )
154 // Issue #99: inter-project messaging.
155 .route(
156 "/api/v1/messages",
157 get(crate::chat::list_messages_handler).post(crate::chat::send_message_handler),
158 )
159 .route(
160 "/api/v1/messages/mark_read",
161 post(crate::chat::mark_message_read_handler),
162 )
163 .route("/health", get(health::health))
164 .route("/api/v1/logs/tail", get(admin::logs_tail))
165 .route("/api/v1/activity", get(activity::activity_handler))
166 .route(
167 "/api/v1/activity/hook",
168 post(activity::hook_activity_handler),
169 )
170 .route("/api/v1/admin/stop", post(admin::admin_stop))
171 // Issue: fire-and-forget memory save for callers that cannot speak
172 // MCP. Sub-agents spawned via Claude Code's Agent tool inherit no
173 // MCP connections, so `memory_remember` is unreachable to them.
174 // This endpoint lets the agent shell out to `trusty-memory note`
175 // (which in turn POSTs here) and the request returns 202 the moment
176 // the body is parsed — the actual `memory_remember` dispatch runs
177 // on a detached `tokio::spawn`. Failures are logged at warn but
178 // never surface to the caller because the contract is one-way.
179 .route("/api/v1/remember", post(admin::remember_async))
180 // Multi-transport refactor: a single JSON-RPC 2.0 endpoint that
181 // accepts the same envelopes the UDS transport speaks. Lets
182 // browser clients, curl, and the stdio bridge fallback hit the
183 // tool surface without learning the REST routes. The REST
184 // routes above remain for backwards compatibility.
185 .route("/rpc", post(rpc::rpc_handler))
186 .fallback(static_assets::static_handler);
187
188 trusty_common::server::with_standard_middleware(router)
189}
190
191#[cfg(test)]
192mod tests;