From 35aaeece6d34a9e5e578b33c22107a27bdc3ba7b Mon Sep 17 00:00:00 2001 From: jay Date: Tue, 9 Jun 2026 10:12:26 -0400 Subject: [PATCH] Fix status/active mirror drift in upsert_sources (pre Promote-candidate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Codex: upsert_sources() wrote `active` but not `status`, so a candidate promoted inactive (the pipeline default) became active=0 + status='active' — the exact mirror drift Phase 1 set out to avoid (scheduler won't poll, admin UI shows "active"). Now derive status from an explicit value or from active, mirror active off status, and write both columns together (insert + conflict update). Test: promote_candidate(active=False) → status='paused', active=0. Also fix stale source_health docstring (now includes retired). Co-Authored-By: Claude Opus 4.8 (1M context) --- goodnews/queries.py | 2 +- goodnews/sources.py | 14 +++++++++++--- tests/test_candidates.py | 6 ++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/goodnews/queries.py b/goodnews/queries.py index 4d31761..7a51ab1 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -282,7 +282,7 @@ def content_stats(conn: sqlite3.Connection) -> dict: def source_health(conn: sqlite3.Connection) -> list[dict]: - """Per source (active AND paused), the data an operator needs to manage feeds: + """Per source (active, paused, AND retired), the data an operator needs to manage feeds: failure streak, last error/success/attempt, computed next poll, and the backing metrics (served/accepted/total counts + acceptance & duplicate rates) so Pause/Flag decisions aren't vibes-based. diff --git a/goodnews/sources.py b/goodnews/sources.py index 5ccb720..f33f68c 100644 --- a/goodnews/sources.py +++ b/goodnews/sources.py @@ -21,14 +21,20 @@ def load_sources(path: Path | str) -> list[dict]: def upsert_sources(conn: sqlite3.Connection, source_defs: list[dict]) -> int: count = 0 for source in source_defs: + # Keep status and the legacy `active` mirror in lockstep (Phase 1 rule): + # derive status from an explicit value or from active, then mirror active. + status = source.get("status") or ("active" if source.get("active", True) else "paused") + if status not in ("active", "paused", "retired"): + status = "active" + active = 1 if status == "active" else 0 conn.execute( """ INSERT INTO sources ( name, homepage_url, feed_url, source_type, default_category, - trust_score, pr_risk_score, active, poll_interval_minutes, notes, + trust_score, pr_risk_score, active, status, poll_interval_minutes, notes, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(feed_url) DO UPDATE SET name = excluded.name, homepage_url = excluded.homepage_url, @@ -37,6 +43,7 @@ def upsert_sources(conn: sqlite3.Connection, source_defs: list[dict]) -> int: trust_score = excluded.trust_score, pr_risk_score = excluded.pr_risk_score, active = excluded.active, + status = excluded.status, poll_interval_minutes = excluded.poll_interval_minutes, notes = excluded.notes, updated_at = CURRENT_TIMESTAMP @@ -49,7 +56,8 @@ def upsert_sources(conn: sqlite3.Connection, source_defs: list[dict]) -> int: source.get("default_category"), int(source.get("trust_score", 5)), int(source.get("pr_risk_score", 3)), - 1 if source.get("active", True) else 0, + active, + status, int(source.get("poll_interval_minutes", 60)), source.get("notes"), ), diff --git a/tests/test_candidates.py b/tests/test_candidates.py index 57c5217..e9fd349 100644 --- a/tests/test_candidates.py +++ b/tests/test_candidates.py @@ -36,9 +36,10 @@ def test_promote_creates_inactive_source_and_marks_promoted(conn): cand = save_candidate(conn, "http://x/feed", name="Lovely Feed") source_id = promote_candidate(conn, cand["id"]) # inactive by default - src = conn.execute("SELECT name, active FROM sources WHERE id = ?", (source_id,)).fetchone() + src = conn.execute("SELECT name, active, status FROM sources WHERE id = ?", (source_id,)).fetchone() assert src["name"] == "Lovely Feed" assert src["active"] == 0 # active-on-approval: not polled until activated + assert src["status"] == "paused" # status mirrors active — no drift (active=0 ⇒ paused) status = conn.execute("SELECT status FROM source_candidates WHERE id = ?", (cand["id"],)).fetchone()["status"] assert status == "promoted" @@ -47,7 +48,8 @@ def test_promote_creates_inactive_source_and_marks_promoted(conn): def test_promote_active_flag(conn): cand = save_candidate(conn, "http://y/feed", name="Y") sid = promote_candidate(conn, cand["id"], active=True) - assert conn.execute("SELECT active FROM sources WHERE id = ?", (sid,)).fetchone()["active"] == 1 + src = conn.execute("SELECT active, status FROM sources WHERE id = ?", (sid,)).fetchone() + assert src["active"] == 1 and src["status"] == "active" # both set together def test_promote_unknown_raises(conn):