intelligence-radar/intelligence_radar/scheduler.py
tegwick 1d4c55ef25 Retain briefs routed by inbound-library as partial mailed teasers.
IR-WP-0010-T03: pull-briefs and a run-due step read routed items through
the library CLI, keep the original message as the artifact, classify
cadence and period, and record schema-2 briefs marked partial with the
unfetched Continue-reading link. Retains the two 4 October Grok mails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 7780@bnt-lap001
Assistant-Session: 622cd705-cb31-4322-ba08-dd42e44fa583
2026-10-06 00:28:11 +02:00

97 lines
4.5 KiB
Python

"""Idempotent daily/weekly source capture and digest jobs, with durable run health."""
from datetime import datetime, timedelta
import fcntl
import json
from pathlib import Path
from zoneinfo import ZoneInfo
from .ingest import ingest
from .store import atomic, put
def run_due(root, config, now=None, runner=ingest):
from .__main__ import draft
root = Path(root)
zone = ZoneInfo(config['timezone'])
now = now or datetime.now(zone)
if now.tzinfo is None:
raise ValueError('scheduler time requires a timezone')
local = now.astimezone(zone)
state_dir = root / '.runtime'
state_dir.mkdir(parents=True, exist_ok=True)
with (state_dir / 'scheduler.lock').open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return {'ok': True, 'status': 'already_running', 'jobs': []}
state_path = state_dir / 'scheduler.json'
state = json.loads(state_path.read_text()) if state_path.exists() else {}
external_report = _external_briefs(root, config, local)
results = []
for job in config['jobs']:
hour, minute = (int(x) for x in job['at'].split(':'))
if not (0 <= hour < 24 and 0 <= minute < 60):
raise ValueError('invalid schedule time')
due = local.replace(hour=hour, minute=minute, second=0, microsecond=0)
if job['cadence'] == 'daily':
if due > local:
due -= timedelta(days=1)
period = due.date().isoformat()
elif job['cadence'] == 'weekly':
due -= timedelta(days=(due.weekday()-job['weekday']) % 7)
if due > local:
due -= timedelta(days=7)
year, week, _ = due.isocalendar()
period = f'{year}-W{week:02}'
else:
raise ValueError('unsupported cadence')
key = job['id'] + ':' + period
if state.get(key, {}).get('ok'):
continue
try:
registry_path = (root / job['sources']).resolve()
if not registry_path.is_relative_to(root.resolve()):
raise ValueError('source registry escapes repository')
registry = json.loads(registry_path.read_text())
result = runner(root, registry)
if not result['ok']:
raise ValueError('one or more sources failed; see ingestion-runs')
# Capture-only changes remain unreviewed. Do not fabricate factual events.
try:
output = draft(root, period)
digest_status = 'created'
except FileExistsError:
output = root / 'digests' / job['cadence'] / f'{period}.md'
digest_status = 'existing_preserved'
entry = {'ok': True, 'finished_at': local.isoformat(), 'period': period,
'digest': str(output.relative_to(root)), 'digest_status': digest_status}
except (ValueError, OSError, KeyError) as exc:
entry = {'ok': False, 'finished_at': local.isoformat(), 'period': period,
'error': str(exc)}
state[key] = entry
put(root, 'scheduler-runs', {'job_id': job['id'], **entry})
atomic(state_path, json.dumps(state, indent=2).encode() + b'\n')
results.append({'job_id': job['id'], **entry})
report = {'ok': all(r['ok'] for r in results), 'status': 'checked', 'jobs': results}
if external_report is not None:
report['external_briefs'] = external_report
return report
def _external_briefs(root, config, now):
spec = config.get('external_briefs')
if not spec:
return None
from .external_briefs import import_inbox, native_status, resolve_inbox
try:
report = import_inbox(root, resolve_inbox(spec),
archive_dirname=spec.get('archive_dirname', 'archive'), now=now)
report['native'] = native_status()
except (ValueError, OSError) as exc:
report = {'ok': False, 'status': 'error', 'error': str(exc), 'files': []}
if config.get('library'):
# A library failure fails only this part of the run; source capture proceeds.
from .library_briefs import pull
report['library'] = pull(root, config['library'], now=now)
report['ok'] = report['ok'] and report['library']['ok']
return report