diff --git a/CLAUDE.md b/CLAUDE.md index 48320b4..ef900e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,10 @@ them without understanding why they exist. - The content detector refuses to baseline a page whose extracted text is below a small threshold (a client-rendered shell), so it never silently hashes nothing forever. - Feed dedupe keys on a stable identity: the Atom `` verbatim, else a normalized link URL. Persist the full - seen-set. An existing item counts as changed only when its title+summary hash moves. + seen-set. An existing item counts as changed only when its title+summary hash moves. A feed whose entry title is only + a date (the AndroidX aggregate feed titles every entry "June 24, 2026") gets a display title synthesized from the + summary's library/version link text; this is display-only — identity and the title+summary dedupe hash still use the + original feed values, so the rewrite never re-fires the seen-set. - The `android_sitemap` detector is host-agnostic: it parses a host's sitemap (a `` of shards, or a single ``) once per run, cached on the `Fetcher` keyed by the sitemap-index URL derived from each source's host ( `:///sitemap.xml`). Sources on the same host share one download (guarded by an `asyncio.Lock`, @@ -97,7 +100,12 @@ them without understanding why they exist. `detected_at`). - `record_change` is idempotent on `(source_id, url, fetched_hash)`: it returns the existing row id and never resets a verdict. -- `set_verdict` is write-once. Triage only touches rows with `verdict IS NULL`. +- `set_verdict` is write-once. The triage worklist is the whole ledger — `changes_needing_triage()` returns every row + with `verdict IS NULL AND superseded = 0`, not just this run's detections. A change recorded during a run that could + not triage keeps a NULL verdict and is never re-detected (its content hash / feed seen-set already matches), so the + ledger is the only place to find it; a later run picks it up and resolves it. When triage cannot run at all (the + triager returns `unavailable`), the run fails open and marks every untriaged row `substantive` with no description, so + the digest still goes out (with the AI-unavailable banner) instead of silently stranding those changes. - When a ranked change is delivered, `supersede_older` marks older undelivered rows for the same `(source_id, url)` so a page that changed twice yields one digest line, not a stale one. - Delivery is per `(change, channel)`, recorded in `deliveries`. Send, then record the delivery transactionally. A @@ -126,7 +134,8 @@ them without understanding why they exist. - `claude_cli` shells out to `claude -p --output-format json`, strips a markdown code fence from the result before parsing, and on any failure returns `TriageResult(unavailable=)` without raising. The digest still goes out, - with a visible "AI unavailable" banner. + with a visible "AI unavailable" banner, and the run marks every untriaged change `substantive` so they are all sent + rather than withheld (same effect as the `noop` triager — when triage cannot classify, send all). - Fetched page content is untrusted. Wrap it in per-run nonce-fenced blocks, length-cap it, and instruct the model to treat it as data, never instructions. - `noop` (AI off) marks every change substantive with no description and does not filter. diff --git a/src/android_watcher/catalog/catalog.toml b/src/android_watcher/catalog/catalog.toml index 51d6c3d..d20ff34 100644 --- a/src/android_watcher/catalog/catalog.toml +++ b/src/android_watcher/catalog/catalog.toml @@ -45,7 +45,11 @@ path_prefix = "" feed_url = "" content_selector = "" default_weight = 0 -exclude_prefixes = ["/reference"] +# /reference: per-symbol API docs. gki-android*-builds: auto-generated kernel +# build lists that regenerate with new build rows on nearly every crawl, so they +# churn the digest daily without telling a developer anything actionable (the +# curated gki-faq / gki-releases / gki-versioning docs are still watched). +exclude_prefixes = ["/reference", "/docs/core/architecture/kernel/gki-android"] [[source]] id = "android-security-bulletins" diff --git a/src/android_watcher/config.py b/src/android_watcher/config.py index e4b016d..9f9637a 100644 --- a/src/android_watcher/config.py +++ b/src/android_watcher/config.py @@ -46,6 +46,11 @@ class ScheduleConfig: at: str = "09:00" # one or more HH:MM, comma-separated days: str = "mon" # weekly only: comma-separated weekday abbrevs (mon..sun) cron: str = "" + # Extra environment variables baked into the native scheduler unit. The + # scheduled job (and the claude CLI it shells out to for triage) inherit a + # bare environment, so e.g. CLAUDE_ACCOUNT here lets an account-aware claude + # wrapper resolve a profile when it cannot from the job's working directory. + env: dict[str, str] = field(default_factory=dict) @dataclass @@ -233,8 +238,9 @@ def _load_schedule(d: dict[str, Any]) -> ScheduleConfig: f"schedule.cron is set but interval is {interval!r}; " "set interval = 'cron' or clear cron" ) + env = {str(k): str(v) for k, v in d.get("env", {}).items()} return ScheduleConfig( - interval=interval, at=d.get("at", "09:00"), days=d.get("days", "mon"), cron=cron + interval=interval, at=d.get("at", "09:00"), days=d.get("days", "mon"), cron=cron, env=env ) diff --git a/src/android_watcher/detect/feed.py b/src/android_watcher/detect/feed.py index 236fee8..4073754 100644 --- a/src/android_watcher/detect/feed.py +++ b/src/android_watcher/detect/feed.py @@ -1,6 +1,8 @@ from __future__ import annotations import hashlib +import html as _html +import re from urllib.parse import urlsplit, urlunsplit from defusedxml import ElementTree as ET @@ -10,6 +12,62 @@ _ATOM = "{http://www.w3.org/2005/Atom}" +# A whole feed title that is *only* a date (e.g. the AndroidX aggregate feed, +# which titles every entry "June 24, 2026"). Such a title makes a useless digest +# headline, so it is replaced by the library/version names from the summary. +_DATE_TITLE_RE = re.compile( + r"^(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|" + r"aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+" + r"\d{1,2},\s+\d{4}$", + re.IGNORECASE, +) +_LINK_TEXT_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) +_TAG_RE = re.compile(r"<[^>]+>") + + +def _is_date_title(title: str) -> bool: + return bool(_DATE_TITLE_RE.match(title.strip())) + + +def _summary_items(summary: str) -> list[str]: + """Library/version labels from a summary's link texts, in order. + + "Media3 Version 1.11.0-alpha01" -> "Media3 1.11.0-alpha01" (the boilerplate + word "Version" is dropped). Returns [] when the summary has no links. + """ + items: list[str] = [] + for raw in _LINK_TEXT_RE.findall(summary): + text = _html.unescape(_TAG_RE.sub("", raw)) + text = re.sub(r"\bversion\b", "", text, flags=re.IGNORECASE) + text = re.sub(r"\s+", " ", text).strip() + if text: + items.append(text) + return items + + +def _synthesize_title(summary: str) -> str | None: + """A digest headline built from a date-titled entry's summary, or None. + + One library -> its label; several -> the first two labels then "+N more". + """ + items = _summary_items(summary) + if not items: + return None + if len(items) == 1: + return items[0] + head = ", ".join(items[:2]) + rest = len(items) - 2 + return f"{head} +{rest} more" if rest else head + + +def _display_title(item: dict) -> str: + """The entry's own title, unless it is a bare date and the summary yields a + better headline. Identity and the dedupe hash still use the original title.""" + title = item["title"] + if _is_date_title(title): + return _synthesize_title(item["summary"]) or title + return title + def _normalize_link(link: str) -> str: parts = urlsplit(link.strip()) @@ -107,6 +165,7 @@ async def detect(self, source: Source, store, fetcher) -> list[Change]: if not identity: continue content_hash = _hash(item["title"], item["summary"]) + title = _display_title(item) prior = store.seen_feed_item(source.id, identity) if prior is None: changes.append( @@ -114,7 +173,7 @@ async def detect(self, source: Source, store, fetcher) -> list[Change]: source_id=source.id, url=item["link"] or identity, change_kind="new", - title=item["title"], + title=title, raw_diff=f"{item['title']}\n\n{item['summary']}".strip()[:500], fetched_hash=content_hash, ) @@ -126,7 +185,7 @@ async def detect(self, source: Source, store, fetcher) -> list[Change]: source_id=source.id, url=item["link"] or identity, change_kind="updated", - title=item["title"], + title=title, raw_diff=f"{item['title']}\n\n{item['summary']}".strip()[:500], fetched_hash=content_hash, ) diff --git a/src/android_watcher/run.py b/src/android_watcher/run.py index 63df09c..83948c3 100644 --- a/src/android_watcher/run.py +++ b/src/android_watcher/run.py @@ -250,15 +250,20 @@ def run_once(config: Config, *, force: bool = False, dry_run: bool = False) -> D for change in changes: change.id = store.record_change(change) # idempotent on (source,url,hash) - # Triage is WRITE-ONCE: only rows whose verdict is still NULL. Re-detected - # rows already carry a final verdict and must not be re-triaged. - untriaged = [c for c in changes if c.verdict is None] + # Triage is WRITE-ONCE and ledger-sourced: the worklist is every row whose + # verdict is still NULL, not just this run's detections. A change recorded + # during a run that could not triage (the triager returned unavailable, + # leaving the verdict NULL) is never re-detected — its content hash / feed + # seen-set already matches — so the ledger is the only place to find it. + untriaged = store.changes_needing_triage() mode = config.ai.mode if config.ai.mode != "off" else "noop" t_triage = time.monotonic() result = _triage_batched(TRIAGERS.get(mode)(), untriaged, config.ai) log.info("triage phase: %.1fs (%d triaged)", time.monotonic() - t_triage, len(untriaged)) for change in result.changes: - if change.id is not None and change.verdict is not None: + if change.id is None: + continue + if change.verdict is not None: store.set_verdict( change.id, change.verdict, @@ -267,6 +272,12 @@ def run_once(config: Config, *, force: bool = False, dry_run: bool = False) -> D change.group_summary, change.group_title, ) + elif result.unavailable is not None: + # Could not triage: fail open and SEND ALL. Mark every untriaged + # change substantive (no description) so the digest still goes out + # — with the AI-unavailable banner — instead of silently withholding + # the change until some later run does manage to triage it. + store.set_verdict(change.id, "substantive", None) # Digest comes from the ledger (undelivered backlog), not this-run changes. digest = _build_ledger_digest(store, config, channels, result.tldr, result.unavailable) diff --git a/src/android_watcher/schedule.py b/src/android_watcher/schedule.py index 67ee5c8..91176cf 100644 --- a/src/android_watcher/schedule.py +++ b/src/android_watcher/schedule.py @@ -176,7 +176,8 @@ def render_plist( A launchd job inherits a bare PATH (``/usr/bin:/bin:/usr/sbin:/sbin``), so when *path_env* is given it is embedded as ``EnvironmentVariables/PATH`` — - without it the run cannot reach the ``claude`` CLI for triage. + without it the run cannot reach the ``claude`` CLI for triage. Any + ``sched.env`` entries are embedded in the same ``EnvironmentVariables`` dict. """ intervals = _calendar_intervals(sched) payload: dict[str, object] = { @@ -185,8 +186,11 @@ def render_plist( "RunAtLoad": False, "StartCalendarInterval": intervals[0] if len(intervals) == 1 else intervals, } + env_vars = dict(sched.env) if path_env: - payload["EnvironmentVariables"] = {"PATH": path_env} + env_vars["PATH"] = path_env + if env_vars: + payload["EnvironmentVariables"] = env_vars return plistlib.dumps(payload, sort_keys=True).decode("utf-8") @@ -243,15 +247,22 @@ def _on_calendar(sched: ScheduleConfig, tz: str) -> str: raise ScheduleError(f"unknown interval {sched.interval!r}") -def render_service(exec_path: str, args: list[str], path_env: str | None = None) -> str: +def render_service( + exec_path: str, + args: list[str], + path_env: str | None = None, + env: dict[str, str] | None = None, +) -> str: """Render a systemd .service unit for android-watcher. systemd user services start from a minimal PATH, so when *path_env* is given it is embedded as ``Environment=PATH=`` — without it the run cannot reach the - ``claude`` CLI for triage. + ``claude`` CLI for triage. Each *env* entry is emitted as its own + ``Environment=KEY=value`` line. """ exec_start = " ".join([exec_path, *args]) - env_line = f"Environment=PATH={path_env}\n" if path_env else "" + env_lines = "".join(f"Environment={k}={v}\n" for k, v in (env or {}).items()) + env_line = (f"Environment=PATH={path_env}\n" if path_env else "") + env_lines return ( "[Unit]\n" "Description=android-watcher scheduled run\n" @@ -307,11 +318,13 @@ def render_crontab( cron runs with a minimal PATH, so when *path_env* is given a ``PATH=`` assignment is emitted ahead of the schedule lines — without it the run cannot - reach the ``claude`` CLI for triage. + reach the ``claude`` CLI for triage. Each ``sched.env`` entry is emitted as + its own ``KEY=value`` assignment, also ahead of the schedule lines. """ body = "\n".join(f"{spec} {line_command}" for spec in _cron_lines(sched)) path_line = f"PATH={path_env}\n" if path_env else "" - return f"{CRON_BEGIN}\nCRON_TZ={tz}\n{path_line}{body}\n{CRON_END}\n" + env_lines = "".join(f"{k}={v}\n" for k, v in sched.env.items()) + return f"{CRON_BEGIN}\nCRON_TZ={tz}\n{path_line}{env_lines}{body}\n{CRON_END}\n" # --------------------------------------------------------------------------- @@ -408,7 +421,9 @@ def _install_systemd(config: Config) -> None: d = Path(_systemd_dir()) d.mkdir(parents=True, exist_ok=True) exe, *run_args = _program_args() - (d / f"{SYSTEMD_UNIT_NAME}.service").write_text(render_service(exe, run_args, _env_path())) + (d / f"{SYSTEMD_UNIT_NAME}.service").write_text( + render_service(exe, run_args, _env_path(), config.schedule.env) + ) (d / f"{SYSTEMD_UNIT_NAME}.timer").write_text(render_timer(config.schedule, _local_tz())) _run(["systemctl", "--user", "daemon-reload"]) _run(["systemctl", "--user", "enable", "--now", f"{SYSTEMD_UNIT_NAME}.timer"]) diff --git a/src/android_watcher/store.py b/src/android_watcher/store.py index 7575813..6ce6da1 100644 --- a/src/android_watcher/store.py +++ b/src/android_watcher/store.py @@ -263,6 +263,24 @@ def record_change(self, change: Change) -> int: change.id = int(row["id"]) return change.id + def changes_needing_triage(self) -> list[Change]: + """Every ledger row still awaiting a verdict (verdict IS NULL, not superseded). + + The triage worklist is sourced from the ledger, not just this run's fresh + detections, so a change recorded during a run that could not triage (the + triager returned unavailable, leaving the verdict NULL) is picked up and + triaged on a later run. Without this it would never be re-detected — its + content hash / feed seen-set already matches — and so would strand forever. + """ + rows = self._conn.execute( + """ + SELECT * FROM changes + WHERE verdict IS NULL AND superseded = 0 + ORDER BY detected_at DESC, id DESC + """ + ).fetchall() + return [self._row_to_change(r) for r in rows] + def changes_for_digest(self, channels: set[str]) -> list[Change]: """Substantive changes not yet delivered to EVERY channel in `channels`. diff --git a/src/android_watcher/triage/claude_cli.py b/src/android_watcher/triage/claude_cli.py index 57040f2..28514da 100644 --- a/src/android_watcher/triage/claude_cli.py +++ b/src/android_watcher/triage/claude_cli.py @@ -14,8 +14,14 @@ logger = logging.getLogger(__name__) MAX_CONTENT_CHARS: int = 4000 -MAX_TRIAGE_BATCH: int = 25 -SUBPROCESS_TIMEOUT: float = 120.0 +# Batch size and per-call timeout are sized for a large backlog drain, not just a +# steady-state run: a batch of N changes carries up to N*MAX_CONTENT_CHARS of page +# content, and `claude -p` must read it and emit JSON for every item within the +# timeout. A timed-out batch trips the run's AI-unavailable banner and falls back +# to send-all, so keep the batch small and the timeout generous to avoid that on a +# one-shot drain of hundreds of changes. +MAX_TRIAGE_BATCH: int = 12 +SUBPROCESS_TIMEOUT: float = 300.0 _INSTRUCTIONS_TEMPLATE = ( "You are triaging changes detected on official Android documentation and blog\n" diff --git a/src/android_watcher/tui/configio.py b/src/android_watcher/tui/configio.py index c4fee43..b8f5e55 100644 --- a/src/android_watcher/tui/configio.py +++ b/src/android_watcher/tui/configio.py @@ -100,6 +100,12 @@ def config_to_toml(config: Config) -> str: lines.append(f"cron = {_toml_str(sc.cron)}") lines.append("") + if sc.env: + lines.append("[schedule.env]") + for key, val in sc.env.items(): + lines.append(f"{_toml_str(key)} = {_toml_str(val)}") + lines.append("") + lines.append("[ai]") lines.append(f"mode = {_toml_str(ai.mode)}") lines.append(f"model = {_toml_str(ai.model)}") diff --git a/tests/detect/test_android_sitemap.py b/tests/detect/test_android_sitemap.py index d253972..6071708 100644 --- a/tests/detect/test_android_sitemap.py +++ b/tests/detect/test_android_sitemap.py @@ -452,6 +452,30 @@ async def test_exclude_prefixes_drops_subtree(): assert got == {"/studio/y", "/develop/z"} +@pytest.mark.asyncio +async def test_source_android_excludes_gki_build_lists(): + """The per-version GKI build-list pages auto-regenerate constantly (new build + rows), so the shipped source-android catalog entry must drop them while still + watching the curated GKI docs.""" + from android_watcher.catalog import load_catalog + + source_android = next(s for s in load_catalog() if s.id == "source-android") + got = await _watched_paths( + source_android, + [ + "/docs/core/architecture/kernel/gki-android14-5_15-release-builds", + "/docs/core/architecture/kernel/gki-android17-6_18-deprecated-builds", + "/docs/core/architecture/kernel/gki-faq", + "/docs/core/architecture/kernel/gki-releases", + ], + host="https://source.android.com", + ) + assert got == { + "/docs/core/architecture/kernel/gki-faq", + "/docs/core/architecture/kernel/gki-releases", + } + + @pytest.mark.asyncio async def test_require_segment_keeps_android_drops_others(): src = _src( diff --git a/tests/detect/test_feed.py b/tests/detect/test_feed.py index 1686687..81d1027 100644 --- a/tests/detect/test_feed.py +++ b/tests/detect/test_feed.py @@ -117,6 +117,58 @@ async def test_atom_id_used_verbatim_as_identity(): assert store.seen_feed_item("blog", "https://example.com/tagged-post") is None +def _atom_entry(id_: str, title: str, summary_html: str) -> str: + """A single-entry Atom feed; summary_html is escaped into the body.""" + esc = summary_html.replace("&", "&").replace("<", "<").replace(">", ">") + return ( + '\n' + '\n' + f" \n {id_}\n {title}\n" + ' \n' + f" {esc}\n" + " 2026-06-24T00:00:00Z\n \n" + ) + + +async def test_date_titled_entry_synthesizes_title_from_summary(): + """A bare-date feed title (the AndroidX aggregate feed) is replaced by the + library/version names pulled from the summary, so the digest headline is + meaningful instead of just 'June 24, 2026'.""" + summary = '' + xml = _atom_entry("androidx#june_24_2026", "June 24, 2026", summary) + store = FakeStore() + changes = await FeedDetector().detect(src(), store, FakeFetcher(xml)) + assert len(changes) == 1 + assert changes[0].title == "Media3 1.11.0-alpha01" + # Identity + dedupe hash still key on the ORIGINAL feed values, so existing + # seen-sets do not all re-fire when this display-only change lands. + assert store.seen_feed_item("blog", "androidx#june_24_2026") + + +async def test_date_titled_entry_multiple_libraries(): + summary = ( + '' + ) + xml = _atom_entry("id-multi", "April 8, 2026", summary) + changes = await FeedDetector().detect(src(), FakeStore(), FakeFetcher(xml)) + assert changes[0].title == "Annotation 1.10.0, Media3 1.9.0 +1 more" + + +async def test_non_date_title_is_left_untouched(): + """A normal feed (Android blog, etc.) keeps its real title verbatim.""" + store = FakeStore() + changes = await FeedDetector().detect(src(), store, FakeFetcher(read("feed_initial.xml"))) + assert {c.title for c in changes} == {"Post A", "Post B"} + + +async def test_date_title_with_unparsable_summary_falls_back_to_date(): + xml = _atom_entry("id-nolinks", "May 1, 2026", "No links in here at all.") + changes = await FeedDetector().detect(src(), FakeStore(), FakeFetcher(xml)) + assert changes[0].title == "May 1, 2026" + + async def test_feed_url_preferred_over_url(): """Source.feed_url should be fetched when set, not Source.url.""" fetched_urls: list[str] = [] diff --git a/tests/run/test_run_once.py b/tests/run/test_run_once.py index ca12eb0..0525a4a 100644 --- a/tests/run/test_run_once.py +++ b/tests/run/test_run_once.py @@ -67,6 +67,10 @@ def record_change(self, change: Change) -> int: change.id = 1000 + len(self.recorded) return change.id + def changes_needing_triage(self) -> list[Change]: + # Ledger double: every recorded row still awaiting a verdict. + return [c for c in self.recorded if c.verdict is None] + def changes_for_digest(self, channels: set[str]) -> list[Change]: if not channels: return [] @@ -492,6 +496,42 @@ def test_write_once_triage_only_verdict_null(patched, monkeypatch): assert [v[0] for v in store.verdicts] == [100] +def test_triage_drains_stranded_ledger_rows(patched, monkeypatch): + """Triage must resolve ledger rows left NULL by a prior run that could not + triage, even when this run detects nothing new (the row will never be + re-detected, so it would strand otherwise).""" + store, detect_calls = patched + install_notifiers(monkeypatch) + stranded = Change(source_id="src", url="https://x/stranded", change_kind="new", id=900) + store.record_change(stranded) # already in the ledger, verdict None + detect_calls["changes"] = [] # this run detects nothing new + captured = install_triager( + monkeypatch, lambda changes: TriageResult(changes=[_set(c) for c in changes]) + ) + + run_mod.run_once(make_config(ai_mode="claude_cli")) + + assert 900 in [c.id for c in captured["changes"]] + assert 900 in [v[0] for v in store.verdicts] + + +def test_unavailable_triage_sends_all_as_substantive(patched, monkeypatch): + """When triage is unavailable we fail open: every untriaged change is marked + substantive so the digest still goes out (with the banner) instead of + silently withholding the change until some later run.""" + store, detect_calls = patched + install_notifiers(monkeypatch) + detect_calls["changes"] = [ + Change(source_id="src", url="https://x/a", change_kind="new", id=700) + ] + store.digest_changes = [] + install_triager(monkeypatch, lambda changes: TriageResult(changes=changes, unavailable="down")) + + run_mod.run_once(make_config(ai_mode="claude_cli")) + + assert (700, "substantive", None) in store.verdicts + + def _set(change): change.verdict = "substantive" change.description = "d" diff --git a/tests/test_config.py b/tests/test_config.py index 7d7e641..5dfec6a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -215,6 +215,24 @@ def test_invalid_interval_rejected(tmp_path): load_config(_write(tmp_path, text)) +def test_schedule_env_loaded(tmp_path): + text = """ +[schedule] +interval = "daily" +at = "09:00" + +[schedule.env] +CLAUDE_ACCOUNT = "personal" +""" + cfg = load_config(_write(tmp_path, text)) + assert cfg.schedule.env == {"CLAUDE_ACCOUNT": "personal"} + + +def test_schedule_env_defaults_empty(tmp_path): + cfg = load_config(_write(tmp_path, '[schedule]\ninterval = "daily"\n')) + assert cfg.schedule.env == {} + + def test_paths(monkeypatch, tmp_path): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) diff --git a/tests/test_configio.py b/tests/test_configio.py index 5f2042a..f1a57a9 100644 --- a/tests/test_configio.py +++ b/tests/test_configio.py @@ -34,7 +34,7 @@ def _make_full_config() -> Config: """A Config with every field populated, using ${...} secret refs.""" return Config( - schedule=ScheduleConfig(interval="daily", at="09:00"), + schedule=ScheduleConfig(interval="daily", at="09:00", env={"CLAUDE_ACCOUNT": "personal"}), ai=AIConfig(mode="claude_cli", model="claude-opus-4-8"), digest=DigestConfig(max_items=5, empty="send"), sort={"security": 10, "releases": 5}, @@ -91,6 +91,7 @@ def test_roundtrip_preserves_fields(tmp_path: Path) -> None: assert loaded.schedule.interval == "daily" assert loaded.schedule.at == "09:00" + assert loaded.schedule.env == {"CLAUDE_ACCOUNT": "personal"} assert loaded.ai.mode == "claude_cli" assert loaded.ai.model == "claude-opus-4-8" assert loaded.digest.max_items == 5 diff --git a/tests/test_schedule_crontab.py b/tests/test_schedule_crontab.py index b332d67..426f76f 100644 --- a/tests/test_schedule_crontab.py +++ b/tests/test_schedule_crontab.py @@ -71,3 +71,11 @@ def test_render_crontab_omits_path_without_path_env() -> None: sched = ScheduleConfig(interval="daily", at="09:00") result = render_crontab("/usr/bin/android-watcher run", sched, "Europe/Berlin") assert "\nPATH=" not in result + + +def test_render_crontab_embeds_schedule_env() -> None: + sched = ScheduleConfig(interval="daily", at="09:00", env={"CLAUDE_ACCOUNT": "personal"}) + result = render_crontab("/usr/bin/android-watcher run", sched, "Europe/Berlin") + assert "CLAUDE_ACCOUNT=personal" in result + # Env assignments must precede the schedule line to take effect. + assert result.index("CLAUDE_ACCOUNT=personal") < result.index("0 9 * * *") diff --git a/tests/test_schedule_plist.py b/tests/test_schedule_plist.py index ca7c753..597d9ed 100644 --- a/tests/test_schedule_plist.py +++ b/tests/test_schedule_plist.py @@ -105,3 +105,30 @@ def test_render_plist_omits_env_without_path() -> None: sched, ) assert "EnvironmentVariables" not in result + + +def test_render_plist_embeds_schedule_env() -> None: + # Extra env from ScheduleConfig.env is baked into EnvironmentVariables so the + # scheduled run carries e.g. CLAUDE_ACCOUNT for an account-aware claude wrapper. + sched = ScheduleConfig(interval="daily", at="09:00", env={"CLAUDE_ACCOUNT": "personal"}) + result = render_plist( + "com.krayong.android-watcher", + ["/usr/bin/android-watcher", "run"], + sched, + path_env="/usr/bin:/bin", + ) + assert "EnvironmentVariables" in result + assert "CLAUDE_ACCOUNT" in result + assert "personal" in result + assert "PATH" in result + + +def test_render_plist_env_without_path() -> None: + sched = ScheduleConfig(interval="daily", at="09:00", env={"CLAUDE_ACCOUNT": "personal"}) + result = render_plist( + "com.krayong.android-watcher", + ["/usr/bin/android-watcher", "run"], + sched, + ) + assert "CLAUDE_ACCOUNT" in result + assert "PATH" not in result diff --git a/tests/test_schedule_systemd.py b/tests/test_schedule_systemd.py index 113325c..7d0bc32 100644 --- a/tests/test_schedule_systemd.py +++ b/tests/test_schedule_systemd.py @@ -73,3 +73,14 @@ def test_render_service_embeds_path_env() -> None: def test_render_service_omits_env_without_path() -> None: result = render_service("/usr/bin/android-watcher", ["run"]) assert "Environment=" not in result + + +def test_render_service_embeds_schedule_env() -> None: + result = render_service( + "/usr/bin/android-watcher", + ["run"], + path_env="/usr/bin:/bin", + env={"CLAUDE_ACCOUNT": "personal"}, + ) + assert "Environment=PATH=/usr/bin:/bin" in result + assert "Environment=CLAUDE_ACCOUNT=personal" in result diff --git a/tests/test_store.py b/tests/test_store.py index a4514ad..ac88909 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -87,6 +87,37 @@ def test_change_record_and_digest_query(store): assert ids == {cid} +def test_changes_needing_triage_returns_untriaged_ledger_rows(store): + """Triage must drain ALL ledger rows with a NULL verdict, not just the ones + detected in the current run. A change recorded during an AI-down run (verdict + left NULL) would otherwise never be re-detected, so it must be picked up from + the ledger on a later run.""" + # A row from a prior AI-down run: recorded, never triaged. + stranded = Change( + source_id="src", url="https://e/stranded", change_kind="new", title="June 24, 2026" + ) + sid = store.record_change(stranded) + # A row already triaged: must NOT come back for triage (write-once). + done = Change(source_id="src", url="https://e/done", change_kind="new") + did = store.record_change(done) + store.set_verdict(did, "substantive", "already described") + # A NULL row that was superseded: excluded. + old = Change(source_id="src", url="https://e/sup", change_kind="updated") + oid = store.record_change(old) + store._conn.execute("UPDATE changes SET superseded = 1 WHERE id = ?", (oid,)) + store._conn.commit() + + pending = store.changes_needing_triage() + ids = {c.id for c in pending} + assert sid in ids + assert did not in ids + assert oid not in ids + # The reconstructed Change carries the data triage needs (raw_diff, title). + row = next(c for c in pending if c.id == sid) + assert row.title == "June 24, 2026" + assert row.verdict is None + + def test_changes_for_digest_empty_channels_returns_empty(store): # CONTRACTS: an empty channel set yields [] (no channel => nothing to send). c = Change(source_id="src", url="https://e/p", change_kind="new", fetched_hash="h1")