Skip to main content

innate_core/daemon/
watch.rs

1use super::{events::*, *};
2
3pub fn run_watch_loop(
4    watch_dirs: &[String],
5    db_path: &str,
6    state_db_path: &str,
7    log_path: &str,
8    pid_file: &str,
9) {
10    // Write our own pid.
11    let _ = std::fs::write(pid_file, std::process::id().to_string());
12
13    // Open log file (append).
14    let log_file = std::fs::OpenOptions::new()
15        .create(true)
16        .append(true)
17        .open(log_path);
18
19    let mut logger: Box<dyn std::io::Write + Send> = match log_file {
20        Ok(f) => Box::new(f),
21        Err(_) => Box::new(std::io::stderr()),
22    };
23
24    let _ = writeln!(logger, "[innate-daemon] started pid={}", std::process::id());
25
26    let state_db = match rusqlite::Connection::open(state_db_path) {
27        Ok(c) => c,
28        Err(e) => {
29            let _ = writeln!(logger, "[innate-daemon] cannot open state db: {e}");
30            return;
31        }
32    };
33    if state_db.execute_batch(DAEMON_SCHEMA).is_err() {
34        let _ = writeln!(logger, "[innate-daemon] failed to init schema");
35        return;
36    }
37
38    // Main poll loop: 500 ms tick.
39    let mut last_evolve_poll = std::time::Instant::now();
40    const EVOLVE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
41    let mut last_backup_poll = std::time::Instant::now();
42    const BACKUP_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30 * 60);
43    loop {
44        for dir in watch_dirs {
45            let dir_path = std::path::Path::new(dir);
46            if !dir_path.exists() {
47                continue;
48            }
49            // Find .log files in directory.
50            if let Ok(entries) = std::fs::read_dir(dir_path) {
51                for entry in entries.flatten() {
52                    let p = entry.path();
53                    if p.extension().and_then(|e| e.to_str()) == Some("log") {
54                        process_log_file(&p, &state_db, db_path, &mut *logger);
55                    }
56                }
57            }
58        }
59        // Periodically consume pending evolve_requests so knowledge grows even without session_end.
60        if last_evolve_poll.elapsed() >= EVOLVE_POLL_INTERVAL {
61            if let Err(error) = call_cli_evolve(db_path, "scheduled") {
62                let _ = writeln!(logger, "[innate-daemon] scheduled evolve failed: {error}");
63                record_daemon_error(
64                    &state_db,
65                    "<scheduler>",
66                    "scheduled_evolve",
67                    &error.to_string(),
68                );
69            }
70            last_evolve_poll = std::time::Instant::now();
71        }
72        if last_backup_poll.elapsed() >= BACKUP_POLL_INTERVAL {
73            // Only attempt a backup when R2 backup is actually configured + enabled.
74            // Otherwise `innate backup run` exits non-zero every cycle, spamming the log
75            // and inflating the daemon error counter with a non-error (settings.json is
76            // read here, never the db — preserves the daemon's no-db-open contract).
77            let backup_enabled = crate::settings::load()
78                .map(|s| s.backup.is_some_and(|b| b.enable && b.r2.is_some()))
79                .unwrap_or(false);
80            if backup_enabled {
81                if let Err(error) = call_cli_backup(db_path) {
82                    let _ = writeln!(logger, "[innate-daemon] auto-backup failed: {error}");
83                    record_daemon_error(
84                        &state_db,
85                        "<scheduler>",
86                        "auto_backup",
87                        &error.to_string(),
88                    );
89                }
90            }
91            last_backup_poll = std::time::Instant::now();
92        }
93        std::thread::sleep(std::time::Duration::from_millis(500));
94    }
95}
96
97pub(in crate::daemon) fn process_log_file(
98    path: &Path,
99    state_db: &rusqlite::Connection,
100    db_path: &str,
101    log: &mut dyn std::io::Write,
102) {
103    let path_str = path.to_string_lossy();
104    let meta = match std::fs::metadata(path) {
105        Ok(m) => m,
106        Err(_) => return,
107    };
108
109    // inode detection for rotation.
110    #[cfg(target_os = "linux")]
111    let inode = {
112        use std::os::linux::fs::MetadataExt;
113        meta.st_ino().to_string()
114    };
115    #[cfg(not(target_os = "linux"))]
116    let inode = String::new();
117
118    let (saved_offset, saved_inode): (i64, Option<String>) = state_db.query_row(
119        "SELECT last_processed_offset, last_processed_inode FROM watch_state WHERE watch_path=?",
120        rusqlite::params![path_str.as_ref()],
121        |r| Ok((r.get(0)?, r.get(1)?)),
122    ).unwrap_or((0, None));
123
124    // Reset on file rotation (inode change or file got shorter).
125    let file_size = meta.len() as i64;
126    let start_offset = if saved_inode.as_deref() != Some(&inode) || file_size < saved_offset {
127        0
128    } else {
129        saved_offset
130    };
131
132    if start_offset >= file_size {
133        return;
134    }
135
136    use std::io::{BufRead, Seek};
137    let mut f = match std::fs::File::open(path) {
138        Ok(f) => f,
139        Err(_) => return,
140    };
141    if f.seek(std::io::SeekFrom::Start(start_offset as u64))
142        .is_err()
143    {
144        return;
145    }
146
147    let mut reader = std::io::BufReader::new(&mut f);
148    let mut new_offset = start_offset;
149    let mut line_buf = String::new();
150
151    loop {
152        let line_start_offset = new_offset;
153        line_buf.clear();
154        let bytes_read = match reader.read_line(&mut line_buf) {
155            Ok(n) => n,
156            Err(_) => break,
157        };
158        if bytes_read == 0 {
159            break; // EOF
160        }
161        // Partial line at EOF (no trailing newline): leave offset before this line
162        // so it is re-read once the writer completes it.
163        if !line_buf.ends_with('\n') {
164            new_offset = line_start_offset;
165            break;
166        }
167        new_offset += bytes_read as i64;
168        let line = line_buf.trim_end_matches('\n').trim_end_matches('\r');
169
170        let Some(event) = parse_log_event(line) else {
171            continue;
172        };
173        let event_type = event.kind;
174
175        // Compute event_id for idempotency.
176        // Include inode so that a rotated file at the same path with the same
177        // offset + content is not mistakenly treated as a duplicate event.
178        let event_id = event
179            .event_id
180            .clone()
181            .unwrap_or_else(|| event_id_for_line(path_str.as_ref(), &inode, new_offset, line));
182
183        // Skip if already processed.
184        let already: i64 = state_db
185            .query_row(
186                "SELECT count(*) FROM processed_events WHERE event_id=?",
187                rusqlite::params![event_id],
188                |r| r.get(0),
189            )
190            .unwrap_or(0);
191        if already > 0 {
192            continue;
193        }
194
195        // The session-tracking chain (start → recall, ok/fail/feedback → record)
196        // is structurally incapable of producing knowledge, so it is not run:
197        //   - the hook emits outcome="unknown", which record() filters out, so
198        //     every such trace retires as abandoned → discarded;
199        //   - the daemon recalls with --session, which suppresses `selected`
200        //     events, so attribution validation rejects any `used` id anyway.
201        // It still cost one remote embedding call plus a full vector scan per
202        // session start, and the recalled knowledge was discarded on the spot.
203        // Reinstating it requires distillation eligibility to stop depending on
204        // a confidence-bearing outcome — until then this chain only burns quota.
205        // `end` is still handled below: its evolve trigger is the useful part.
206        if matches!(event_type, "start" | "ok" | "fail" | "feedback") {
207            continue;
208        }
209
210        // Only logs that carry their own trace_id can be retired; the daemon no
211        // longer opens traces of its own.
212        let trace_id = event.trace_id.clone();
213
214        if event_type == "end" {
215            if let Some(tid) = &trace_id {
216                if let Err(e) = call_cli_record(db_path, tid, &event) {
217                    let ts = crate::utils::utc_now_iso();
218                    let _ = writeln!(log, "{ts} [daemon] end trace retirement failed: {e}");
219                    record_daemon_error(
220                        state_db,
221                        path_str.as_ref(),
222                        "record_session_end",
223                        &e.to_string(),
224                    );
225                }
226            }
227            let result = call_cli_evolve(db_path, "manual");
228            let ts = crate::utils::utc_now_iso();
229            // The session has ended regardless of whether evolve succeeded, so the
230            // end-of-session bookkeeping must run on BOTH branches: otherwise a
231            // failed manual evolve loses the end event (never recorded in
232            // processed_events, so once the read offset advances it is neither
233            // retried nor logged). A failed manual evolve is independently retried
234            // by the periodic scheduled tick, so it must not block this cleanup.
235            // Errors here are surfaced to the log instead of being silently swallowed.
236            if let Err(e) = state_db.execute(
237                "INSERT OR IGNORE INTO processed_events
238                 (event_id, watch_path, trace_id, event_type, ts)
239                 VALUES (?,?,?,?,?)",
240                rusqlite::params![event_id, path_str.as_ref(), trace_id, event_type, ts],
241            ) {
242                let _ = writeln!(log, "{ts} [daemon] failed to record processed end event: {e}");
243            }
244            match result {
245                Ok(()) => {
246                    let _ = call_cli_evolve(db_path, "scheduled");
247                    let _ = writeln!(log, "{ts} [daemon] end evolve ok");
248                }
249                Err(e) => {
250                    let _ = writeln!(log, "{ts} [daemon] end evolve failed (scheduled tick will retry): {e}");
251                    record_daemon_error(state_db, path_str.as_ref(), "evolve", &e.to_string());
252                }
253            }
254            continue;
255        }
256
257    }
258
259    // Update watch_state.
260    let ts = crate::utils::utc_now_iso();
261    let _ = state_db.execute(
262        "INSERT OR REPLACE INTO watch_state(watch_path, last_processed_offset, last_processed_inode, updated_at)
263         VALUES (?,?,?,?)",
264        rusqlite::params![path_str.as_ref(), new_offset, inode, ts],
265    );
266}