Release OW (v0.51.436): cron sessions mark unread on new runs (#3460) (#4253)

* 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:
nesquena-hermes 2026-06-15 10:52:52 -07:00 committed by GitHub
parent e775fb3355
commit a004e49ebf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 678 additions and 2 deletions

View file

@ -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

View file

@ -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})

View file

@ -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();

View file

@ -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();

View file

@ -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):

View 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,
}