#!/usr/bin/env python3 """retention-archive-followups.py — retention piece 1 (board bug b67084660385). Archives resolved follow-ups older than 7 days and escalated follow-ups older than 30 days from followups.json into followups.archive.jsonl (append-only). ARCHIVE-ONLY: records are moved from the live file to the archive file; the archive itself is never pruned or hard-deleted by this script. Concurrency: the live file is written atomically (tmp + os.replace) by both this script and bin/followup-sweeper.py. This script snapshots the file's (mtime_ns, size) before deciding the archival set and aborts (rc=2) if the file changed in the meantime — the next run picks it up. """ import json import os import sys from datetime import datetime, timezone, timedelta ROOT = os.environ.get("NETVM_ROOT", "/home/super/Projects/NetVM") LIVE = os.path.join(ROOT, "followups.json") ARCHIVE = os.path.join(ROOT, "followups.archive.jsonl") RESOLVED_AFTER_DAYS = 7 ESCALATED_AFTER_DAYS = 30 def parse_ts(ts): if not ts: return None try: dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00")) return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc) except Exception: return None def main(): if not os.path.exists(LIVE): print("SKIP followups: no live file at %s" % LIVE) return 0 with open(LIVE, encoding="utf-8") as f: raw = f.read() st_before = os.stat(LIVE) data = json.loads(raw) now = datetime.now(timezone.utc) archive, keep = [], {} for key, rec in data.items(): status = rec.get("status") eligible = False reason = "" if status == "resolved": dt = parse_ts(rec.get("resolved_at")) or parse_ts(rec.get("sent_at")) if dt and (now - dt) > timedelta(days=RESOLVED_AFTER_DAYS): eligible, reason = True, "resolved>7d" elif status == "escalated": dt = parse_ts(rec.get("escalated_at")) or parse_ts(rec.get("sent_at")) if dt and (now - dt) > timedelta(days=ESCALATED_AFTER_DAYS): eligible, reason = True, "escalated>30d" if eligible: out = dict(rec) out["_archived_at"] = now.isoformat() out["_archive_reason"] = reason archive.append(out) else: keep[key] = rec if not archive: print("SKIP followups: 0 eligible of %d records (resolved>7d / escalated>30d)" % len(data)) return 0 st_after = os.stat(LIVE) if (st_after.st_mtime_ns, st_after.st_size) != (st_before.st_mtime_ns, st_before.st_size): print("ABORT followups: live file changed during archival decision; retry next run", file=sys.stderr) return 2 with open(ARCHIVE, "a", encoding="utf-8") as f: for rec in archive: f.write(json.dumps(rec) + "\n") tmp = "%s.tmp.retention.%d" % (LIVE, os.getpid()) with open(tmp, "w", encoding="utf-8") as f: json.dump(keep, f, indent=2) os.replace(tmp, LIVE) print("OK followups: archived %d records -> %s (live now %d)" % (len(archive), ARCHIVE, len(keep))) return 0 if __name__ == "__main__": sys.exit(main())