heartbit_core/config/daemon.rs
1#![allow(missing_docs)]
2use serde::Deserialize;
3
4use crate::Error;
5
6/// Daemon mode configuration for runtime execution.
7///
8/// When `kafka` is absent, the daemon runs in HTTP-only mode: it serves
9/// `/v1/tasks/execute` for cloud-delegated execution but does not consume
10/// Kafka commands or produce events. This is the recommended mode for the
11/// 3-tier architecture where Kafka lives in the gateway, not the runtime.
12///
13/// Input-source fields (schedules, sensors, ws, telegram, heartbit_pulse,
14/// owner_emails, mcp_server) have been moved to the gateway crate as part
15/// of the 3-tier architecture refactoring. The type definitions are kept
16/// here so they can be re-used by the gateway.
17#[derive(Debug, Clone, Deserialize)]
18pub struct DaemonConfig {
19 #[serde(default)]
20 pub kafka: Option<KafkaConfig>,
21 #[serde(default = "default_daemon_bind")]
22 pub bind: String,
23 #[serde(default = "default_max_concurrent")]
24 pub max_concurrent_tasks: usize,
25 /// Prometheus metrics configuration. Metrics are enabled by default.
26 #[serde(default)]
27 pub metrics: Option<MetricsConfig>,
28 /// PostgreSQL URL for durable task persistence. When absent, tasks are
29 /// stored in-memory (lost on restart).
30 #[serde(default)]
31 pub database_url: Option<String>,
32 /// HTTP API authentication configuration.
33 pub auth: Option<AuthConfig>,
34 /// Memory access control configuration.
35 #[serde(default)]
36 pub memory: DaemonMemoryConfig,
37 /// Audit log retention configuration.
38 #[serde(default)]
39 pub audit: DaemonAuditConfig,
40 /// Idempotency-key TTL sweep configuration.
41 #[serde(default)]
42 pub idempotency: IdempotencyConfig,
43}
44
45/// MCP server configuration for the daemon.
46///
47/// When present, the daemon exposes an MCP-compatible endpoint at `/mcp`
48/// so external MCP clients can discover and call heartbit tools/resources.
49#[derive(Debug, Clone, Deserialize)]
50pub struct DaemonMcpServerConfig {
51 /// Server name reported in the `initialize` response. Defaults to `"heartbit"`.
52 #[serde(default = "default_mcp_server_name")]
53 pub name: String,
54 /// Whether to expose heartbit tools via MCP. Default: `true`.
55 #[serde(default = "super::default_true")]
56 pub expose_tools: bool,
57 /// Whether to expose resources (tasks, memory, knowledge) via MCP. Default: `true`.
58 #[serde(default = "super::default_true")]
59 pub expose_resources: bool,
60 /// Whether to expose prompts via MCP. Default: `false`.
61 #[serde(default)]
62 pub expose_prompts: bool,
63}
64
65fn default_mcp_server_name() -> String {
66 "heartbit".into()
67}
68
69/// Audit log retention configuration for the daemon.
70///
71/// Controls automatic pruning of old audit log entries.
72/// When `retain_days` is set and a Postgres store is attached, the daemon
73/// spawns a background task that deletes entries older than `retain_days`.
74#[derive(Debug, Clone, Default, Deserialize)]
75pub struct DaemonAuditConfig {
76 /// Number of days to retain audit log entries. Entries older than this
77 /// are deleted by the background prune task. `None` disables pruning.
78 #[serde(default)]
79 pub retain_days: Option<u32>,
80 /// Interval in minutes between prune runs. Defaults to 60 (hourly).
81 #[serde(default)]
82 pub prune_interval_minutes: Option<u64>,
83}
84
85/// Idempotency-key sweep settings.
86///
87/// When `ttl_hours` is `Some`, the daemon runs a background task that nulls
88/// out idempotency keys older than the TTL. The row itself is retained so
89/// existing primary-key lookups still work; only the dedup contract expires.
90#[derive(Debug, Clone, Default, Deserialize)]
91pub struct IdempotencyConfig {
92 /// Hours to retain idempotency keys before the sweep nulls them out.
93 /// Default `None` disables the sweep.
94 #[serde(default)]
95 pub ttl_hours: Option<u32>,
96 /// How often the sweep runs, in minutes. Default 60.
97 #[serde(default)]
98 pub sweep_interval_minutes: Option<u32>,
99}
100
101/// Memory access control configuration for the daemon.
102///
103/// Controls which users can write to shared institutional memory.
104#[derive(Debug, Clone, Default, Deserialize)]
105pub struct DaemonMemoryConfig {
106 /// Roles that are allowed to write to shared institutional memory.
107 ///
108 /// When empty (the default), all users can write — backward compatible.
109 /// When non-empty, only users with at least one of these roles can write.
110 ///
111 /// Example: `["admin", "knowledge_manager"]`
112 #[serde(default)]
113 pub shared_write_roles: Vec<String>,
114}
115
116/// HTTP API authentication configuration for the daemon.
117#[derive(Debug, Clone, Deserialize)]
118pub struct AuthConfig {
119 /// Bearer tokens that grant API access. Multiple tokens support key rotation.
120 #[serde(default)]
121 pub bearer_tokens: Vec<String>,
122 /// JWKS endpoint URL for JWT signature verification
123 /// (e.g. `"https://idp.example.com/.well-known/jwks.json"`).
124 pub jwks_url: Option<String>,
125 /// Expected JWT issuer (`iss` claim). Validated when present.
126 pub issuer: Option<String>,
127 /// Expected JWT audience (`aud` claim). Validated when present.
128 pub audience: Option<String>,
129 /// JWT claim to extract user ID from. Defaults to `"sub"`.
130 pub user_id_claim: Option<String>,
131 /// JWT claim to extract tenant ID from. Defaults to `"tid"`.
132 pub tenant_id_claim: Option<String>,
133 /// JWT claim to extract roles from. Defaults to `"roles"`.
134 pub roles_claim: Option<String>,
135 /// RFC 8693 Token Exchange configuration for per-user MCP auth delegation.
136 /// When configured, the daemon exchanges user JWTs for MCP-scoped delegated tokens.
137 pub token_exchange: Option<TokenExchangeConfig>,
138}
139
140/// RFC 8693 Token Exchange configuration for per-user MCP auth delegation.
141///
142/// When configured, each task submitted with a JWT gets a user-scoped delegated
143/// token injected into MCP requests. The daemon acts as the agent (actor) and
144/// exchanges the user's subject token for a scoped access token.
145#[derive(Debug, Clone, Deserialize)]
146pub struct TokenExchangeConfig {
147 /// Token exchange endpoint URL (e.g. `"https://idp.example.com/oauth/token"`).
148 pub exchange_url: String,
149 /// OAuth client ID for the daemon/agent.
150 pub client_id: String,
151 /// OAuth client secret for the daemon/agent.
152 pub client_secret: String,
153 /// NHI tenant ID — used for `X-Tenant-ID` header in `client_credentials` grant.
154 /// When set, `agent_token` is fetched and cached automatically; no static token needed.
155 pub tenant_id: Option<String>,
156 /// Static fallback agent token (`actor_token` in RFC 8693).
157 /// Used only when `tenant_id` is absent (backward-compat).
158 #[serde(default)]
159 pub agent_token: String,
160 /// OAuth scopes to request for the delegated token. Defaults to empty.
161 #[serde(default)]
162 pub scopes: Vec<String>,
163}
164
165/// Heartbit pulse configuration for autonomous periodic awareness.
166///
167/// When enabled, the daemon periodically reviews its persistent todo list
168/// and decides what to work on next — a cognitive pulse loop.
169#[derive(Debug, Clone, Deserialize)]
170pub struct HeartbitPulseConfig {
171 /// Enable the heartbit pulse. Defaults to `false`.
172 #[serde(default)]
173 pub enabled: bool,
174 /// Interval in seconds between heartbit pulse ticks. Defaults to 1800 (30 min).
175 #[serde(default = "default_pulse_interval")]
176 pub interval_seconds: u64,
177 /// Active hours window. When set, the pulse only fires within this window.
178 pub active_hours: Option<ActiveHoursConfig>,
179 /// Custom prompt override for the heartbit pulse. When absent, the
180 /// default built-in prompt is used.
181 pub prompt: Option<String>,
182 /// Number of consecutive HEARTBIT_OK responses before doubling the
183 /// interval (idle backoff). Defaults to 6 (3h at 30min interval).
184 #[serde(default = "default_idle_backoff_threshold")]
185 pub idle_backoff_threshold: u32,
186}
187
188fn default_pulse_interval() -> u64 {
189 1800
190}
191
192fn default_idle_backoff_threshold() -> u32 {
193 6
194}
195
196/// Active hours window for the heartbit pulse.
197#[derive(Debug, Clone, Deserialize)]
198pub struct ActiveHoursConfig {
199 /// Start time in "HH:MM" format (24-hour).
200 pub start: String,
201 /// End time in "HH:MM" format (24-hour).
202 pub end: String,
203}
204
205impl ActiveHoursConfig {
206 /// Parse the start hour and minute. Returns `(hour, minute)`.
207 pub fn parse_start(&self) -> Result<(u32, u32), Error> {
208 parse_hhmm(&self.start)
209 }
210
211 /// Parse the end hour and minute. Returns `(hour, minute)`.
212 pub fn parse_end(&self) -> Result<(u32, u32), Error> {
213 parse_hhmm(&self.end)
214 }
215}
216
217fn parse_hhmm(s: &str) -> Result<(u32, u32), Error> {
218 let parts: Vec<&str> = s.split(':').collect();
219 if parts.len() != 2 {
220 return Err(Error::Config(format!(
221 "invalid time format '{}': expected HH:MM",
222 s
223 )));
224 }
225 let hour: u32 = parts[0]
226 .parse()
227 .map_err(|_| Error::Config(format!("invalid hour in '{s}'")))?;
228 let minute: u32 = parts[1]
229 .parse()
230 .map_err(|_| Error::Config(format!("invalid minute in '{s}'")))?;
231 if hour > 23 || minute > 59 {
232 return Err(Error::Config(format!(
233 "time '{}' out of range (00:00-23:59)",
234 s
235 )));
236 }
237 Ok((hour, minute))
238}
239
240/// WebSocket configuration for bidirectional user↔agent communication.
241#[derive(Debug, Clone, Deserialize)]
242pub struct WsConfig {
243 /// Whether WebSocket endpoint is enabled. Defaults to `true`.
244 #[serde(default = "super::default_true")]
245 pub enabled: bool,
246 /// Timeout in seconds for blocking interactions (approval, input, question).
247 /// Defaults to 120 seconds.
248 #[serde(default = "default_interaction_timeout")]
249 pub interaction_timeout_seconds: u64,
250 /// Maximum concurrent WebSocket connections. Defaults to 100.
251 #[serde(default = "default_max_ws_connections")]
252 pub max_connections: usize,
253 /// PostgreSQL URL for durable session persistence. When absent, sessions
254 /// are stored in-memory (lost on restart).
255 #[serde(default)]
256 pub database_url: Option<String>,
257}
258
259fn default_interaction_timeout() -> u64 {
260 120
261}
262
263fn default_max_ws_connections() -> usize {
264 100
265}
266
267/// Kafka broker connection settings.
268#[derive(Debug, Clone, Deserialize)]
269pub struct KafkaConfig {
270 pub brokers: String,
271 #[serde(default = "default_consumer_group")]
272 pub consumer_group: String,
273 #[serde(default = "default_commands_topic")]
274 pub commands_topic: String,
275 #[serde(default = "default_events_topic")]
276 pub events_topic: String,
277 /// Topic for events that failed triage processing.
278 #[serde(default = "default_dead_letter_topic")]
279 pub dead_letter_topic: String,
280}
281
282/// A scheduled task entry for the cron scheduler.
283#[derive(Debug, Clone, Deserialize)]
284pub struct ScheduleEntry {
285 pub name: String,
286 pub cron: String,
287 pub task: String,
288 #[serde(default = "super::default_true")]
289 pub enabled: bool,
290}
291
292/// Prometheus metrics configuration for daemon mode.
293#[derive(Debug, Clone, Deserialize)]
294pub struct MetricsConfig {
295 /// Whether Prometheus metrics are enabled. Defaults to `true`.
296 #[serde(default = "super::default_true")]
297 pub enabled: bool,
298}
299
300fn default_daemon_bind() -> String {
301 "127.0.0.1:3000".into()
302}
303
304fn default_max_concurrent() -> usize {
305 4
306}
307
308fn default_consumer_group() -> String {
309 "heartbit-daemon".into()
310}
311
312fn default_commands_topic() -> String {
313 "heartbit.commands".into()
314}
315
316fn default_events_topic() -> String {
317 "heartbit.events".into()
318}
319
320fn default_dead_letter_topic() -> String {
321 "heartbit.dead-letter".into()
322}