agent_block_core/bridge/config.rs
1//! Config resolution for `std.kv` / `std.sql` / `std.ts` storage backends.
2//!
3//! All knobs are ENV-driven (no CLI flags) so `.env` can drive them uniformly.
4//!
5//! | ENV var | Default | Used by |
6//! |------------------------------------|--------------------------|----------|
7//! | `AGENT_BLOCK_HOME` | `$HOME/.agent-block` | all |
8//! | `AGENT_BLOCK_KV_PATH` | `{HOME}/kv.sqlite` | std.kv |
9//! | `AGENT_BLOCK_SQL_PATH` | `{HOME}/db.sqlite` | std.sql |
10//! | `AGENT_BLOCK_TS_PATH` | `{HOME}/ts.sqlite` | std.ts |
11//! | `AGENT_BLOCK_KNL_PATH` | `{HOME}/projects/<slug>/knl.sqlite` | knl |
12//! | `AGENT_BLOCK_SQL_BUSY_TIMEOUT_MS` | `5000` | all |
13//! | `AGENT_BLOCK_SQL_QUERY_TIMEOUT_MS` | `5000` | all |
14//! | `AGENT_BLOCK_SQL_JOURNAL_MODE` | `WAL` | all |
15//! | `AGENT_BLOCK_BUS_CAPACITY` | `64` | EventBus |
16//! | `AGENT_BLOCK_TASK_GRACE_MS` | `1000` | task/bus |
17//! | `AGENT_BLOCK_SH_PROCESS_GROUP` | `1` | sh.exec |
18//! | `AGENT_BLOCK_UNSEAL` | unset | blocks |
19//!
20//! `AGENT_BLOCK_UNSEAL=1` is the one knob here that is not a path or a bound:
21//! it downgrades the sealed-module refusal (a project `blocks/knl/`,
22//! `knl_adapter`, `knl_types` or `lshape` shadowing the embedded kernel —
23//! `host::SEALED`) from an error that ends the run to a `warn!`. It exists for
24//! development **on the kernel itself**, where the Lua half is the thing being
25//! edited; a project that sets it is running against a kernel the Rust side
26//! was not declared against. Any other value, including unset, leaves the
27//! refusal in place.
28//!
29//! `std.kv`, `std.sql`, and `std.ts` are backed by separate SQLite database
30//! files so that agent-internal KV state, explicit user SQL data, and
31//! time-series rows don't share WAL, page cache, or backup lifecycle.
32//! Pragma/timeout knobs apply to all three.
33//!
34//! The kernel's log is the fourth, and the one that is *per project* rather
35//! than per host: a session belongs to the tree of work the script it ran
36//! under is part of, so the default lands under `{base_dir}/projects/<slug>/`
37//! where `<slug>` names the project root ([`project_slug`]). The other three
38//! are unaffected and stay where they are.
39//!
40//! Special: `=:memory:` selects an in-memory database (works for
41//! `AGENT_BLOCK_KV_PATH`, `AGENT_BLOCK_SQL_PATH`, and `AGENT_BLOCK_TS_PATH`).
42//! Journal mode is ignored for `:memory:` (SQLite forces MEMORY).
43//! `AGENT_BLOCK_SQL_QUERY_TIMEOUT_MS=0` disables the query timeout.
44
45// Not gated: the kernel's database is resolved in every build, because the
46// kernel is (see the `sqlite` feature note in this crate's manifest — that
47// feature is the `sql.*` / `kv.*` / `ts.*` batteries, not SQLite itself).
48use std::path::{Path, PathBuf};
49#[cfg(feature = "sqlite")]
50use std::time::Duration;
51
52#[cfg(feature = "sqlite")]
53const DEFAULT_SQL_BUSY_TIMEOUT_MS: u64 = 5000;
54#[cfg(feature = "sqlite")]
55const DEFAULT_SQL_QUERY_TIMEOUT_MS: u64 = 5000;
56#[cfg(feature = "sqlite")]
57const DEFAULT_SQL_JOURNAL_MODE: &str = "WAL";
58const DEFAULT_BUS_CAPACITY: usize = 64;
59const DEFAULT_TASK_GRACE_MS: u64 = 1000;
60
61/// Base dir for agent-block local state.
62/// `AGENT_BLOCK_HOME` → `$HOME/.agent-block`.
63pub fn base_dir() -> Result<PathBuf, String> {
64 if let Some(v) = std::env::var_os("AGENT_BLOCK_HOME") {
65 return Ok(PathBuf::from(v));
66 }
67 let home = std::env::var_os("HOME").ok_or_else(|| "HOME env var not set".to_string())?;
68 Ok(PathBuf::from(home).join(".agent-block"))
69}
70
71/// Path to the std.kv SQLite database file (or `:memory:`).
72/// `AGENT_BLOCK_KV_PATH` → `{base_dir}/kv.sqlite`.
73#[cfg(feature = "sqlite")]
74pub fn kv_path() -> Result<PathBuf, String> {
75 if let Some(v) = std::env::var_os("AGENT_BLOCK_KV_PATH") {
76 return Ok(PathBuf::from(v));
77 }
78 Ok(base_dir()?.join("kv.sqlite"))
79}
80
81/// Path to the std.sql SQLite database file (or `:memory:`).
82/// `AGENT_BLOCK_SQL_PATH` → `{base_dir}/db.sqlite`.
83#[cfg(feature = "sqlite")]
84pub fn sql_path() -> Result<PathBuf, String> {
85 if let Some(v) = std::env::var_os("AGENT_BLOCK_SQL_PATH") {
86 return Ok(PathBuf::from(v));
87 }
88 Ok(base_dir()?.join("db.sqlite"))
89}
90
91/// Path to the std.ts SQLite database file (or `:memory:`).
92///
93/// `AGENT_BLOCK_TS_PATH` → `{base_dir}/ts.sqlite`.
94/// Separate from kv and sql so the TSDB WAL does not share page cache or
95/// backup lifecycle with agent-internal KV or user SQL data.
96#[cfg(feature = "sqlite")]
97pub fn ts_path() -> Result<PathBuf, String> {
98 if let Some(v) = std::env::var_os("AGENT_BLOCK_TS_PATH") {
99 return Ok(PathBuf::from(v));
100 }
101 Ok(base_dir()?.join("ts.sqlite"))
102}
103
104/// The directory one project's kernel database lives in, as a single name.
105///
106/// The project root's absolute path with every separator replaced by `-`, so
107/// `/home/u/projects/x` becomes `-home-u-projects-x` — the leading separator
108/// keeps its dash, and nothing is hashed. A slug is therefore *readable*: the
109/// project a database belongs to can be told from the directory listing, which
110/// is the whole reason for the form (it is the one `.claude/projects/<slug>`
111/// already uses, so one habit reads both).
112///
113/// The caller passes the root it resolved — [`crate::host::HostContext`]'s,
114/// which is canonicalized — because a relative path would slug two different
115/// projects to the same name.
116pub fn project_slug(project_root: &Path) -> String {
117 project_root
118 .to_string_lossy()
119 .chars()
120 .map(|c| {
121 if c == '/' || c == std::path::MAIN_SEPARATOR {
122 '-'
123 } else {
124 c
125 }
126 })
127 .collect()
128}
129
130/// Path to the kernel's SQLite database for the project rooted at
131/// `project_root`.
132///
133/// `AGENT_BLOCK_KNL_PATH` → `{base_dir}/projects/<slug>/knl.sqlite`.
134///
135/// Per project rather than per host, unlike the three above: every session a
136/// script opens without naming a `store` is a stream in this one file, so the
137/// sessions of one project — a tree opened from a default parent included —
138/// share a database and can be read back with one statement.
139pub fn knl_path(project_root: &Path) -> Result<PathBuf, String> {
140 resolve_knl_path(
141 std::env::var_os("AGENT_BLOCK_KNL_PATH").map(PathBuf::from),
142 project_root,
143 )
144}
145
146/// [`knl_path`] with the override handed in rather than read.
147///
148/// The env read is the one thing a test cannot do twice at once, so it stays
149/// in the caller above and the rule — an override wins whole, otherwise the
150/// per-project default — is a function that can be asked directly.
151fn resolve_knl_path(
152 override_path: Option<PathBuf>,
153 project_root: &Path,
154) -> Result<PathBuf, String> {
155 if let Some(path) = override_path {
156 return Ok(path);
157 }
158 Ok(base_dir()?
159 .join("projects")
160 .join(project_slug(project_root))
161 .join("knl.sqlite"))
162}
163
164/// True when the resolved path is SQLite's in-memory sentinel.
165#[cfg(feature = "sqlite")]
166pub fn is_memory_sql(path: &std::path::Path) -> bool {
167 path.as_os_str() == ":memory:"
168}
169
170/// SQLite busy_timeout.
171/// `AGENT_BLOCK_SQL_BUSY_TIMEOUT_MS` → 5000ms.
172#[cfg(feature = "sqlite")]
173pub fn sql_busy_timeout() -> Duration {
174 let ms = std::env::var("AGENT_BLOCK_SQL_BUSY_TIMEOUT_MS")
175 .ok()
176 .and_then(|s| s.parse::<u64>().ok())
177 .unwrap_or(DEFAULT_SQL_BUSY_TIMEOUT_MS);
178 Duration::from_millis(ms)
179}
180
181/// SQLite journal_mode pragma value.
182/// `AGENT_BLOCK_SQL_JOURNAL_MODE` → `WAL`.
183#[cfg(feature = "sqlite")]
184pub fn sql_journal_mode() -> String {
185 std::env::var("AGENT_BLOCK_SQL_JOURNAL_MODE")
186 .unwrap_or_else(|_| DEFAULT_SQL_JOURNAL_MODE.to_string())
187}
188
189/// Per-query timeout. `0` disables the timeout.
190/// `AGENT_BLOCK_SQL_QUERY_TIMEOUT_MS` → 5000ms.
191#[cfg(feature = "sqlite")]
192pub fn sql_query_timeout() -> Option<Duration> {
193 let ms = std::env::var("AGENT_BLOCK_SQL_QUERY_TIMEOUT_MS")
194 .ok()
195 .and_then(|s| s.parse::<u64>().ok())
196 .unwrap_or(DEFAULT_SQL_QUERY_TIMEOUT_MS);
197 if ms == 0 {
198 None
199 } else {
200 Some(Duration::from_millis(ms))
201 }
202}
203
204/// EventBus bounded mpsc capacity.
205/// `AGENT_BLOCK_BUS_CAPACITY` → 64. Parse failures warn and fall back.
206pub fn bus_capacity() -> usize {
207 match std::env::var("AGENT_BLOCK_BUS_CAPACITY") {
208 Ok(v) => v.parse::<usize>().unwrap_or_else(|e| {
209 tracing::warn!(
210 value = %v,
211 error = %e,
212 default = DEFAULT_BUS_CAPACITY,
213 "AGENT_BLOCK_BUS_CAPACITY parse failed, using default"
214 );
215 DEFAULT_BUS_CAPACITY
216 }),
217 Err(_) => DEFAULT_BUS_CAPACITY,
218 }
219}
220
221/// SIGTERM/SIGINT grace window (ms) shared by `std.task.with_timeout` and the
222/// EventBus shutdown path.
223/// `AGENT_BLOCK_TASK_GRACE_MS` → 1000. Parse failures warn and fall back.
224pub fn task_grace_ms() -> u64 {
225 match std::env::var("AGENT_BLOCK_TASK_GRACE_MS") {
226 Ok(v) => v.parse::<u64>().unwrap_or_else(|e| {
227 tracing::warn!(
228 value = %v,
229 error = %e,
230 default = DEFAULT_TASK_GRACE_MS,
231 "AGENT_BLOCK_TASK_GRACE_MS parse failed, using default"
232 );
233 DEFAULT_TASK_GRACE_MS
234 }),
235 Err(_) => DEFAULT_TASK_GRACE_MS,
236 }
237}
238
239/// Whether `sh.exec` puts each command in a process group of its own.
240/// `AGENT_BLOCK_SH_PROCESS_GROUP` → on. Any of `0` / `false` / `no` / `off`
241/// turns it off; anything else, including unset, leaves it on.
242///
243/// On, a timeout kills the group, so the descendants of the command go with
244/// it — `sh -c "cargo test"` leaves no test binary behind. Off, the command
245/// shares the host's group and a timeout reaches only the command itself,
246/// which is what this knob exists to let a caller choose: a group of its own
247/// also means the terminal's Ctrl-C no longer reaches the command directly.
248/// The host forwards it (see `sh::install_signal_cleanup`), so the effect is
249/// the same for anyone who has not replaced that path.
250pub fn sh_process_group() -> bool {
251 match std::env::var("AGENT_BLOCK_SH_PROCESS_GROUP") {
252 Ok(v) => !matches!(
253 v.trim().to_ascii_lowercase().as_str(),
254 "0" | "false" | "no" | "off"
255 ),
256 Err(_) => true,
257 }
258}
259
260/// The kernel database's resolution, held to the two rules it has.
261#[cfg(test)]
262mod knl_path_tests {
263 use super::*;
264
265 /// The slug is the path, readable, with the separators turned into
266 /// dashes — the leading one included.
267 #[test]
268 fn a_slug_is_the_project_path_with_dashes() {
269 assert_eq!(
270 project_slug(Path::new("/home/u/projects/x")),
271 "-home-u-projects-x"
272 );
273 assert_eq!(project_slug(Path::new("/")), "-");
274 }
275
276 /// Two projects are two directories, so two databases: the slug is what
277 /// keeps one project's sessions out of another's file.
278 #[test]
279 fn two_projects_slug_apart() {
280 assert_ne!(
281 project_slug(Path::new("/home/u/a")),
282 project_slug(Path::new("/home/u/b"))
283 );
284 }
285
286 /// The default lands under `projects/<slug>/knl.sqlite`. Asserted on the
287 /// tail rather than the whole path, because the head is `base_dir`'s and
288 /// that is the env's to say.
289 #[test]
290 fn the_default_is_per_project() {
291 let path = resolve_knl_path(None, Path::new("/home/u/projects/x")).expect("resolved");
292 assert!(
293 path.ends_with("projects/-home-u-projects-x/knl.sqlite"),
294 "{}",
295 path.display()
296 );
297 }
298
299 /// An override is taken whole: neither the base dir nor the slug is
300 /// appended to it, so a caller that names a file gets that file.
301 #[test]
302 fn an_override_wins() {
303 let path = resolve_knl_path(
304 Some(PathBuf::from("/tmp/elsewhere/knl.sqlite")),
305 Path::new("/home/u/projects/x"),
306 )
307 .expect("resolved");
308 assert_eq!(path, PathBuf::from("/tmp/elsewhere/knl.sqlite"));
309 }
310}