* stage-3921: cron-session unread disambiguation (#3460, rodboev) * Release OW: cron sessions mark unread on new runs (#3460) Self-rebased rodboev's #3921 onto v0.51.435; full Codex+Opus+suite gate. Fixes cron-session unread cross-attribution: resolver now considers all known job ids and attributes each session to the longest matching cron_<jobid>_ prefix (provably unambiguous). Read-only state.db scan, fires only on real completions. Co-authored-by: rodboev <rodboev@users.noreply.github.com> --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
parent
e775fb3355
commit
a004e49ebf
6 changed files with 678 additions and 2 deletions
|
|
@ -3,7 +3,11 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.435] — 2026-06-15 — Release OV (supported local pytest runner)
|
||||
## [v0.51.436] — 2026-06-15 — Release OW (cron sessions mark unread on new runs)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cron sessions in the sidebar now mark unread when a new run lands, attributed to the correct job.** When a scheduled job finished, the sidebar's unread resolver matched a session id against `cron_<jobid>_` prefixes built only from the just-completed jobs, so a job whose id is a prefix of another's (e.g. `backup` vs `backup_full`) could steal the other's session (`cron_backup_full_…` resolving to `backup`). The resolver now considers all known job ids and attributes each session to the longest matching job-id prefix, which is provably unambiguous (the matching prefixes of a single session id are nested, so the longest is unique). The lookup is a read-only `state.db` scan that only runs when a cron actually completed, degrades gracefully on contention, and filters in Python rather than via SQL `LIKE`. (#3460)
|
||||
|
||||
### Added
|
||||
|
||||
|
|
|
|||
101
api/routes.py
101
api/routes.py
|
|
@ -249,6 +249,95 @@ _CLIENT_EVENT_ALLOWED_FIELDS = {
|
|||
}
|
||||
|
||||
|
||||
def _normalize_cron_job_ids(job_ids) -> list[str]:
|
||||
seen = set()
|
||||
normalized = []
|
||||
for job_id in job_ids or []:
|
||||
jid = str(job_id or "").strip()
|
||||
if not jid or jid in seen:
|
||||
continue
|
||||
seen.add(jid)
|
||||
normalized.append(jid)
|
||||
return normalized
|
||||
|
||||
|
||||
def _latest_cron_session_info_for_jobs(
|
||||
job_ids, completed_job_ids=None
|
||||
) -> dict[str, dict[str, int | str | None]]:
|
||||
"""Return newest persisted cron session info keyed by completed cron job id."""
|
||||
normalized = _normalize_cron_job_ids(job_ids)
|
||||
requested = _normalize_cron_job_ids(completed_job_ids if completed_job_ids is not None else job_ids)
|
||||
if not requested:
|
||||
return {}
|
||||
if not normalized:
|
||||
return {jid: {"session_id": "", "message_count": None} for jid in requested}
|
||||
db_path = _active_state_db_path()
|
||||
if not db_path or not Path(db_path).exists():
|
||||
return {jid: {"session_id": "", "message_count": None} for jid in requested}
|
||||
try:
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA table_info(sessions)")
|
||||
session_cols = {row[1] for row in cur.fetchall()}
|
||||
if "id" not in session_cols or "source" not in session_cols:
|
||||
return {jid: {"session_id": "", "message_count": None} for jid in requested}
|
||||
select_message_count = (
|
||||
"s.message_count AS message_count"
|
||||
if "message_count" in session_cols
|
||||
else "NULL AS message_count"
|
||||
)
|
||||
if "started_at" in session_cols:
|
||||
query = f"""
|
||||
SELECT s.id,
|
||||
{select_message_count}
|
||||
FROM sessions s
|
||||
WHERE LOWER(COALESCE(s.source, '')) = 'cron'
|
||||
ORDER BY COALESCE(s.started_at, 0) DESC, s.id DESC -- newest start, not last activity
|
||||
"""
|
||||
else:
|
||||
query = f"""
|
||||
SELECT s.id,
|
||||
{select_message_count}
|
||||
FROM sessions s
|
||||
WHERE LOWER(COALESCE(s.source, '')) = 'cron'
|
||||
ORDER BY s.id DESC
|
||||
"""
|
||||
cur.execute(query)
|
||||
results = {
|
||||
jid: {"session_id": "", "message_count": None} for jid in requested
|
||||
}
|
||||
requested_ids = set(requested)
|
||||
prefixes = {jid: f"cron_{jid}_" for jid in normalized}
|
||||
for row in cur.fetchall():
|
||||
sid = str(row["id"] or "")
|
||||
if not sid:
|
||||
continue
|
||||
matches = [
|
||||
jid
|
||||
for jid in normalized
|
||||
if sid.startswith(prefixes[jid])
|
||||
]
|
||||
if matches:
|
||||
jid = max(matches, key=len)
|
||||
if jid not in requested_ids or results[jid]["session_id"]:
|
||||
continue
|
||||
results[jid] = {
|
||||
"session_id": sid,
|
||||
"message_count": (
|
||||
int(row["message_count"])
|
||||
if row["message_count"] is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
if all(info["session_id"] for info in results.values()):
|
||||
break
|
||||
return results
|
||||
except sqlite3.Error:
|
||||
return {jid: {"session_id": "", "message_count": None} for jid in requested}
|
||||
|
||||
|
||||
|
||||
def _session_field(session, field, default=None):
|
||||
if isinstance(session, dict):
|
||||
return session.get(field, default)
|
||||
|
|
@ -12596,6 +12685,7 @@ def _handle_cron_recent(handler, parsed):
|
|||
jobs = list_jobs(include_disabled=True)
|
||||
completions = []
|
||||
for job in jobs:
|
||||
job_id = str(job.get("id", "") or "")
|
||||
last_run = job.get("last_run_at")
|
||||
if not last_run:
|
||||
continue
|
||||
|
|
@ -12611,13 +12701,22 @@ def _handle_cron_recent(handler, parsed):
|
|||
if ts > since:
|
||||
completions.append(
|
||||
{
|
||||
"job_id": job.get("id", ""),
|
||||
"job_id": job_id,
|
||||
"name": job.get("name", "Unknown"),
|
||||
"status": job.get("last_status", "unknown"),
|
||||
"completed_at": ts,
|
||||
"toast_notifications": job.get("toast_notifications") is not False,
|
||||
}
|
||||
)
|
||||
latest_session_info = _latest_cron_session_info_for_jobs(
|
||||
[job.get("id", "") for job in jobs],
|
||||
[c["job_id"] for c in completions],
|
||||
)
|
||||
for completion in completions:
|
||||
info = latest_session_info.get(str(completion.get("job_id", "") or ""), {})
|
||||
completion["session_id"] = str(info.get("session_id", "") or "")
|
||||
if info.get("message_count") is not None:
|
||||
completion["message_count"] = int(info["message_count"])
|
||||
return j(handler, {"completions": completions, "since": since})
|
||||
except ImportError:
|
||||
return j(handler, {"completions": [], "since": since})
|
||||
|
|
|
|||
|
|
@ -8361,6 +8361,9 @@ function startCronPolling(){
|
|||
}
|
||||
_cronPollSince=Math.max(_cronPollSince,c.completed_at);
|
||||
if(c.job_id) _cronNewJobIds.add(String(c.job_id));
|
||||
if(c.session_id && typeof _markSessionCompletionUnreadIfBackground === 'function'){
|
||||
_markSessionCompletionUnreadIfBackground(c.session_id, c.message_count);
|
||||
}
|
||||
}
|
||||
// _cronUnreadCount is derived from _cronNewJobIds.size in updateCronBadge.
|
||||
updateCronBadge();
|
||||
|
|
|
|||
|
|
@ -255,6 +255,25 @@ function _markSessionCompletionUnread(sid, messageCount = 0) {
|
|||
_saveSessionCompletionUnread();
|
||||
}
|
||||
|
||||
function _markSessionCompletionUnreadIfBackground(sid, messageCount = null) {
|
||||
if (!sid) return false;
|
||||
let count = Number.isFinite(messageCount) ? Number(messageCount) : NaN;
|
||||
if (!Number.isFinite(count)) {
|
||||
const snapshot = _sessionListSnapshotById.get(sid)
|
||||
|| (_allSessions || []).find(s => s && s.session_id === sid)
|
||||
|| null;
|
||||
count = Number(snapshot && snapshot.message_count) || 0;
|
||||
}
|
||||
if (_isSessionActivelyViewedForList(sid)) {
|
||||
_setSessionViewedCount(sid, count);
|
||||
if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache();
|
||||
return false;
|
||||
}
|
||||
_markSessionCompletionUnread(sid, count);
|
||||
if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache();
|
||||
return true;
|
||||
}
|
||||
|
||||
function _clearSessionCompletionUnread(sid) {
|
||||
if (!sid) return;
|
||||
const unread = _getSessionCompletionUnread();
|
||||
|
|
|
|||
|
|
@ -87,6 +87,17 @@ def test_cron_recent_marks_muted_jobs_without_requesting_toast(monkeypatch):
|
|||
"toast_notifications": False,
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_latest_cron_session_info_for_jobs",
|
||||
lambda job_ids, completed_job_ids=None: {
|
||||
str(job_id): {
|
||||
"session_id": f"cron_{job_id}_latest",
|
||||
"message_count": 3 if str(job_id) == "loud" else 5,
|
||||
}
|
||||
for job_id in (completed_job_ids or job_ids)
|
||||
},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
|
|
@ -97,7 +108,11 @@ def test_cron_recent_marks_muted_jobs_without_requesting_toast(monkeypatch):
|
|||
assert handler.status == 200
|
||||
by_id = {item["job_id"]: item for item in body["completions"]}
|
||||
assert by_id["loud"]["toast_notifications"] is True
|
||||
assert by_id["loud"]["session_id"] == "cron_loud_latest"
|
||||
assert by_id["loud"]["message_count"] == 3
|
||||
assert by_id["muted"]["toast_notifications"] is False
|
||||
assert by_id["muted"]["session_id"] == "cron_muted_latest"
|
||||
assert by_id["muted"]["message_count"] == 5
|
||||
|
||||
|
||||
def test_cron_create_persists_muted_toast_setting_after_create(monkeypatch):
|
||||
|
|
|
|||
536
tests/test_issue3460_cron_session_unread.py
Normal file
536
tests/test_issue3460_cron_session_unread.py
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
"""Regression coverage for #3460 cron session unread badges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SESSIONS_JS_PATH = REPO / "static" / "sessions.js"
|
||||
PANELS_JS_PATH = REPO / "static" / "panels.js"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
|
||||
class _JSONHandler:
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.headers = {}
|
||||
self.response_headers = []
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
|
||||
def _payload(handler):
|
||||
return json.loads(handler.wfile.getvalue().decode("utf-8"))
|
||||
|
||||
|
||||
def _run_node(script: str) -> dict:
|
||||
assert NODE is not None
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_cron_recent_returns_latest_session_id_for_job(monkeypatch, tmp_path):
|
||||
import api.routes as routes
|
||||
|
||||
db_path = tmp_path / "state.db"
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT,
|
||||
started_at REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source, started_at) VALUES (?, ?, ?)",
|
||||
("cron_job3460_20260610_060000", "cron", 100.0),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source, started_at) VALUES (?, ?, ?)",
|
||||
("cron_job3460_20260610_070000", "cron", 200.0),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source, started_at) VALUES (?, ?, ?)",
|
||||
("telegram_job3460_20260610_080000", "telegram", 300.0),
|
||||
)
|
||||
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
cron_jobs.list_jobs = lambda include_disabled=True: [
|
||||
{
|
||||
"id": "job3460",
|
||||
"name": "Morning Briefing",
|
||||
"last_run_at": 250,
|
||||
"last_status": "success",
|
||||
}
|
||||
]
|
||||
monkeypatch.setattr(routes, "_active_state_db_path", lambda: db_path)
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_recent(handler, SimpleNamespace(query="since=0"))
|
||||
|
||||
body = _payload(handler)
|
||||
assert handler.status == 200
|
||||
assert body["completions"][0]["job_id"] == "job3460"
|
||||
assert body["completions"][0]["session_id"] == "cron_job3460_20260610_070000"
|
||||
assert body["completions"][0].get("message_count") is None
|
||||
|
||||
|
||||
def test_cron_recent_falls_back_to_id_order_when_started_at_missing(monkeypatch, tmp_path):
|
||||
import api.routes as routes
|
||||
|
||||
db_path = tmp_path / "state.db"
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source) VALUES (?, ?)",
|
||||
("cron_job3460_20260610_060000", "cron"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source) VALUES (?, ?)",
|
||||
("cron_job3460_20260610_070000", "cron"),
|
||||
)
|
||||
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
cron_jobs.list_jobs = lambda include_disabled=True: [
|
||||
{
|
||||
"id": "job3460",
|
||||
"name": "Morning Briefing",
|
||||
"last_run_at": 250,
|
||||
"last_status": "success",
|
||||
}
|
||||
]
|
||||
monkeypatch.setattr(routes, "_active_state_db_path", lambda: db_path)
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_recent(handler, SimpleNamespace(query="since=0"))
|
||||
|
||||
body = _payload(handler)
|
||||
assert handler.status == 200
|
||||
assert body["completions"][0]["session_id"] == "cron_job3460_20260610_070000"
|
||||
|
||||
|
||||
def test_cron_recent_escapes_like_wildcards_in_job_id(monkeypatch, tmp_path):
|
||||
import api.routes as routes
|
||||
|
||||
db_path = tmp_path / "state.db"
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT,
|
||||
started_at REAL,
|
||||
message_count INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)",
|
||||
("cron_job_3460%_20260610_090000", "cron", 300.0, 11),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)",
|
||||
("cron_jobA3460x_20260610_100000", "cron", 400.0, 99),
|
||||
)
|
||||
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
cron_jobs.list_jobs = lambda include_disabled=True: [
|
||||
{
|
||||
"id": "job_3460%",
|
||||
"name": "Wildcard Job",
|
||||
"last_run_at": 500,
|
||||
"last_status": "success",
|
||||
}
|
||||
]
|
||||
monkeypatch.setattr(routes, "_active_state_db_path", lambda: db_path)
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_recent(handler, SimpleNamespace(query="since=0"))
|
||||
|
||||
body = _payload(handler)
|
||||
assert handler.status == 200
|
||||
assert body["completions"][0]["session_id"] == "cron_job_3460%_20260610_090000"
|
||||
assert body["completions"][0]["message_count"] == 11
|
||||
|
||||
|
||||
def test_cron_recent_does_not_cross_match_shared_job_prefixes(monkeypatch, tmp_path):
|
||||
import api.routes as routes
|
||||
|
||||
db_path = tmp_path / "state.db"
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT,
|
||||
started_at REAL,
|
||||
message_count INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)",
|
||||
("cron_backup_20260610_090000", "cron", 100.0, 4),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)",
|
||||
("cron_backup_full_20260610_100000", "cron", 200.0, 8),
|
||||
)
|
||||
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
cron_jobs.list_jobs = lambda include_disabled=True: [
|
||||
{
|
||||
"id": "backup",
|
||||
"name": "Backup",
|
||||
"last_run_at": 150,
|
||||
"last_status": "success",
|
||||
},
|
||||
{
|
||||
"id": "backup_full",
|
||||
"name": "Backup Full",
|
||||
"last_run_at": 250,
|
||||
"last_status": "success",
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(routes, "_active_state_db_path", lambda: db_path)
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_recent(handler, SimpleNamespace(query="since=0"))
|
||||
|
||||
body = _payload(handler)
|
||||
assert handler.status == 200
|
||||
by_id = {item["job_id"]: item for item in body["completions"]}
|
||||
assert by_id["backup"]["session_id"] == "cron_backup_20260610_090000"
|
||||
assert by_id["backup"]["message_count"] == 4
|
||||
assert by_id["backup_full"]["session_id"] == "cron_backup_full_20260610_100000"
|
||||
assert by_id["backup_full"]["message_count"] == 8
|
||||
|
||||
|
||||
def test_cron_recent_does_not_steal_older_long_prefix_history_for_short_job(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
import api.routes as routes
|
||||
|
||||
db_path = tmp_path / "state.db"
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT,
|
||||
started_at REAL,
|
||||
message_count INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)",
|
||||
("cron_backup_full_20260610_110000", "cron", 300.0, 12),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)",
|
||||
("cron_backup_full_20260610_100000", "cron", 200.0, 8),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)",
|
||||
("cron_backup_20260610_090000", "cron", 100.0, 4),
|
||||
)
|
||||
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
cron_jobs.list_jobs = lambda include_disabled=True: [
|
||||
{
|
||||
"id": "backup",
|
||||
"name": "Backup",
|
||||
"last_run_at": 150,
|
||||
"last_status": "success",
|
||||
},
|
||||
{
|
||||
"id": "backup_full",
|
||||
"name": "Backup Full",
|
||||
"last_run_at": 50,
|
||||
"last_status": "success",
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(routes, "_active_state_db_path", lambda: db_path)
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_recent(handler, SimpleNamespace(query="since=0"))
|
||||
|
||||
body = _payload(handler)
|
||||
assert handler.status == 200
|
||||
assert body["completions"][0]["job_id"] == "backup"
|
||||
assert body["completions"][0]["session_id"] == "cron_backup_20260610_090000"
|
||||
assert body["completions"][0]["message_count"] == 4
|
||||
|
||||
|
||||
def test_cron_recent_does_not_cross_match_newer_long_prefix_session_when_only_short_job_completed(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
import api.routes as routes
|
||||
|
||||
db_path = tmp_path / "state.db"
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT,
|
||||
started_at REAL,
|
||||
message_count INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)",
|
||||
("cron_backup_full_20260610_110000", "cron", 300.0, 12),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)",
|
||||
("cron_backup_20260610_090000", "cron", 100.0, 4),
|
||||
)
|
||||
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
cron_jobs.list_jobs = lambda include_disabled=True: [
|
||||
{
|
||||
"id": "backup",
|
||||
"name": "Backup",
|
||||
"last_run_at": 250,
|
||||
"last_status": "success",
|
||||
},
|
||||
{
|
||||
"id": "backup_full",
|
||||
"name": "Backup Full",
|
||||
"last_run_at": 150,
|
||||
"last_status": "success",
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(routes, "_active_state_db_path", lambda: db_path)
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_recent(handler, SimpleNamespace(query="since=200"))
|
||||
|
||||
body = _payload(handler)
|
||||
assert handler.status == 200
|
||||
assert body["completions"] == [
|
||||
{
|
||||
"job_id": "backup",
|
||||
"name": "Backup",
|
||||
"status": "success",
|
||||
"completed_at": 250.0,
|
||||
"toast_notifications": True,
|
||||
"session_id": "cron_backup_20260610_090000",
|
||||
"message_count": 4,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_sessions_helper_marks_background_completion_with_existing_snapshot():
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync({json.dumps(str(SESSIONS_JS_PATH))}, 'utf8');
|
||||
function extractFunc(name) {{
|
||||
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
let _sessionListSnapshotById = new Map([['cron_1', {{message_count: 7}}]]);
|
||||
let _allSessions = [];
|
||||
let viewed = [];
|
||||
let unread = [];
|
||||
let renders = 0;
|
||||
function _isSessionActivelyViewedForList() {{ return false; }}
|
||||
function _setSessionViewedCount(sid, count) {{ viewed.push([sid, count]); }}
|
||||
function _markSessionCompletionUnread(sid, count) {{ unread.push([sid, count]); }}
|
||||
function renderSessionListFromCache() {{ renders += 1; }}
|
||||
global.window = {{}};
|
||||
eval(extractFunc('_markSessionCompletionUnreadIfBackground'));
|
||||
const result = _markSessionCompletionUnreadIfBackground('cron_1');
|
||||
console.log(JSON.stringify({{result, viewed, unread, renders}}));
|
||||
"""
|
||||
payload = _run_node(script)
|
||||
|
||||
assert payload == {
|
||||
"result": True,
|
||||
"viewed": [],
|
||||
"unread": [["cron_1", 7]],
|
||||
"renders": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_sessions_helper_marks_actively_viewed_completion_as_read():
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync({json.dumps(str(SESSIONS_JS_PATH))}, 'utf8');
|
||||
function extractFunc(name) {{
|
||||
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
let _sessionListSnapshotById = new Map();
|
||||
let _allSessions = [{{session_id: 'cron_2', message_count: 9}}];
|
||||
let viewed = [];
|
||||
let unread = [];
|
||||
let renders = 0;
|
||||
function _isSessionActivelyViewedForList(sid) {{ return sid === 'cron_2'; }}
|
||||
function _setSessionViewedCount(sid, count) {{ viewed.push([sid, count]); }}
|
||||
function _markSessionCompletionUnread(sid, count) {{ unread.push([sid, count]); }}
|
||||
function renderSessionListFromCache() {{ renders += 1; }}
|
||||
global.window = {{}};
|
||||
eval(extractFunc('_markSessionCompletionUnreadIfBackground'));
|
||||
const result = _markSessionCompletionUnreadIfBackground('cron_2');
|
||||
console.log(JSON.stringify({{result, viewed, unread, renders}}));
|
||||
"""
|
||||
payload = _run_node(script)
|
||||
|
||||
assert payload == {
|
||||
"result": False,
|
||||
"viewed": [["cron_2", 9]],
|
||||
"unread": [],
|
||||
"renders": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_cron_polling_marks_sidebar_unread_without_needing_toast():
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync({json.dumps(str(PANELS_JS_PATH))}, 'utf8');
|
||||
function extractFunc(name) {{
|
||||
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
let _cronPollSince = 10;
|
||||
let _cronPollTimer = null;
|
||||
let _cronUnreadCount = 0;
|
||||
const _cronNewJobIds = new Set();
|
||||
const markCalls = [];
|
||||
const toastCalls = [];
|
||||
let badgeUpdates = 0;
|
||||
global.document = {{ hidden: false }};
|
||||
global.setInterval = (fn, _ms) => {{ global.__tick = fn; return 1; }};
|
||||
async function api(_url) {{
|
||||
return {{
|
||||
completions: [{{
|
||||
job_id: 'job3460',
|
||||
session_id: 'cron_job3460_20260610_070000',
|
||||
message_count: 12,
|
||||
name: 'Morning Briefing',
|
||||
status: 'success',
|
||||
completed_at: 25,
|
||||
toast_notifications: false
|
||||
}}]
|
||||
}};
|
||||
}}
|
||||
function showToast(...args) {{ toastCalls.push(args); }}
|
||||
function t(...args) {{ return args.join('|'); }}
|
||||
function updateCronBadge() {{ badgeUpdates += 1; }}
|
||||
function _markSessionCompletionUnreadIfBackground(sid, count) {{ markCalls.push([sid, count]); }}
|
||||
eval(extractFunc('startCronPolling'));
|
||||
(async() => {{
|
||||
startCronPolling();
|
||||
await global.__tick();
|
||||
console.log(JSON.stringify({{
|
||||
since: _cronPollSince,
|
||||
unreadJobs: Array.from(_cronNewJobIds),
|
||||
markCalls,
|
||||
toastCalls,
|
||||
badgeUpdates
|
||||
}}));
|
||||
}})().catch(err => {{
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}});
|
||||
"""
|
||||
payload = _run_node(script)
|
||||
|
||||
assert payload == {
|
||||
"since": 25,
|
||||
"unreadJobs": ["job3460"],
|
||||
"markCalls": [["cron_job3460_20260610_070000", 12]],
|
||||
"toastCalls": [],
|
||||
"badgeUpdates": 1,
|
||||
}
|
||||
Loading…
Reference in a new issue