94 lines
3.2 KiB
Python
94 lines
3.2 KiB
Python
|
|
#!/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())
|