kindling_server/lib.rs
1//! kindling daemon — HTTP API over a Unix domain socket.
2//!
3//! A long-running per-user process that serves the kindling v1 HTTP API,
4//! wrapping the in-process [`kindling_service::KindlingService`]. This is the
5//! `kindling serve` backend; the CLI wiring lives in a later task (PORT-013).
6//! This crate exposes a library surface so it can be both unit/integration
7//! tested and driven by the future CLI.
8//!
9//! # v1 HTTP API
10//!
11//! ```text
12//! GET /v1/health → 200 { version, schemaVersion, supportedKinds, storagePath, kindRegistry, projects: [...] }
13//! POST /v1/capsules → 201 Capsule
14//! GET /v1/capsules/open?sessionId → 200 Capsule | null
15//! PATCH /v1/capsules/:id/close → 200 Capsule
16//! POST /v1/observations → 201 Observation
17//! POST /v1/observations/list → 200 ListObservationsResult (paginated)
18//! POST /v1/observations/:id/forget → 204 (redact an observation)
19//! POST /v1/retrieve → 200 RetrieveResult
20//! POST /v1/pins → 201 Pin
21//! DELETE /v1/pins/:id → 204
22//! POST /v1/context/session-start → 200 { additionalContext: string | null }
23//! POST /v1/context/pre-compact → 200 { additionalContext: string | null }
24//! ```
25//!
26//! Request bodies are camelCase JSON; response bodies serialize the domain
27//! types (already camelCase). See [`dto`] for the request shapes. The
28//! `/v1/context/*` endpoints assemble AND format the injected-context markdown
29//! server-side (the byte-for-byte date/markdown logic lives in [`inject`]).
30//!
31//! # Per-project routing
32//!
33//! Every data endpoint requires the `X-Kindling-Project` header. Its value is
34//! the **project root string**; the daemon derives the SQLite DB via
35//! [`kindling_store::project_db_path`] under [`ServerConfig::kindling_home`]
36//! and caches one service per project. `/v1/health` needs no header; any other
37//! endpoint without it returns `400`.
38//!
39//! # Lifecycle
40//!
41//! [`serve`] acquires a PID lock (cleaning up a stale file — see [`pid`]), binds
42//! the UDS at mode `0600`, builds the router, and runs until idle. The daemon
43//! shuts down after [`ServerConfig::idle_timeout`] of no in-flight and no
44//! recent requests, then removes the socket and PID file.
45
46mod config;
47mod dto;
48mod error;
49mod handlers;
50pub mod inject;
51mod pid;
52mod state;
53
54pub use config::{ServerConfig, Transport, DEFAULT_IDLE_TIMEOUT};
55pub use error::{ApiError, ServerError};
56pub use handlers::{PROJECT_HEADER, SESSION_HEADER};
57pub use pid::{acquire_pid_lock, PidGuard};
58pub use state::AppState;
59
60use std::sync::Arc;
61use std::time::Duration;
62
63use axum::routing::{delete, patch, post};
64use axum::Router;
65
66/// Build the v1 API router over the given [`AppState`].
67///
68/// Exposed so integration tests (and the future CLI) can drive routes either
69/// through the full [`serve`] over a temp socket or by serving this router
70/// directly. An activity-tracking middleware updates the idle clock on every
71/// request.
72pub fn build_router(state: AppState) -> Router {
73 let activity = Arc::clone(state.activity());
74 Router::new()
75 .route("/v1/health", axum::routing::get(handlers::health))
76 .route("/v1/capsules", post(handlers::open_capsule))
77 .route(
78 "/v1/capsules/open",
79 axum::routing::get(handlers::get_open_capsule),
80 )
81 .route("/v1/capsules/{id}/close", patch(handlers::close_capsule))
82 .route("/v1/observations", post(handlers::append_observation))
83 .route("/v1/observations/list", post(handlers::list_observations))
84 .route(
85 "/v1/observations/{id}/forget",
86 post(handlers::forget_observation),
87 )
88 .route("/v1/retrieve", post(handlers::retrieve))
89 .route("/v1/pins", post(handlers::create_pin))
90 .route("/v1/pins/{id}", delete(handlers::unpin))
91 .route(
92 "/v1/context/session-start",
93 post(handlers::session_start_context),
94 )
95 .route(
96 "/v1/context/pre-compact",
97 post(handlers::pre_compact_context),
98 )
99 .layer(axum::middleware::from_fn(
100 move |req, next: axum::middleware::Next| {
101 let activity = Arc::clone(&activity);
102 async move {
103 activity.enter();
104 let response = next.run(req).await;
105 activity.leave();
106 response
107 }
108 },
109 ))
110 .with_state(state)
111}
112
113/// Run the daemon to completion: acquire the PID lock, bind the UDS at mode
114/// `0600`, serve the v1 API, and shut down on idle — cleaning up the socket and
115/// PID file on exit.
116///
117/// Resolves `Ok(())` on a clean idle shutdown, so callers (and tests) can wrap
118/// it in a `tokio::time::timeout`.
119pub async fn serve(config: ServerConfig) -> Result<(), ServerError> {
120 let _pid_guard = acquire_pid_lock(&config.pid_path)?;
121 let state = AppState::new(config.kindling_home.clone());
122 let app = build_router(state.clone());
123
124 match config.transport {
125 #[cfg(unix)]
126 Transport::Uds => {
127 serve_on_uds(&config, app, state.activity().clone()).await?;
128 // Best-effort socket cleanup; the PID guard removes the PID file on
129 // drop.
130 let _ = remove_socket(&config.socket_path);
131 }
132 Transport::Tcp => {
133 serve_on_tcp(&config, app, state.activity().clone()).await?;
134 }
135 }
136 Ok(())
137}
138
139/// Idle-shutdown future: resolves once the daemon has been idle for
140/// `idle_timeout`. Polled at a fraction of the timeout (min 25ms) so short
141/// test timeouts still fire promptly.
142async fn wait_until_idle(activity: Arc<state::Activity>, idle_timeout: Duration) {
143 let poll = idle_timeout
144 .checked_div(4)
145 .unwrap_or(idle_timeout)
146 .max(Duration::from_millis(25));
147 loop {
148 tokio::time::sleep(poll).await;
149 if activity.is_idle_for(idle_timeout) {
150 return;
151 }
152 }
153}
154
155#[cfg(unix)]
156async fn serve_on_uds(
157 config: &ServerConfig,
158 app: Router,
159 activity: Arc<state::Activity>,
160) -> Result<(), ServerError> {
161 use std::os::unix::fs::PermissionsExt;
162 use tokio::net::UnixListener;
163
164 // A leftover socket from an unclean shutdown would make bind fail with
165 // EADDRINUSE. Remove it first (the PID lock already guarantees no live
166 // daemon is using it).
167 let _ = remove_socket(&config.socket_path);
168 if let Some(parent) = config.socket_path.parent() {
169 if !parent.as_os_str().is_empty() {
170 std::fs::create_dir_all(parent)?;
171 // Defence in depth: the socket is chmod'd to 0600 only after bind,
172 // so for a brief window it carries the process umask. Lock the
173 // containing directory to the owner (0700) so no other local user
174 // can reach the socket during that window — filesystem permissions
175 // are the daemon's only authn (per the design spec).
176 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
177 }
178 }
179
180 let listener = UnixListener::bind(&config.socket_path)?;
181 // Restrict the socket to the owning user (0600) after bind, before serving.
182 std::fs::set_permissions(&config.socket_path, std::fs::Permissions::from_mode(0o600))?;
183
184 let idle_timeout = config.idle_timeout;
185 axum::serve(listener, app)
186 .with_graceful_shutdown(wait_until_idle(activity, idle_timeout))
187 .await?;
188 Ok(())
189}
190
191/// Serve over loopback TCP on an ephemeral `127.0.0.1` port.
192///
193/// Compiled on all platforms (it is the Windows default, and is exercised by
194/// the Linux test suite). Binds `127.0.0.1:0`, reads back the OS-assigned port,
195/// and publishes it as decimal text to [`ServerConfig::port_path`] so the
196/// client can discover where to connect — TCP has no filesystem rendezvous like
197/// a UDS path. The port file is removed (best-effort) on shutdown.
198async fn serve_on_tcp(
199 config: &ServerConfig,
200 app: Router,
201 activity: Arc<state::Activity>,
202) -> Result<(), ServerError> {
203 use tokio::net::TcpListener;
204
205 let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
206 let port = listener.local_addr()?.port();
207
208 if let Some(parent) = config.port_path.parent() {
209 if !parent.as_os_str().is_empty() {
210 std::fs::create_dir_all(parent)?;
211 }
212 }
213 std::fs::write(&config.port_path, port.to_string())?;
214
215 let idle_timeout = config.idle_timeout;
216 let serve_result = axum::serve(listener, app)
217 .with_graceful_shutdown(wait_until_idle(activity, idle_timeout))
218 .await;
219
220 // Best-effort port-file cleanup; mirrors the UDS socket cleanup.
221 let _ = remove_socket(&config.port_path);
222 serve_result?;
223 Ok(())
224}
225
226fn remove_socket(path: &std::path::Path) -> std::io::Result<()> {
227 match std::fs::remove_file(path) {
228 Ok(()) => Ok(()),
229 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
230 Err(e) => Err(e),
231 }
232}