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}