Skip to main content

nexus_core/app/
watches.rs

1//! Standing research: `watches` re-run their topic's research on an
2//! interval, with no daemon — `due_watches` is checked once on app startup.
3
4use chrono::{DateTime, Utc};
5use std::fmt::Write as _;
6
7use crate::db::Watch;
8
9/// Watches whose interval has elapsed since their last run (or that have
10/// never run) as of `now`.
11pub fn due_watches(watches: &[Watch], now: DateTime<Utc>) -> Vec<Watch> {
12    watches
13        .iter()
14        .filter(|w| match &w.last_run_at {
15            None => true,
16            Some(t) => DateTime::parse_from_rfc3339(t).map_or(true, |last| {
17                now.signed_duration_since(last) >= chrono::Duration::hours(w.interval_hours)
18            }),
19        })
20        .cloned()
21        .collect()
22}
23
24/// A "## What changed since last run" section prepended to a watch's new
25/// report: lists newly-seen sources (by URL) not cited in the previous
26/// report. Does not diff prose — an LLM-generated summary of what changed
27/// is out of scope for this pass (YAGNI: a source-level diff is what a
28/// user actually scans for first).
29pub fn diff_section(previous_report: &str, new_report: &str, new_sources: &[String]) -> String {
30    let _ = (previous_report, new_report); // reserved for a future prose diff; unused today
31    let mut out = String::from("## What changed since last run\n\n");
32    if new_sources.is_empty() {
33        out.push_str("No new sources since the last run.\n");
34    } else {
35        out.push_str("New sources:\n");
36        for s in new_sources {
37            let _ = writeln!(out, "- {s}");
38        }
39    }
40    out
41}
42
43/// New (not-previously-cited) sources in `new_report` vs `previous_citations`
44/// — a plain set difference over normalized URLs.
45pub fn new_sources_since(new_report: &str, previous_citations: &[String]) -> Vec<String> {
46    let previous: std::collections::HashSet<String> = previous_citations
47        .iter()
48        .map(|u| crate::tools::normalize_url(u))
49        .collect();
50    crate::citations::parse_citations(new_report)
51        .into_iter()
52        .map(|(_, url)| url)
53        .filter(|url| !previous.contains(&crate::tools::normalize_url(url)))
54        .collect()
55}
56
57impl super::App {
58    /// `/watch <topic>` with no existing watch of that exact topic in this
59    /// space: create one (fixed 24h interval) plus its own research
60    /// session, and kick off the first run immediately (ungated).
61    pub fn create_watch(&mut self, topic: &str) {
62        if topic.is_empty() {
63            self.push_status("usage: /watch <topic>".to_string());
64            return;
65        }
66        self.start_research_with_gate(topic, false);
67        let Some(session) = &self.session else {
68            self.push_status("could not start watch: no session created".to_string());
69            return;
70        };
71        match self
72            .db
73            .create_watch(&self.active_space.id, topic, 24, &session.id)
74        {
75            Ok(_) => self.push_status(format!("watching: {topic} (every 24h)")),
76            Err(e) => self.push_status(format!("watch creation failed: {e}")),
77        }
78    }
79
80    /// The watch picker's confirm/delete flows live in the view layer; this
81    /// is the delete half: drop the row from the db and refresh the cache.
82    /// Returns whether a row existed.
83    pub fn delete_watch(&mut self, id: &str) -> anyhow::Result<bool> {
84        let existed = self.watches_cache.iter().any(|w| w.id == id);
85        if existed {
86            let _ = self.db.delete_watch(id);
87            self.watches_cache.retain(|x| x.id != id);
88        }
89        Ok(existed)
90    }
91
92    /// Startup hook: re-run every due watch (across all spaces) in the
93    /// background, ungated. Best-effort — a watch whose research job can't
94    /// start (e.g. no model configured) is silently skipped; it'll be
95    /// retried on the next app open since `last_run_at` isn't touched.
96    pub fn run_due_watches(&mut self) {
97        let Ok(all) = self.db.list_all_watches() else {
98            return;
99        };
100        let due = due_watches(&all, chrono::Utc::now());
101        for w in due {
102            let _ = self.run_one_watch(&w);
103        }
104    }
105
106    /// Start one watch's research job (due or not — `nexus watch run <id>`
107    /// force-runs). A watch may belong to a space other than whatever's
108    /// currently active — `start_research_with_gate` reads `self.active_space`
109    /// for the toolbox/file paths and `save_research_report`'s destination,
110    /// so it must be switched to the watch's own space for the run, same as
111    /// its session. Returns whether a job actually started.
112    pub fn run_one_watch(&mut self, w: &crate::db::Watch) -> bool {
113        let Ok(spaces) = self.db.list_spaces() else {
114            return false;
115        };
116        let Some(space_row) = spaces.into_iter().find(|s| s.id == w.space_id) else {
117            return false;
118        };
119        let restore_space = self.active_space.clone();
120        let restore_session = self.session.clone();
121        let restore_messages = std::mem::take(&mut self.messages);
122        let mut started = false;
123        if let Ok(Some(s)) = self.db.get_session(&w.session_id) {
124            let prior_session_id = s.id.clone();
125            self.active_space = space_row;
126            self.session = Some(s);
127            let _ = self.execute(super::AppCommand::RunResearch {
128                topic: w.topic.clone(),
129                gated: false,
130            });
131            // `start_research_with_gate` only allows one job at a time —
132            // for the 2nd+ due watch in the startup loop, `research_rx.is_some()`
133            // is still set from the first watch's job (it isn't cleared until
134            // that job's background task finishes, long after this synchronous
135            // loop returns), so the guard fires and the call above is a no-op:
136            // `self.session` is left exactly as set on the line above (the
137            // watch's *prior* session), unchanged. Only when a new session was
138            // actually created do we know the job really started — compare ids
139            // to tell those cases apart, and leave a not-actually-run watch
140            // untouched (still due) for the next startup rather than falsely
141            // marking it caught up.
142            if let Some(new_session) = self.session.as_ref()
143                && new_session.id != prior_session_id
144            {
145                let _ = self.db.set_watch_session(&w.id, &new_session.id);
146                let _ = self.db.touch_watch(&w.id, &chrono::Utc::now().to_rfc3339());
147                started = true;
148            }
149        }
150        self.active_space = restore_space;
151        self.session = restore_session;
152        self.messages = restore_messages;
153        self.refresh_toolbox();
154        started
155    }
156
157    /// `Some(urls)` if `session_id` is a watch's session and it has a prior
158    /// run (citations already indexed from an earlier `save_research_report`
159    /// call); `Ok(None)` for a first run or a non-watch session — either way
160    /// means "no diff section". Takes the report's own `space_id` rather than
161    /// reading `self.active_space`: this runs from `on_research_done`, which
162    /// fires asynchronously and may land well after the user (or
163    /// `run_due_watches`, which restores it right after spawning the job) has
164    /// switched the active space away from the one this job actually ran in.
165    pub fn previous_citations_for_watch_session(
166        &self,
167        session_id: &str,
168        space_id: &str,
169    ) -> anyhow::Result<Option<Vec<String>>> {
170        let Some(w) = self
171            .db
172            .list_all_watches()?
173            .into_iter()
174            .find(|w| w.session_id == session_id)
175        else {
176            return Ok(None);
177        };
178        // Scope to this watch's own prior report(s): `save_research_report`
179        // names every report it saves `research-<slug>-<timestamp>.md` where
180        // `slug = slugify(topic)`, and a watch's topic (hence its slug) is
181        // stable across re-runs. Filtering `report_file` to that prefix keeps
182        // an unrelated `/research` session elsewhere in the same space from
183        // suppressing a source as "not new" for this watch.
184        let slug = super::sessions::slugify(&w.topic);
185        let prefix = format!("research-{slug}-");
186        let rows = self.db.search_citations(space_id, Some(&prefix))?;
187        let rows: Vec<_> = rows
188            .into_iter()
189            .filter(|(report_file, _, _)| {
190                report_file.starts_with(&prefix)
191                    && report_file
192                        .chars()
193                        .nth(prefix.len())
194                        .is_some_and(|c| c.is_ascii_digit())
195            })
196            .collect();
197        if rows.is_empty() {
198            return Ok(None);
199        }
200        Ok(Some(rows.into_iter().map(|(_, url, _)| url).collect()))
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::app::App;
208    use crate::db::Db;
209    use crate::space::Space;
210
211    fn test_app() -> App {
212        let db = Db::open_in_memory().unwrap();
213        let root =
214            std::env::temp_dir().join(format!("nexus-watches-test-{}", uuid::Uuid::new_v4()));
215        std::fs::create_dir_all(root.join("spaces")).unwrap();
216        let space = Space { root };
217        App::new(db, Some("k"), space)
218    }
219
220    #[tokio::test]
221    async fn run_due_watches_repoints_the_watch_at_its_new_session() {
222        let mut a = test_app();
223        a.current_model = Some("openai/gpt-5-mini".to_string());
224        let space_id = a.active_space.id.clone();
225
226        // The watch's original session, from some earlier run.
227        let first_session =
228            a.db.create_session("first run", "openai/gpt-5-mini", &space_id, "chat")
229                .unwrap();
230        let watch_id =
231            a.db.create_watch(&space_id, "rust async runtimes", 24, &first_session.id)
232                .unwrap();
233
234        a.run_due_watches();
235
236        let updated =
237            a.db.list_all_watches()
238                .unwrap()
239                .into_iter()
240                .find(|w| w.id == watch_id)
241                .unwrap();
242        assert_ne!(
243            updated.session_id, first_session.id,
244            "run_due_watches should repoint the watch at the session its re-run actually used, \
245             not leave it pinned to the first run's session forever"
246        );
247        assert!(updated.last_run_at.is_some());
248    }
249
250    #[tokio::test]
251    async fn run_due_watches_only_touches_the_watch_whose_job_actually_started() {
252        let mut a = test_app();
253        a.current_model = Some("openai/gpt-5-mini".to_string());
254        let space_id = a.active_space.id.clone();
255
256        let first_session =
257            a.db.create_session("first run", "openai/gpt-5-mini", &space_id, "chat")
258                .unwrap();
259        let second_session =
260            a.db.create_session("second run", "openai/gpt-5-mini", &space_id, "chat")
261                .unwrap();
262        let watch_a =
263            a.db.create_watch(&space_id, "rust async runtimes", 24, &first_session.id)
264                .unwrap();
265        let watch_b =
266            a.db.create_watch(&space_id, "wasm gc proposal", 24, &second_session.id)
267                .unwrap();
268
269        // Only one research job can run at a time — the pipeline's guard
270        // (`research_rx.is_some()`) fires for the 2nd+ watch in this
271        // synchronous startup loop, since nothing clears `research_rx` until
272        // a background job later completes. So watch_a's run actually
273        // starts (fresh session, repointed + touched); watch_b's call is a
274        // no-op (guard fires) and must be left exactly as it was — still due
275        // — for the next startup.
276        a.run_due_watches();
277
278        let updated_a =
279            a.db.list_all_watches()
280                .unwrap()
281                .into_iter()
282                .find(|w| w.id == watch_a)
283                .unwrap();
284        let updated_b =
285            a.db.list_all_watches()
286                .unwrap()
287                .into_iter()
288                .find(|w| w.id == watch_b)
289                .unwrap();
290
291        assert_ne!(
292            updated_a.session_id, first_session.id,
293            "watch_a's job actually started, so it should be repointed at its new session"
294        );
295        assert!(
296            updated_a.last_run_at.is_some(),
297            "watch_a's job actually started, so it should be touched"
298        );
299
300        assert_eq!(
301            updated_b.session_id, second_session.id,
302            "watch_b's job never started (guard fired) — it must not be repointed"
303        );
304        assert!(
305            updated_b.last_run_at.is_none(),
306            "watch_b's job never started (guard fired) — touching it would falsely mark it caught up \
307             and make it silently skip a full interval"
308        );
309    }
310
311    fn watch(topic: &str, interval_hours: i64, last_run_at: Option<&str>) -> Watch {
312        Watch {
313            id: "w1".to_string(),
314            space_id: "space-1".to_string(),
315            topic: topic.to_string(),
316            interval_hours,
317            session_id: "sess-1".to_string(),
318            last_run_at: last_run_at.map(str::to_string),
319        }
320    }
321
322    #[test]
323    fn never_run_watch_is_always_due() {
324        let w = watch("topic", 24, None);
325        let now = chrono::DateTime::parse_from_rfc3339("2026-07-07T00:00:00+00:00")
326            .unwrap()
327            .to_utc();
328        assert_eq!(due_watches(&[w], now).len(), 1);
329    }
330
331    #[test]
332    fn watch_run_recently_is_not_due() {
333        let w = watch("topic", 24, Some("2026-07-07T00:00:00+00:00"));
334        let now = chrono::DateTime::parse_from_rfc3339("2026-07-07T05:00:00+00:00")
335            .unwrap()
336            .to_utc();
337        assert!(due_watches(&[w], now).is_empty());
338    }
339
340    #[test]
341    fn watch_past_its_interval_is_due() {
342        let w = watch("topic", 24, Some("2026-07-06T00:00:00+00:00"));
343        let now = chrono::DateTime::parse_from_rfc3339("2026-07-07T01:00:00+00:00")
344            .unwrap()
345            .to_utc();
346        assert_eq!(due_watches(&[w], now).len(), 1);
347    }
348
349    #[test]
350    fn diff_section_lists_new_sources_when_present() {
351        let section = diff_section(
352            "# Old Report\nOld body.",
353            "# New Report\nNew body.",
354            &["https://new-source.example".to_string()],
355        );
356        assert!(
357            section.contains("What changed since last run"),
358            "{section:?}"
359        );
360        assert!(
361            section.contains("https://new-source.example"),
362            "{section:?}"
363        );
364    }
365
366    #[test]
367    fn diff_section_empty_new_sources_still_produces_a_header() {
368        let section = diff_section("old", "new", &[]);
369        assert!(section.contains("What changed since last run"));
370        assert!(!section.contains("New sources"));
371    }
372
373    #[test]
374    fn new_sources_since_filters_out_previously_cited_urls() {
375        let new_report =
376            "Body [1][2].\n\n## Sources\n1. https://old.example/a\n2. https://fresh.example/b\n";
377        let previous = vec!["https://old.example/a".to_string()];
378        let new_sources = new_sources_since(new_report, &previous);
379        assert_eq!(new_sources, vec!["https://fresh.example/b".to_string()]);
380    }
381
382    #[test]
383    fn previous_citations_for_watch_session_is_scoped_to_the_watchs_own_reports() {
384        let a = test_app();
385        let space_id = a.active_space.id.clone();
386        let session =
387            a.db.create_session(
388                "rust async runtimes",
389                "openai/gpt-5-mini",
390                &space_id,
391                "chat",
392            )
393            .unwrap();
394        let watch_id =
395            a.db.create_watch(&space_id, "rust async runtimes", 24, &session.id)
396                .unwrap();
397        let _ = watch_id;
398
399        // This watch's own prior report (named per `save_research_report`'s
400        // `research-<slug>-<timestamp>.md` scheme, slug derived from its topic).
401        let slug = super::super::sessions::slugify("rust async runtimes");
402        a.db.add_citations(
403            &space_id,
404            &format!("research-{slug}-20260101-000000.md"),
405            &[("https://own-report.example".to_string(), None)],
406        )
407        .unwrap();
408
409        // An unrelated report elsewhere in the *same space* (a plain
410        // `/research` session, or another watch's topic) must not pollute
411        // this watch's diff.
412        a.db.add_citations(
413            &space_id,
414            "research-some-other-topic-20260101-000000.md",
415            &[("https://unrelated.example".to_string(), None)],
416        )
417        .unwrap();
418
419        let prev = a
420            .previous_citations_for_watch_session(&session.id, &space_id)
421            .unwrap();
422        let prev = prev.expect("watch session with prior citations should yield Some");
423        assert_eq!(prev, vec!["https://own-report.example".to_string()]);
424        assert!(
425            !prev.contains(&"https://unrelated.example".to_string()),
426            "an unrelated citation elsewhere in the space must not suppress a source as \
427             already-cited for this watch: {prev:?}"
428        );
429    }
430
431    #[test]
432    fn new_sources_since_normalizes_urls_before_comparing() {
433        // Trailing slash / scheme case differences shouldn't count as "new".
434        let new_report = "Body [1].\n\n## Sources\n1. https://Old.example/a/\n";
435        let previous = vec!["https://old.example/a".to_string()];
436        assert!(new_sources_since(new_report, &previous).is_empty());
437    }
438
439    #[test]
440    fn previous_citations_for_watch_session_prefix_collision_doesnt_match_longer_slugs() {
441        let a = test_app();
442        let space_id = a.active_space.id.clone();
443
444        // Two watches: one with topic "rust", another with "rust async".
445        // Their slugs are "rust" and "rust-async" respectively.
446        let session_rust =
447            a.db.create_session("rust", "openai/gpt-5-mini", &space_id, "chat")
448                .unwrap();
449        let _watch_rust =
450            a.db.create_watch(&space_id, "rust", 24, &session_rust.id)
451                .unwrap();
452
453        let session_rust_async =
454            a.db.create_session("rust async", "openai/gpt-5-mini", &space_id, "chat")
455                .unwrap();
456        let _watch_rust_async =
457            a.db.create_watch(&space_id, "rust async", 24, &session_rust_async.id)
458                .unwrap();
459
460        // Add citations to the "rust async" watch (the one with the longer slug).
461        // Its report file starts with "research-rust-async-" then the timestamp.
462        let slug_rust_async = super::super::sessions::slugify("rust async");
463        a.db.add_citations(
464            &space_id,
465            &format!("research-{slug_rust_async}-20260101-000000.md"),
466            &[("https://rust-async-report.example".to_string(), None)],
467        )
468        .unwrap();
469
470        // The "rust" watch should NOT see the "rust async" watch's citations,
471        // even though "research-rust-async-..." starts with "research-rust-".
472        // With the bug unfixed, the "rust" watch would incorrectly pull in the
473        // "rust-async" watch's citations since "research-rust-async-20260101-000000.md"
474        // starts with the prefix "research-rust-".
475        let prev_rust = a
476            .previous_citations_for_watch_session(&session_rust.id, &space_id)
477            .unwrap();
478        assert!(
479            prev_rust.is_none(),
480            "rust watch should return None (no prior citations for itself), \
481             not see rust-async watch's citations (would show prefix collision bug): {prev_rust:?}"
482        );
483    }
484}