Skip to main content

kindling_runtime/
lib.rs

1//! kindling-runtime — the anvil-first integration facade.
2//!
3//! One Cargo dependency that bundles daemon startup, client wiring, and durable
4//! emit for Rust downstreams (chiefly **anvil**) that want **one binary** and
5//! **daemon semantics** without a separate `kindling` install.
6//!
7//! The runtime composes the existing crates — it does not fork their wire
8//! shapes:
9//!
10//! - [`kindling-client`](kindling_client) for the HTTP-over-UDS surface
11//!   (re-exported as [`Client`]).
12//! - the opt-in `spool` layer for durable emit (re-exported as
13//!   [`SpooledClient`]).
14//! - [`kindling-server`](kindling_server) started in-process on a tokio task
15//!   (the `embedded-daemon` feature).
16//! - [`kindling-types`](kindling_types) re-exported as [`types`] (and the
17//!   common shapes at the crate root) so consumers need no separate dep.
18//!
19//! kindling stays **mechanism, not policy**: the runtime owns process lifecycle
20//! and client wiring; it does not encode anvil governance.
21//!
22//! # Quickstart
23//!
24//! ```no_run
25//! # #[cfg(all(feature = "embedded-daemon", feature = "spool"))]
26//! # async fn run() -> Result<(), kindling_runtime::RuntimeError> {
27//! use kindling_runtime::{Runtime, RuntimeConfig};
28//! use kindling_runtime::types::{ObservationInput, ObservationKind, ScopeIds};
29//!
30//! // Embedded daemon (default), durable spooled emit, default ~/.kindling home.
31//! let runtime = Runtime::start(RuntimeConfig::embedded("/path/to/my/project")).await?;
32//!
33//! // Durable append: reaches the daemon, or buffers to the spool on outage.
34//! let input = ObservationInput {
35//!     id: None,
36//!     kind: ObservationKind::Message,
37//!     content: "gate evaluated: pass".to_string(),
38//!     provenance: None,
39//!     ts: None,
40//!     scope_ids: ScopeIds::default(),
41//!     redacted: None,
42//! };
43//! runtime
44//!     .spooled_client()
45//!     .append_observation(input, None, None)
46//!     .await
47//!     .expect("durable append");
48//!
49//! runtime.shutdown().await?;
50//! # Ok(())
51//! # }
52//! ```
53//!
54//! # Attach-or-start
55//!
56//! [`Runtime::start`] never *pre-emptively* starts a daemon. It builds a client
57//! for the configured socket; the client only spawns when the socket does not
58//! answer. So if a daemon (the CLI, a Claude Code hook, or another runtime) is
59//! already listening on the same socket, the runtime **attaches** to it and the
60//! embedded spawner is not invoked.
61//!
62//! This "attach, don't spawn" guarantee holds for the **sequential** case: a
63//! daemon that is already bound when [`start`](Runtime::start) runs is attached
64//! to. It is *not* a cross-process lock. If two cold-starts race on the same
65//! socket (neither daemon bound yet), both embedded spawners can briefly fire;
66//! the second `serve` then fails to bind the already-taken socket and its task
67//! exits cleanly, leaving exactly one daemon. For v1 anvil (a single binary
68//! owning its socket) this window does not arise, so the runtime deliberately
69//! ships no socket lock — see the design spec's "socket lock file" note.
70
71#![forbid(unsafe_code)]
72
73#[cfg(feature = "client")]
74mod spawn;
75
76use std::path::PathBuf;
77
78/// Domain types, re-exported so consumers need no separate `kindling-types`
79/// dependency.
80pub use kindling_types as types;
81
82// The common domain shapes at the crate root for ergonomic access.
83pub use kindling_types::{
84    Capsule, CapsuleStatus, CapsuleType, Id, Observation, ObservationInput, ObservationKind, Pin,
85    RetrieveOptions, RetrieveResult, ScopeIds,
86};
87
88#[cfg(feature = "client")]
89pub use kindling_client::{Client, ClientConfig, ClientError, Spawner, Transport};
90
91#[cfg(feature = "spool")]
92pub use kindling_client::spool::{AppendOutcome, FlushReport, SpoolError, SpooledClient};
93
94#[cfg(feature = "client")]
95use std::time::Duration;
96
97/// How the runtime obtains a running daemon when the socket is not answering.
98///
99/// Note that *all* strategies attach to an already-running daemon on the
100/// configured socket — the variant only decides what happens when a spawn is
101/// actually required.
102#[derive(Clone, Debug, Default, PartialEq, Eq)]
103#[non_exhaustive]
104pub enum SpawnStrategy {
105    /// Start an in-process [`kindling-server`](kindling_server) on a tokio task
106    /// (requires the `embedded-daemon` feature). The blessed anvil path: one
107    /// binary, no `kindling` on `PATH`. This is the default.
108    #[default]
109    Embedded,
110    /// Exec the real `kindling` binary on `PATH` (requires the `external-spawn`
111    /// feature). For hosts that ship the CLI separately.
112    External,
113    /// Never spawn. Attach to an already-running daemon or fail with
114    /// [`ClientError::Unavailable`](kindling_client::ClientError::Unavailable).
115    /// For tests and hosts that manage the daemon externally.
116    AttachOnly,
117}
118
119/// Configuration for a [`Runtime`].
120///
121/// `#[non_exhaustive]`: build via [`RuntimeConfig::embedded`],
122/// [`RuntimeConfig::with_home`], or [`RuntimeConfig::from_default_home`] (then
123/// mutate fields), so new fields can be added without a breaking change.
124#[derive(Clone, Debug)]
125#[non_exhaustive]
126pub struct RuntimeConfig {
127    /// Root of the per-project databases and the daemon's socket/pid/port files
128    /// (`~/.kindling` by default). Using the default layout shares the DB with
129    /// the `kindling` CLI and Claude Code hooks.
130    pub kindling_home: PathBuf,
131    /// Project root string, sent as the `X-Kindling-Project` header on every
132    /// data endpoint for per-project DB routing.
133    pub project_root: String,
134    /// Path to the durable-emit spool file. Defaults to `<home>/spool.ndjson`
135    /// when `None`. Only meaningful with the `spool` feature.
136    pub spool_path: Option<PathBuf>,
137    /// How to obtain the daemon. Defaults to [`SpawnStrategy::Embedded`].
138    pub spawn: SpawnStrategy,
139}
140
141impl RuntimeConfig {
142    /// Build a config rooted at the default kindling home (`~/.kindling`) for
143    /// `project_root`, with the default [`SpawnStrategy`].
144    ///
145    /// Errors only if no home directory can be determined.
146    pub fn from_default_home(project_root: impl Into<String>) -> Result<Self, RuntimeError> {
147        let kindling_home = default_kindling_home()
148            .ok_or_else(|| RuntimeError::Config("could not determine kindling home".into()))?;
149        Ok(Self {
150            kindling_home,
151            project_root: project_root.into(),
152            spool_path: None,
153            spawn: SpawnStrategy::default(),
154        })
155    }
156
157    /// Build an embedded-daemon config rooted at the default home for
158    /// `project_root` (the common anvil case).
159    ///
160    /// Infallible: if no home directory can be determined it **silently falls
161    /// back** to `.kindling` in the current working directory. Prefer
162    /// [`from_default_home`](Self::from_default_home) when you want a missing
163    /// home to surface as an error instead of a cwd-relative fallback.
164    pub fn embedded(project_root: impl Into<String>) -> Self {
165        let kindling_home = default_kindling_home().unwrap_or_else(|| PathBuf::from(".kindling"));
166        Self {
167            kindling_home,
168            project_root: project_root.into(),
169            spool_path: None,
170            spawn: SpawnStrategy::Embedded,
171        }
172    }
173
174    /// Build a config rooted at an explicit `kindling_home` (tests / isolated
175    /// hosts) with the given [`SpawnStrategy`].
176    pub fn with_home(
177        kindling_home: impl Into<PathBuf>,
178        project_root: impl Into<String>,
179        spawn: SpawnStrategy,
180    ) -> Self {
181        Self {
182            kindling_home: kindling_home.into(),
183            project_root: project_root.into(),
184            spool_path: None,
185            spawn,
186        }
187    }
188
189    /// The effective spool path: the configured one, or `<home>/spool.ndjson`.
190    pub fn effective_spool_path(&self) -> PathBuf {
191        self.spool_path
192            .clone()
193            .unwrap_or_else(|| self.kindling_home.join("spool.ndjson"))
194    }
195
196    #[cfg(feature = "client")]
197    fn socket_path(&self) -> PathBuf {
198        self.kindling_home.join("kindling.sock")
199    }
200
201    #[cfg(feature = "client")]
202    fn port_path(&self) -> PathBuf {
203        self.kindling_home.join("kindling.port")
204    }
205}
206
207/// Errors from [`Runtime`] operations.
208#[derive(Debug, thiserror::Error)]
209#[non_exhaustive]
210pub enum RuntimeError {
211    /// Invalid or unresolvable configuration (e.g. no home directory).
212    #[error("runtime config error: {0}")]
213    Config(String),
214
215    /// The requested [`SpawnStrategy`] needs a feature that was not compiled in.
216    #[error("runtime feature error: {0}")]
217    Feature(String),
218
219    /// A client-side failure surfaced while wiring or probing the daemon.
220    #[cfg(feature = "client")]
221    #[error(transparent)]
222    Client(#[from] kindling_client::ClientError),
223}
224
225/// The schema version this runtime expects the daemon to report, taken from the
226/// client's compiled-in constant.
227#[cfg(feature = "client")]
228fn expected_schema_version() -> u32 {
229    kindling_client::EXPECTED_SCHEMA_VERSION
230}
231
232/// Resolve `~/.kindling` from the environment without depending on
233/// `kindling-store` (which pulls rusqlite). Mirrors the client's HOME logic.
234fn default_kindling_home() -> Option<PathBuf> {
235    let home = std::env::var_os("HOME")
236        .filter(|v| !v.is_empty())
237        .or_else(|| std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()))?;
238    Some(PathBuf::from(home).join(".kindling"))
239}
240
241/// A composed kindling integration: a client (and durable-emit spooled client)
242/// over a daemon the runtime either started in-process or attached to.
243///
244/// Owns the embedded server task handle (when [`SpawnStrategy::Embedded`]), so
245/// [`shutdown`](Self::shutdown) can stop it.
246///
247/// Dropping a `Runtime` **without** calling [`shutdown`](Self::shutdown) does
248/// not stop an embedded daemon: the `serve` task was spawned onto the tokio
249/// runtime and keeps running until the runtime itself shuts down (or the daemon
250/// idles out). Call [`shutdown`](Self::shutdown) for deterministic teardown.
251#[cfg(feature = "client")]
252#[derive(Debug)]
253pub struct Runtime {
254    client: Client,
255    #[cfg(feature = "spool")]
256    spooled: SpooledClient,
257    /// The strategy this runtime was started with (for diagnostics).
258    strategy: SpawnStrategy,
259    /// Slot holding the embedded daemon's task handle, if one was started.
260    #[cfg(feature = "embedded-daemon")]
261    server_handle: spawn::ServerHandleSlot,
262    /// Whether the embedded spawner actually ran (false ⇒ attached to a
263    /// pre-existing daemon).
264    spawn_flag: spawn::SpawnFlag,
265}
266
267#[cfg(feature = "client")]
268impl Runtime {
269    /// Start the runtime: build a client for the configured socket and wire the
270    /// [`SpawnStrategy`]. Probes the daemon with a `health` call (which triggers
271    /// attach-or-spawn) so a started runtime is immediately usable.
272    pub async fn start(config: RuntimeConfig) -> Result<Self, RuntimeError> {
273        let spawn_flag = spawn::SpawnFlag::new();
274
275        #[cfg(feature = "embedded-daemon")]
276        let server_handle: spawn::ServerHandleSlot =
277            std::sync::Arc::new(std::sync::Mutex::new(None));
278
279        let spawner = build_spawner(&config, spawn_flag.clone(), {
280            #[cfg(feature = "embedded-daemon")]
281            {
282                server_handle.clone()
283            }
284            #[cfg(not(feature = "embedded-daemon"))]
285            {
286                ()
287            }
288        })?;
289
290        let client_config = ClientConfig {
291            socket_path: config.socket_path(),
292            port_path: config.port_path(),
293            project_root: config.project_root.clone(),
294            expected_schema_version: expected_schema_version(),
295            connect_timeout: Duration::from_secs(5),
296            poll_interval: Duration::from_millis(10),
297            spawn: spawner,
298            transport: Transport::default(),
299            spawn_log_path: None,
300        };
301
302        let client = Client::with_config(client_config);
303
304        // Probe: this triggers attach-or-spawn. A running daemon answers without
305        // the spawner firing; otherwise the strategy decides what happens.
306        client.health().await?;
307
308        #[cfg(feature = "spool")]
309        let spooled = SpooledClient::new(client.clone(), config.effective_spool_path());
310
311        Ok(Self {
312            client,
313            #[cfg(feature = "spool")]
314            spooled,
315            strategy: config.spawn,
316            #[cfg(feature = "embedded-daemon")]
317            server_handle,
318            spawn_flag,
319        })
320    }
321
322    /// Borrow the underlying daemon client for reads and non-spooled ops
323    /// (health, retrieve, capsules, pins, …).
324    pub fn client(&self) -> &Client {
325        &self.client
326    }
327
328    /// Borrow the durable-emit spooled client. Append through this for
329    /// outage-resilient writes.
330    #[cfg(feature = "spool")]
331    pub fn spooled_client(&self) -> &SpooledClient {
332        &self.spooled
333    }
334
335    /// The [`SpawnStrategy`] this runtime was started with.
336    pub fn strategy(&self) -> &SpawnStrategy {
337        &self.strategy
338    }
339
340    /// Whether this runtime started an embedded daemon (`true`) or attached to a
341    /// pre-existing one (`false`). Only meaningful for
342    /// [`SpawnStrategy::Embedded`].
343    pub fn spawned_embedded_daemon(&self) -> bool {
344        self.spawn_flag.fired()
345    }
346
347    /// Stop the runtime, aborting the embedded daemon task if this runtime
348    /// started one. Attached daemons (started elsewhere) are left running.
349    pub async fn shutdown(self) -> Result<(), RuntimeError> {
350        #[cfg(feature = "embedded-daemon")]
351        {
352            // `.lock().ok()` swallows a poisoned mutex: the only code that holds
353            // this lock is the spawner closure storing the JoinHandle, which
354            // never panics while holding it, so poisoning cannot occur in
355            // practice. If it somehow did, treating it as "no handle to abort"
356            // is the benign outcome (the daemon idles out on its own).
357            let handle = self.server_handle.lock().ok().and_then(|mut g| g.take());
358            if let Some(handle) = handle {
359                handle.abort();
360                // Best-effort await of the aborted task; ignore the Cancelled
361                // join error.
362                let _ = handle.await;
363            }
364        }
365        Ok(())
366    }
367}
368
369/// Build the [`Spawner`] for `config`'s [`SpawnStrategy`], honouring the
370/// compiled feature set.
371#[cfg(feature = "client")]
372fn build_spawner(
373    config: &RuntimeConfig,
374    spawn_flag: spawn::SpawnFlag,
375    #[cfg(feature = "embedded-daemon")] server_handle: spawn::ServerHandleSlot,
376    #[cfg(not(feature = "embedded-daemon"))] _server_handle: (),
377) -> Result<Spawner, RuntimeError> {
378    match config.spawn {
379        SpawnStrategy::Embedded => {
380            #[cfg(feature = "embedded-daemon")]
381            {
382                let server_config = kindling_server::ServerConfig {
383                    socket_path: config.socket_path(),
384                    kindling_home: config.kindling_home.clone(),
385                    pid_path: config.kindling_home.join("kindling.pid"),
386                    port_path: config.port_path(),
387                    // Long idle timeout: the runtime owns the lifecycle and
388                    // stops the daemon on shutdown, so it must not idle out
389                    // underneath a live Runtime.
390                    idle_timeout: Duration::from_secs(60 * 60),
391                    transport: kindling_server::Transport::default(),
392                };
393                Ok(spawn::embedded_spawner(
394                    server_config,
395                    server_handle,
396                    spawn_flag,
397                ))
398            }
399            #[cfg(not(feature = "embedded-daemon"))]
400            {
401                let _ = spawn_flag;
402                Err(RuntimeError::Feature(
403                    "SpawnStrategy::Embedded requires the `embedded-daemon` feature".into(),
404                ))
405            }
406        }
407        SpawnStrategy::External => {
408            #[cfg(feature = "external-spawn")]
409            {
410                let _ = spawn_flag;
411                Ok(Spawner::Command)
412            }
413            #[cfg(not(feature = "external-spawn"))]
414            {
415                let _ = spawn_flag;
416                Err(RuntimeError::Feature(
417                    "SpawnStrategy::External requires the `external-spawn` feature".into(),
418                ))
419            }
420        }
421        SpawnStrategy::AttachOnly => Ok(spawn::attach_only_spawner(spawn_flag)),
422    }
423}