1use std::io::{self, BufRead, Write};
9use std::path::PathBuf;
10use std::sync::Mutex;
11
12use serde_json::{json, Value};
13
14use crate::kb::{
15 AppraiseParams, KnowledgeBase, RecallParams, RecordParams, Situation, APPRAISE_ADVISORY,
16};
17
18#[cfg(target_os = "linux")]
19use libc;
20
21const TOOLS: &[(&str, &str)] = &[
23 ("innate_recall", "Call FIRST at the start of any task — retrieve relevant knowledge from the knowledge base and get a trace_id for subsequent recording."),
24 ("innate_record", "Call LAST after completing any task — close the trace_id from recall with outcome ok/fail/unknown and optional feedback."),
25 ("innate_appraise", "Critic check — given a situation (and optional candidate answer), return how much footing exists: {valence, strength, tier, confidence, dispersion, abstained, abstain_reason, flagged_points}. May abstain (abstained=true) when there is no footing — abstaining is correct, not a failure. Never returns an answer. Use to gut-check before committing to a risky step; flagged_points = things to be careful about."),
26 ("innate_add", "Capture a confirmed insight as a knowledge chunk (always starts as pending for agent source)."),
27 ("innate_spark", "Save a quick idea / hypothesis for later incubation."),
28 ("innate_inspect", "Show knowledge base health: chunk counts, debt ratio, embed rebuild queue."),
29 ("innate_evolve", "Call at session end — distil episodic logs into pending chunks and run curate cycle (archive / decay / promote). Pass rebuild_embeddings=true to also rebuild the embedding index."),
30 ("innate_approve", "Approve a pending chunk, making it active."),
31 ("innate_archive", "Archive a knowledge chunk."),
32 ("innate_invalidate", "Invalidate a chunk and blacklist its content hash."),
33 ("innate_restore", "Restore an archived chunk to active."),
34 ("innate_mature_spark", "Advance a spark maturity: seed → sprouting → incubating."),
35 ("innate_promote_spark", "Promote a spark to a full knowledge chunk."),
36 ("innate_drop_spark", "Drop (abandon) a spark."),
37 ("innate_backup", "Backup or inspect R2 backups. action: run=backup now (honours interval unless force=true), status=show state, list=list backups in R2, prune=delete old backups."),
38];
39
40fn default_mcp_log() -> PathBuf {
41 crate::paths::mcp_log_path()
42}
43
44fn mcp_log(log: &Mutex<Box<dyn io::Write + Send>>, msg: &str) {
45 let ts = crate::utils::utc_now_iso();
46 if let Ok(mut w) = log.lock() {
47 let _ = writeln!(w, "{ts} {msg}");
48 }
49}
50
51pub fn run_server(db_path: PathBuf) -> anyhow::Result<()> {
52 crate::paths::ensure_layout();
53 maybe_auto_start_daemon(&db_path);
54
55 let log_path = default_mcp_log();
56 if let Some(p) = log_path.parent() {
57 let _ = std::fs::create_dir_all(p);
58 }
59 let log: Mutex<Box<dyn io::Write + Send>> = Mutex::new(
60 match std::fs::OpenOptions::new()
61 .create(true)
62 .append(true)
63 .open(&log_path)
64 {
65 Ok(f) => Box::new(f),
66 Err(_) => Box::new(io::sink()),
67 },
68 );
69 mcp_log(&log, &format!("[mcp] started db={}", db_path.display()));
70
71 let kb = Mutex::new(crate::open_kb(&db_path)?);
72 let stdin = io::stdin();
73 let stdout = io::stdout();
74
75 for line in stdin.lock().lines() {
76 let line = line?;
77 if line.trim().is_empty() {
78 continue;
79 }
80
81 let req: Value = match serde_json::from_str(&line) {
82 Ok(v) => v,
83 Err(e) => {
84 mcp_log(&log, &format!("[mcp] parse error: {e}"));
85 write_response(
86 &stdout,
87 json!({
88 "jsonrpc": "2.0",
89 "error": {"code": -32700, "message": format!("Parse error: {e}")},
90 "id": null
91 }),
92 )?;
93 continue;
94 }
95 };
96
97 let id = req.get("id").cloned().unwrap_or(Value::Null);
98 let method = req.get("method").and_then(Value::as_str).unwrap_or("");
99 let params = req.get("params").cloned().unwrap_or(json!({}));
100
101 let response = match method {
102 "initialize" => handle_initialize(&id),
103 "tools/list" => handle_tools_list(&id),
104 "tools/call" => handle_tool_call(&kb, &id, ¶ms, &log),
105 "notifications/initialized" | "ping" => continue,
106 _ => json!({
107 "jsonrpc": "2.0",
108 "error": {"code": -32601, "message": format!("Method not found: {method}")},
109 "id": id
110 }),
111 };
112
113 write_response(&stdout, response)?;
114 }
115 Ok(())
116}
117
118fn write_response(stdout: &io::Stdout, v: Value) -> io::Result<()> {
119 let mut out = stdout.lock();
120 writeln!(out, "{}", serde_json::to_string(&v).unwrap_or_default())?;
121 out.flush()
122}
123
124fn handle_initialize(id: &Value) -> Value {
125 json!({
126 "jsonrpc": "2.0",
127 "id": id,
128 "result": {
129 "protocolVersion": "2024-11-05",
130 "capabilities": {"tools": {}},
131 "serverInfo": {
132 "name": "innate",
133 "version": env!("CARGO_PKG_VERSION")
134 }
135 }
136 })
137}
138
139fn handle_tools_list(id: &Value) -> Value {
140 let tools: Vec<Value> = TOOLS
141 .iter()
142 .map(|(name, desc)| {
143 json!({
144 "name": name,
145 "description": desc,
146 "inputSchema": tool_schema(name)
147 })
148 })
149 .collect();
150 json!({"jsonrpc": "2.0", "id": id, "result": {"tools": tools}})
151}
152
153fn handle_tool_call(
154 kb: &Mutex<KnowledgeBase>,
155 id: &Value,
156 params: &Value,
157 log: &Mutex<Box<dyn io::Write + Send>>,
158) -> Value {
159 let name = params.get("name").and_then(Value::as_str).unwrap_or("");
160 let args = params.get("arguments").cloned().unwrap_or(json!({}));
161
162 let result = {
163 let kb = kb.lock().unwrap_or_else(|e| e.into_inner());
164 dispatch(&kb, name, &args)
165 };
166
167 match result {
168 Ok(v) => {
169 mcp_log(log, &format!("[mcp] tool={name} ok"));
170 json!({
171 "jsonrpc": "2.0",
172 "id": id,
173 "result": {
174 "content": [{"type": "text", "text": v.to_string()}],
175 "isError": false
176 }
177 })
178 }
179 Err(e) => {
180 mcp_log(log, &format!("[mcp] tool={name} err={e}"));
181 json!({
182 "jsonrpc": "2.0",
183 "id": id,
184 "result": {
185 "content": [{"type": "text", "text": format!("error: {e}")}],
186 "isError": true
187 }
188 })
189 }
190 }
191}
192
193fn dispatch(kb: &KnowledgeBase, name: &str, args: &Value) -> crate::errors::Result<Value> {
194 let s = |key: &str| {
195 args.get(key)
196 .and_then(Value::as_str)
197 .unwrap_or("")
198 .to_string()
199 };
200 let so = |key: &str| args.get(key).and_then(Value::as_str).map(str::to_string);
201 let b = |key: &str, d: bool| args.get(key).and_then(Value::as_bool).unwrap_or(d);
202 let n = |key: &str, d: i64| args.get(key).and_then(Value::as_i64).unwrap_or(d);
203 let arr = |key: &str| -> Vec<String> {
204 args.get(key)
205 .and_then(Value::as_array)
206 .map(|a| {
207 a.iter()
208 .filter_map(|v| v.as_str().map(str::to_string))
209 .collect()
210 })
211 .unwrap_or_default()
212 };
213
214 match name {
215 "innate_recall" => {
216 let query = s("query");
217 let budget = n("budget", 6000) as usize;
218 let top = args.get("top").and_then(Value::as_u64).map(|v| v as usize);
219 let include_sparks = b("include_sparks", false);
220 let source = if s("source").is_empty() {
221 "mcp".to_string()
222 } else {
223 s("source")
224 };
225 let expand_deps = if s("expand_deps").is_empty() {
226 "false".to_string()
227 } else {
228 s("expand_deps")
229 };
230 let allow_trim = b("allow_trim", false);
231 let refine_mode = if s("refine_mode").is_empty() {
232 "off".to_string()
233 } else {
234 s("refine_mode")
235 };
236 let min_score = args.get("min_score").and_then(Value::as_f64);
237 let result = kb.recall(RecallParams {
238 query: &query,
239 budget,
240 trace: true,
241 include_sparks,
242 top,
243 source: &source,
244 expand_deps: &expand_deps,
245 allow_trim,
246 refine_mode: &refine_mode,
247 min_score,
248 session_only: false,
249 rerank: false,
251 })?;
252 Ok(json!({
253 "trace_id": result.trace_id,
254 "knowledge": result.knowledge,
255 "sparks": result.sparks,
256 "empty": result.empty,
257 }))
258 }
259 "innate_appraise" => {
260 let query = s("query");
261 let last_error = so("last_error");
262 let stage = so("stage");
263 let file_context = so("file_context");
264 let candidate = so("candidate");
265 let recent_actions = arr("recent_actions");
266 let top = args.get("top").and_then(Value::as_u64).map(|v| v as usize);
267 let min_strength = args.get("min_strength").and_then(Value::as_f64);
268 let source = if s("source").is_empty() {
269 "mcp".to_string()
270 } else {
271 s("source")
272 };
273 let situation = Situation {
274 query: (!query.is_empty()).then_some(query.as_str()),
275 last_error: last_error.as_deref(),
276 recent_actions: &recent_actions,
277 stage: stage.as_deref(),
278 file_context: file_context.as_deref(),
279 };
280 let verdict = kb.appraise(AppraiseParams {
281 situation,
282 candidate: candidate.as_deref(),
283 min_strength,
284 top,
285 trace: true,
286 source: &source,
287 })?;
288 Ok(json!({
289 "advisory": APPRAISE_ADVISORY,
291 "valence": verdict.valence,
292 "strength": verdict.strength,
293 "tier": verdict.tier,
294 "confidence": verdict.confidence,
295 "dispersion": verdict.dispersion,
296 "abstained": verdict.abstained,
297 "abstain_reason": verdict.abstain_reason,
298 "flagged_points": verdict.flagged_points,
299 "contributors": verdict.contributors,
300 "trace_id": verdict.trace_id,
301 }))
302 }
303 "innate_record" => {
304 let trace_id = s("trace_id");
305 let outcome = so("outcome");
306 let used = arr("used");
307 let used_ref: Option<&[String]> = args
308 .get("used")
309 .and_then(Value::as_array)
310 .map(|_| used.as_slice());
311 let fb_up = arr("feedback_up");
312 let fb_up_ref: Option<&[String]> = if fb_up.is_empty() { None } else { Some(&fb_up) };
313 let fb_down = arr("feedback_down");
314 let fb_down_ref: Option<&[String]> = if fb_down.is_empty() {
315 None
316 } else {
317 Some(&fb_down)
318 };
319 let source = if s("source").is_empty() {
320 "mcp".to_string()
321 } else {
322 s("source")
323 };
324 let used_attribution = if s("used_attribution").is_empty() {
325 "explicit".to_string()
326 } else {
327 s("used_attribution")
328 };
329 let feedback_kind = if s("feedback_kind").is_empty() {
330 "user".to_string()
331 } else {
332 s("feedback_kind")
333 };
334 let query = so("query");
335 let output = so("output");
336 let output_summary = so("output_summary");
337 let feedback_actor = so("feedback_actor");
338 let feedback_reason = so("feedback_reason");
339 let nomination = so("nomination");
340 let task_state = so("task_state");
341 kb.record(RecordParams {
342 trace_id: &trace_id,
343 query: query.as_deref(),
344 output: output.as_deref(),
345 output_summary: output_summary.as_deref(),
346 outcome: outcome.as_deref(),
347 used: used_ref,
348 used_attribution: &used_attribution,
349 used_complete: Some(b("used_complete", true)),
350 feedback_up: fb_up_ref,
351 feedback_down: fb_down_ref,
352 feedback_kind: &feedback_kind,
353 feedback_actor: feedback_actor.as_deref(),
354 feedback_reason: feedback_reason.as_deref(),
355 nomination: nomination.as_deref(),
356 priority: n("priority", 0),
357 task_state: task_state.as_deref(),
358 source: &source,
359 verdict_heeded: b("verdict_heeded", false),
360 })?;
361 Ok(json!({"ok": true}))
362 }
363 "innate_add" => {
364 let content = s("content");
365 let kind = if s("kind").is_empty() {
366 "note".to_string()
367 } else {
368 s("kind")
369 };
370 let source = if s("source").is_empty() {
371 "agent".to_string()
372 } else {
373 s("source")
374 };
375 let dep_kind = if s("dep_kind").is_empty() {
376 "hard".to_string()
377 } else {
378 s("dep_kind")
379 };
380 let deps: Vec<(String, String)> = arr("depends_on")
381 .into_iter()
382 .map(|d| (d, dep_kind.clone()))
383 .collect();
384 let id = kb.add_with_deps(
385 &content,
386 &kind,
387 so("trigger_desc").as_deref(),
388 so("anti_trigger_desc").as_deref(),
389 &source,
390 so("skill_name").as_deref(),
391 &deps,
392 )?;
393 Ok(json!({"chunk_id": id}))
394 }
395 "innate_spark" => {
396 let content = s("content");
397 let id = kb.spark(
398 &content,
399 so("trigger_desc").as_deref(),
400 so("anti_trigger_desc").as_deref(),
401 )?;
402 Ok(json!({"chunk_id": id}))
403 }
404 "innate_inspect" => kb.inspect(),
405 "innate_evolve" => {
406 let trigger = if s("trigger").is_empty() {
407 "manual".to_string()
408 } else {
409 s("trigger")
410 };
411 if b("rebuild_embeddings", false) {
412 let cap = n("rebuild_max", 200);
417 let max = if cap <= 0 { None } else { Some(cap as usize) };
418 let (rebuilt, remaining) = kb.rebuild_embeddings_capped(max)?;
419 let evolve = kb.evolve(&trigger)?;
420 return Ok(json!({
421 "rebuilt_embeddings": rebuilt,
422 "rebuild_remaining": remaining,
423 "evolve": evolve,
424 }));
425 }
426 kb.evolve(&trigger)
427 }
428 "innate_approve" => {
429 kb.approve(&s("chunk_id"))?;
430 Ok(json!({"ok": true}))
431 }
432 "innate_archive" => {
433 let reason = s("reason");
434 let reason = if reason.is_empty() { "stale" } else { &reason };
435 kb.archive(&s("chunk_id"), reason)?;
436 Ok(json!({"ok": true}))
437 }
438 "innate_invalidate" => {
439 kb.invalidate(&s("chunk_id"), &s("reason"))?;
440 Ok(json!({"ok": true}))
441 }
442 "innate_restore" => {
443 kb.restore(&s("chunk_id"))?;
444 Ok(json!({"ok": true}))
445 }
446 "innate_mature_spark" => {
447 kb.mature_spark(&s("spark_id"), &s("to"))?;
448 Ok(json!({"ok": true}))
449 }
450 "innate_promote_spark" => {
451 let to = if s("to").is_empty() {
452 "note".to_string()
453 } else {
454 s("to")
455 };
456 let new_id = kb.promote_spark(&s("spark_id"), &to)?;
457 Ok(json!({"chunk_id": new_id}))
458 }
459 "innate_drop_spark" => {
460 kb.drop_spark(&s("spark_id"), &s("reason"))?;
461 Ok(json!({"ok": true}))
462 }
463 "innate_backup" => {
464 let db_path = kb.storage.db_path.clone();
465 dispatch_backup(&s("action"), b("force", false), &db_path)
466 }
467 _ => Err(crate::errors::InnateError::Other(format!(
468 "unknown tool: {name}"
469 ))),
470 }
471}
472
473fn tool_schema(name: &str) -> Value {
474 match name {
475 "innate_recall" => json!({
476 "type": "object",
477 "properties": {
478 "query": {"type": "string", "description": "Search query"},
479 "budget": {"type": "integer", "description": "Token budget (default 6000)"},
480 "top": {"type": "integer", "description": "Max results"},
481 "include_sparks": {"type": "boolean"},
482 "expand_deps": {"type": "string", "enum": ["false","direct","closure"], "description": "Dependency expansion: false (default) | direct | closure"},
483 "source": {"type": "string", "enum": ["mcp","sdk","cli","hook","daemon","augmented"]},
484 "min_score": {"type": "number", "description": "Relevance gate applied before the pending lifecycle penalty; final ranking remains penalized (omit to disable)"}
485 },
486 "required": ["query"]
487 }),
488 "innate_appraise" => json!({
489 "type": "object",
490 "properties": {
491 "query": {"type": "string", "description": "Explicit question/instruction (optional)"},
492 "last_error": {"type": "string", "description": "Current or last error text"},
493 "recent_actions": {"type": "array", "items": {"type": "string"}, "description": "Last few actions taken"},
494 "stage": {"type": "string", "description": "Task stage, e.g. merge | implement | review"},
495 "file_context": {"type": "string", "description": "File type/path summary in scope"},
496 "candidate": {"type": "string", "description": "Candidate answer under judgement (sanitized, never echoed back)"},
497 "top": {"type": "integer"},
498 "min_strength": {"type": "number"},
499 "source": {"type": "string", "enum": ["mcp","sdk","cli","hook","daemon","augmented"]}
500 }
501 }),
502 "innate_record" => json!({
503 "type": "object",
504 "properties": {
505 "trace_id": {"type": "string"},
506 "query": {"type": "string", "description": "Original query from the corresponding recall"},
507 "output": {"type": "string", "description": "Raw task output (optional, for distillation)"},
508 "outcome": {"type": "string", "enum": ["ok","fail","unknown"]},
509 "used": {"type": "array", "items": {"type": "string"}},
510 "used_attribution": {"type": "string", "enum": ["explicit","cited","inferred"]},
511 "used_complete": {"type": "boolean", "description": "Whether used exhaustively lists all selected chunks (default true)"},
512 "feedback_up": {"type": "array", "items": {"type": "string"}},
513 "feedback_down": {"type": "array", "items": {"type": "string"}},
514 "feedback_kind": {"type": "string", "enum": ["user","judge"]},
515 "feedback_actor": {"type": "string"},
516 "feedback_reason": {"type": "string"},
517 "task_state": {"type": "string", "enum": ["recalled","running","completed","abandoned","timed_out"]},
518 "output_summary": {"type": "string"},
519 "nomination": {"type": "string"},
520 "priority": {"type": "integer"},
521 "verdict_heeded": {"type": "boolean", "description": "Set when this trace came from an appraise whose caution was heeded (action avoided): the outcome is counterfactual and is excluded from the critic's calibration."},
522 "source": {"type": "string", "enum": ["mcp","sdk","cli","hook","daemon","augmented"]}
523 },
524 "required": ["trace_id"]
525 }),
526 "innate_add" => json!({
527 "type": "object",
528 "properties": {
529 "content": {"type": "string"},
530 "kind": {"type": "string", "enum": ["note","skill"]},
531 "trigger_desc": {"type": "string"},
532 "anti_trigger_desc": {"type": "string"},
533 "source": {"type": "string", "enum": ["chat","manual","doc","agent"]},
534 "skill_name": {"type": "string"},
535 "depends_on": {"type": "array", "items": {"type": "string"}, "description": "Chunk ids this chunk depends on"},
536 "dep_kind": {"type": "string", "enum": ["hard","soft"], "description": "Dependency kind for depends_on (default hard)"}
537 },
538 "required": ["content"]
539 }),
540 "innate_spark" => json!({
541 "type": "object",
542 "properties": {
543 "content": {"type": "string"},
544 "trigger_desc": {"type": "string"},
545 "anti_trigger_desc": {"type": "string"}
546 },
547 "required": ["content"]
548 }),
549 "innate_inspect" => json!({"type": "object", "properties": {}}),
550 "innate_evolve" => json!({
551 "type": "object",
552 "properties": {
553 "trigger": {"type": "string", "enum": ["manual","scheduled","threshold"]},
554 "rebuild_embeddings": {"type": "boolean", "description": "Also rebuild the embedding index before evolving"},
555 "rebuild_max": {"type": "integer", "description": "Max stale chunks to re-embed this call (default 200; 0 = unbounded). Bounds MCP latency; remainder reported as rebuild_remaining."}
556 }
557 }),
558 "innate_approve" | "innate_archive" | "innate_invalidate" | "innate_restore" => json!({
559 "type": "object",
560 "properties": {
561 "chunk_id": {"type": "string"},
562 "reason": {"type": "string"}
563 },
564 "required": ["chunk_id"]
565 }),
566 "innate_mature_spark" => json!({
567 "type": "object",
568 "properties": {
569 "spark_id": {"type": "string"},
570 "to": {"type": "string", "enum": ["sprouting","incubating"]}
571 },
572 "required": ["spark_id", "to"]
573 }),
574 "innate_promote_spark" => json!({
575 "type": "object",
576 "properties": {
577 "spark_id": {"type": "string"},
578 "to": {"type": "string", "enum": ["note","skill"]}
579 },
580 "required": ["spark_id"]
581 }),
582 "innate_drop_spark" => json!({
583 "type": "object",
584 "properties": {
585 "spark_id": {"type": "string"},
586 "reason": {"type": "string"}
587 },
588 "required": ["spark_id"]
589 }),
590 "innate_backup" => json!({
591 "type": "object",
592 "properties": {
593 "action": {
594 "type": "string",
595 "enum": ["run", "status", "list", "prune"],
596 "description": "run=backup now, status=show last backup state, list=list R2 backups, prune=delete old backups"
597 },
598 "force": {
599 "type": "boolean",
600 "description": "For action=run: skip the interval check and backup immediately (default false)"
601 }
602 },
603 "required": ["action"]
604 }),
605 _ => json!({"type": "object", "properties": {}}),
606 }
607}
608
609fn dispatch_backup(
614 action: &str,
615 force: bool,
616 db_path: &std::path::Path,
617) -> crate::errors::Result<Value> {
618 use crate::backup::R2BackupService;
619 use crate::errors::InnateError;
620
621 let settings = crate::settings::load()?;
622 let cfg = settings.backup.as_ref().ok_or_else(|| {
623 InnateError::Other(
624 "backup not configured — add a \"backup\" section with \"enable\": true \
625 and \"r2\" credentials to ~/.innate/settings.json"
626 .into(),
627 )
628 })?;
629 if !cfg.enable {
630 return Err(InnateError::Other(
631 "R2 backup is disabled (backup.enable = false). \
632 Set \"enable\": true in ~/.innate/settings.json to activate."
633 .into(),
634 ));
635 }
636 let r2_cfg = cfg.r2.as_ref().ok_or_else(|| {
637 InnateError::Other("backup.r2 not configured in ~/.innate/settings.json".into())
638 })?;
639
640 match action {
641 "run" => {
642 if !force && !R2BackupService::needs_backup(cfg.auto_backup_interval_hours) {
643 let state = R2BackupService::last_backup_state();
644 return Ok(json!({
645 "ok": false,
646 "reason": "not_due",
647 "last_backup_at": state.last_backup_at,
648 "interval_hours": cfg.auto_backup_interval_hours,
649 }));
650 }
651 let svc = R2BackupService::from_config(r2_cfg)
652 .map_err(|e| InnateError::Other(e.to_string()))?;
653 let result = svc
654 .backup_now(db_path, cfg.retention_days, cfg.min_backups)
655 .map_err(|e| InnateError::Other(e.to_string()))?;
656 Ok(json!({
657 "ok": true,
658 "key": result.key,
659 "size_bytes": result.size_bytes,
660 "pruned": result.prune.deleted,
661 "kept": result.prune.kept,
662 "protected_by_min": result.prune.protected_by_min,
663 }))
664 }
665 "status" => {
666 let state = R2BackupService::last_backup_state();
667 Ok(json!({
668 "bucket": r2_cfg.bucket,
669 "last_backup_at": state.last_backup_at,
670 "last_backup_key": state.last_backup_key,
671 "backup_due": R2BackupService::needs_backup(cfg.auto_backup_interval_hours),
672 "interval_hours": cfg.auto_backup_interval_hours,
673 "retention_days": cfg.retention_days,
674 "min_backups": cfg.min_backups,
675 }))
676 }
677 "list" => {
678 let svc = R2BackupService::from_config(r2_cfg)
679 .map_err(|e| InnateError::Other(e.to_string()))?;
680 let backups = svc
681 .list_backups()
682 .map_err(|e| InnateError::Other(e.to_string()))?;
683 Ok(json!({"backups": backups}))
684 }
685 "prune" => {
686 let svc = R2BackupService::from_config(r2_cfg)
687 .map_err(|e| InnateError::Other(e.to_string()))?;
688 let result = svc
689 .prune_old_backups(cfg.retention_days, cfg.min_backups)
690 .map_err(|e| InnateError::Other(e.to_string()))?;
691 Ok(json!({
692 "deleted": result.deleted,
693 "kept": result.kept,
694 "protected_by_min": result.protected_by_min,
695 }))
696 }
697 other => Err(InnateError::Other(format!(
698 "unknown backup action '{other}'; valid: run, status, list, prune"
699 ))),
700 }
701}
702
703fn default_pid_file() -> PathBuf {
708 crate::paths::daemon_pid_path()
709}
710
711fn maybe_auto_start_daemon(db_path: &std::path::Path) {
715 let s = crate::settings::load().unwrap_or_default();
718 match &s.daemon {
719 Some(c) if c.auto_start => {}
720 _ => return,
721 };
722 let watch_dirs = crate::settings::resolved_watch_dirs(&s);
723 if watch_dirs.is_empty() {
724 return;
725 }
726
727 let pid_file = default_pid_file();
728 if crate::daemon::is_running(&pid_file) {
729 return;
730 }
731
732 let Ok(exe) = std::env::current_exe() else {
734 return;
735 };
736 let mut cmd = std::process::Command::new(&exe);
737 cmd.arg("--db").arg(db_path);
739 cmd.arg("daemon").arg("start");
740 for dir in &watch_dirs {
741 cmd.arg("--watch").arg(dir);
742 }
743 cmd.stdin(std::process::Stdio::null())
745 .stdout(std::process::Stdio::null())
746 .stderr(std::process::Stdio::null());
747
748 #[cfg(target_os = "linux")]
751 {
752 use std::os::unix::process::CommandExt;
753 unsafe {
754 cmd.pre_exec(|| {
755 libc::setsid();
756 Ok(())
757 });
758 }
759 }
760
761 let _ = cmd.spawn(); }