Skip to main content

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}