intelligence-radar/intelligence_radar/scheduler.py
tegwick 8249c34100 Retain unarchived ChatGPT and Grok briefs from the local inbox.
IR-WP-0008 imports those drop-folder exports on the due-job timer and records that website automation runs still have no public feed.

Assistant: grok
Assistant-Session: 01a106a5-fc07-78c2-b64f-8e04cea25daf
2026-10-04 14:24:03 +02:00

92 lines
4.2 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()
return report
except (ValueError, OSError) as exc:
return {'ok': False, 'status': 'error', 'error': str(exc), 'files': []}