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_memory_snapshot = self.memory_snapshot.clone();
122        let restore_cache_epoch = self.cache_epoch;
123        let restore_messages = std::mem::take(&mut self.messages);
124        let mut started = false;
125        if let Ok(Some(s)) = self.db.get_session(&w.session_id) {
126            let prior_session_id = s.id.clone();
127            self.active_space = space_row;
128            self.session = Some(s);
129            let _ = self.execute(super::AppCommand::RunResearch {
130                topic: w.topic.clone(),
131                gated: false,
132            });
133            // `start_research_with_gate` only allows one job at a time —
134            // for the 2nd+ due watch in the startup loop, `research_rx.is_some()`
135            // is still set from the first watch's job (it isn't cleared until
136            // that job's background task finishes, long after this synchronous
137            // loop returns), so the guard fires and the call above is a no-op:
138            // `self.session` is left exactly as set on the line above (the
139            // watch's *prior* session), unchanged. Only when a new session was
140            // actually created do we know the job really started — compare ids
141            // to tell those cases apart, and leave a not-actually-run watch
142            // untouched (still due) for the next startup rather than falsely
143            // marking it caught up.
144            if let Some(new_session) = self.session.as_ref()
145                && new_session.id != prior_session_id
146            {
147                let _ = self.db.set_watch_session(&w.id, &new_session.id);
148                let _ = self.db.touch_watch(&w.id, &chrono::Utc::now().to_rfc3339());
149                started = true;
150            }
151        }
152        self.active_space = restore_space;
153        self.session = restore_session;
154        self.memory_snapshot = restore_memory_snapshot;
155        self.cache_epoch = restore_cache_epoch;
156        self.messages = restore_messages;
157        self.refresh_toolbox();
158        started
159    }
160
161    /// `Some(urls)` if `session_id` is a watch's session and it has a prior
162    /// run (citations already indexed from an earlier `save_research_report`
163    /// call); `Ok(None)` for a first run or a non-watch session — either way
164    /// means "no diff section". Takes the report's own `space_id` rather than
165    /// reading `self.active_space`: this runs from `on_research_done`, which
166    /// fires asynchronously and may land well after the user (or
167    /// `run_due_watches`, which restores it right after spawning the job) has
168    /// switched the active space away from the one this job actually ran in.
169    pub fn previous_citations_for_watch_session(
170        &self,
171        session_id: &str,
172        space_id: &str,
173    ) -> anyhow::Result<Option<Vec<String>>> {
174        let Some(w) = self
175            .db
176            .list_all_watches()?
177            .into_iter()
178            .find(|w| w.session_id == session_id)
179        else {
180            return Ok(None);
181        };
182        // Scope to this watch's own prior report(s): `save_research_report`
183        // names every report it saves `research-<slug>-<timestamp>.md` where
184        // `slug = slugify(topic)`, and a watch's topic (hence its slug) is
185        // stable across re-runs. Filtering `report_file` to that prefix keeps
186        // an unrelated `/research` session elsewhere in the same space from
187        // suppressing a source as "not new" for this watch.
188        let slug = super::sessions::slugify(&w.topic);
189        let prefix = format!("research-{slug}-");
190        let rows = self.db.search_citations(space_id, Some(&prefix))?;
191        let rows: Vec<_> = rows
192            .into_iter()
193            .filter(|(report_file, _, _)| {
194                report_file.starts_with(&prefix)
195                    && report_file
196                        .chars()
197                        .nth(prefix.len())
198                        .is_some_and(|c| c.is_ascii_digit())
199            })
200            .collect();
201        if rows.is_empty() {
202            return Ok(None);
203        }
204        Ok(Some(rows.into_iter().map(|(_, url, _)| url).collect()))
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::app::App;
212    use crate::db::Db;
213    use crate::space::Space;
214
215    fn test_app() -> App {
216        let db = Db::open_in_memory().unwrap();
217        let root =
218            std::env::temp_dir().join(format!("nexus-watches-test-{}", uuid::Uuid::new_v4()));
219        std::fs::create_dir_all(root.join("spaces")).unwrap();
220        let space = Space { root };
221        App::new(db, Some("k"), space)
222    }
223
224    #[tokio::test]
225    async fn run_due_watches_repoints_the_watch_at_its_new_session() {
226        let mut a = test_app();
227        a.current_model = Some("openai/gpt-5-mini".to_string());
228        let space_id = a.active_space.id.clone();
229
230        // The watch's original session, from some earlier run.
231        let first_session =
232            a.db.create_session("first run", "openai/gpt-5-mini", &space_id, "chat")
233                .unwrap();
234        let watch_id =
235            a.db.create_watch(&space_id, "rust async runtimes", 24, &first_session.id)
236                .unwrap();
237
238        a.run_due_watches();
239
240        let updated =
241            a.db.list_all_watches()
242                .unwrap()
243                .into_iter()
244                .find(|w| w.id == watch_id)
245                .unwrap();
246        assert_ne!(
247            updated.session_id, first_session.id,
248            "run_due_watches should repoint the watch at the session its re-run actually used, \
249             not leave it pinned to the first run's session forever"
250        );
251        assert!(updated.last_run_at.is_some());
252    }
253
254    #[tokio::test]
255    async fn run_due_watches_only_touches_the_watch_whose_job_actually_started() {
256        let mut a = test_app();
257        a.current_model = Some("openai/gpt-5-mini".to_string());
258        let space_id = a.active_space.id.clone();
259
260        let first_session =
261            a.db.create_session("first run", "openai/gpt-5-mini", &space_id, "chat")
262                .unwrap();
263        let second_session =
264            a.db.create_session("second run", "openai/gpt-5-mini", &space_id, "chat")
265                .unwrap();
266        let watch_a =
267            a.db.create_watch(&space_id, "rust async runtimes", 24, &first_session.id)
268                .unwrap();
269        let watch_b =
270            a.db.create_watch(&space_id, "wasm gc proposal", 24, &second_session.id)
271                .unwrap();
272
273        // Only one research job can run at a time — the pipeline's guard
274        // (`research_rx.is_some()`) fires for the 2nd+ watch in this
275        // synchronous startup loop, since nothing clears `research_rx` until
276        // a background job later completes. So watch_a's run actually
277        // starts (fresh session, repointed + touched); watch_b's call is a
278        // no-op (guard fires) and must be left exactly as it was — still due
279        // — for the next startup.
280        a.run_due_watches();
281
282        let updated_a =
283            a.db.list_all_watches()
284                .unwrap()
285                .into_iter()
286                .find(|w| w.id == watch_a)
287                .unwrap();
288        let updated_b =
289            a.db.list_all_watches()
290                .unwrap()
291                .into_iter()
292                .find(|w| w.id == watch_b)
293                .unwrap();
294
295        assert_ne!(
296            updated_a.session_id, first_session.id,
297            "watch_a's job actually started, so it should be repointed at its new session"
298        );
299        assert!(
300            updated_a.last_run_at.is_some(),
301            "watch_a's job actually started, so it should be touched"
302        );
303
304        assert_eq!(
305            updated_b.session_id, second_session.id,
306            "watch_b's job never started (guard fired) — it must not be repointed"
307        );
308        assert!(
309            updated_b.last_run_at.is_none(),
310            "watch_b's job never started (guard fired) — touching it would falsely mark it caught up \
311             and make it silently skip a full interval"
312        );
313    }
314
315    fn watch(topic: &str, interval_hours: i64, last_run_at: Option<&str>) -> Watch {
316        Watch {
317            id: "w1".to_string(),
318            space_id: "space-1".to_string(),
319            topic: topic.to_string(),
320            interval_hours,
321            session_id: "sess-1".to_string(),
322            last_run_at: last_run_at.map(str::to_string),
323        }
324    }
325
326    #[test]
327    fn never_run_watch_is_always_due() {
328        let w = watch("topic", 24, None);
329        let now = chrono::DateTime::parse_from_rfc3339("2026-07-07T00:00:00+00:00")
330            .unwrap()
331            .to_utc();
332        assert_eq!(due_watches(&[w], now).len(), 1);
333    }
334
335    #[test]
336    fn watch_run_recently_is_not_due() {
337        let w = watch("topic", 24, Some("2026-07-07T00:00:00+00:00"));
338        let now = chrono::DateTime::parse_from_rfc3339("2026-07-07T05:00:00+00:00")
339            .unwrap()
340            .to_utc();
341        assert!(due_watches(&[w], now).is_empty());
342    }
343
344    #[test]
345    fn watch_past_its_interval_is_due() {
346        let w = watch("topic", 24, Some("2026-07-06T00:00:00+00:00"));
347        let now = chrono::DateTime::parse_from_rfc3339("2026-07-07T01:00:00+00:00")
348            .unwrap()
349            .to_utc();
350        assert_eq!(due_watches(&[w], now).len(), 1);
351    }
352
353    #[test]
354    fn diff_section_lists_new_sources_when_present() {
355        let section = diff_section(
356            "# Old Report\nOld body.",
357            "# New Report\nNew body.",
358            &["https://new-source.example".to_string()],
359        );
360        assert!(
361            section.contains("What changed since last run"),
362            "{section:?}"
363        );
364        assert!(
365            section.contains("https://new-source.example"),
366            "{section:?}"
367        );
368    }
369
370    #[test]
371    fn diff_section_empty_new_sources_still_produces_a_header() {
372        let section = diff_section("old", "new", &[]);
373        assert!(section.contains("What changed since last run"));
374        assert!(!section.contains("New sources"));
375    }
376
377    #[test]
378    fn new_sources_since_filters_out_previously_cited_urls() {
379        let new_report =
380            "Body [1][2].\n\n## Sources\n1. https://old.example/a\n2. https://fresh.example/b\n";
381        let previous = vec!["https://old.example/a".to_string()];
382        let new_sources = new_sources_since(new_report, &previous);
383        assert_eq!(new_sources, vec!["https://fresh.example/b".to_string()]);
384    }
385
386    #[test]
387    fn previous_citations_for_watch_session_is_scoped_to_the_watchs_own_reports() {
388        let a = test_app();
389        let space_id = a.active_space.id.clone();
390        let session =
391            a.db.create_session(
392                "rust async runtimes",
393                "openai/gpt-5-mini",
394                &space_id,
395                "chat",
396            )
397            .unwrap();
398        let watch_id =
399            a.db.create_watch(&space_id, "rust async runtimes", 24, &session.id)
400                .unwrap();
401        let _ = watch_id;
402
403        // This watch's own prior report (named per `save_research_report`'s
404        // `research-<slug>-<timestamp>.md` scheme, slug derived from its topic).
405        let slug = super::super::sessions::slugify("rust async runtimes");
406        a.db.add_citations(
407            &space_id,
408            &format!("research-{slug}-20260101-000000.md"),
409            &[("https://own-report.example".to_string(), None)],
410        )
411        .unwrap();
412
413        // An unrelated report elsewhere in the *same space* (a plain
414        // `/research` session, or another watch's topic) must not pollute
415        // this watch's diff.
416        a.db.add_citations(
417            &space_id,
418            "research-some-other-topic-20260101-000000.md",
419            &[("https://unrelated.example".to_string(), None)],
420        )
421        .unwrap();
422
423        let prev = a
424            .previous_citations_for_watch_session(&session.id, &space_id)
425            .unwrap();
426        let prev = prev.expect("watch session with prior citations should yield Some");
427        assert_eq!(prev, vec!["https://own-report.example".to_string()]);
428        assert!(
429            !prev.contains(&"https://unrelated.example".to_string()),
430            "an unrelated citation elsewhere in the space must not suppress a source as \
431             already-cited for this watch: {prev:?}"
432        );
433    }
434
435    #[test]
436    fn new_sources_since_normalizes_urls_before_comparing() {
437        // Trailing slash / scheme case differences shouldn't count as "new".
438        let new_report = "Body [1].\n\n## Sources\n1. https://Old.example/a/\n";
439        let previous = vec!["https://old.example/a".to_string()];
440        assert!(new_sources_since(new_report, &previous).is_empty());
441    }
442
443    #[test]
444    fn previous_citations_for_watch_session_prefix_collision_doesnt_match_longer_slugs() {
445        let a = test_app();
446        let space_id = a.active_space.id.clone();
447
448        // Two watches: one with topic "rust", another with "rust async".
449        // Their slugs are "rust" and "rust-async" respectively.
450        let session_rust =
451            a.db.create_session("rust", "openai/gpt-5-mini", &space_id, "chat")
452                .unwrap();
453        let _watch_rust =
454            a.db.create_watch(&space_id, "rust", 24, &session_rust.id)
455                .unwrap();
456
457        let session_rust_async =
458            a.db.create_session("rust async", "openai/gpt-5-mini", &space_id, "chat")
459                .unwrap();
460        let _watch_rust_async =
461            a.db.create_watch(&space_id, "rust async", 24, &session_rust_async.id)
462                .unwrap();
463
464        // Add citations to the "rust async" watch (the one with the longer slug).
465        // Its report file starts with "research-rust-async-" then the timestamp.
466        let slug_rust_async = super::super::sessions::slugify("rust async");
467        a.db.add_citations(
468            &space_id,
469            &format!("research-{slug_rust_async}-20260101-000000.md"),
470            &[("https://rust-async-report.example".to_string(), None)],
471        )
472        .unwrap();
473
474        // The "rust" watch should NOT see the "rust async" watch's citations,
475        // even though "research-rust-async-..." starts with "research-rust-".
476        // With the bug unfixed, the "rust" watch would incorrectly pull in the
477        // "rust-async" watch's citations since "research-rust-async-20260101-000000.md"
478        // starts with the prefix "research-rust-".
479        let prev_rust = a
480            .previous_citations_for_watch_session(&session_rust.id, &space_id)
481            .unwrap();
482        assert!(
483            prev_rust.is_none(),
484            "rust watch should return None (no prior citations for itself), \
485             not see rust-async watch's citations (would show prefix collision bug): {prev_rust:?}"
486        );
487    }
488}