#!/usr/bin/env python3
"""
Review-Queue Expire-Job
Zweck: pending Reviews mit abgelaufener expires_at auf expired setzen.
Keine Löschung. Keine Obsidian-/Graphiti-Aktion. Keine automatischen Approvals.
"""
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
REVIEW_DIR = Path("/opt/struktur/memory/review-queue")
AUDIT_LOG = Path("/opt/struktur/memory/expire_audit.jsonl")
ACTOR = "system-expire-job"
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def parse_dt(s: str) -> datetime:
"""ISO-8601 String → datetime (timezone-aware)."""
# Python 3.11+ unterstützt Z direkt; für ältere Versionen ersetzen
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def write_audit(entry: dict):
AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True)
with open(AUDIT_LOG, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
def expire_reviews(dry_run: bool = False) -> dict:
now = now_utc()
stats = {"checked": 0, "expired": 0, "skipped": 0, "errors": 0}
if not REVIEW_DIR.exists():
print(f"[WARN] Review-Verzeichnis nicht gefunden: {REVIEW_DIR}")
return stats
for fp in sorted(REVIEW_DIR.glob("*.json")):
stats["checked"] += 1
try:
with open(fp, "r", encoding="utf-8") as f:
review = json.load(f)
review_id = review.get("review_id", fp.stem)
# Nur pending Reviews prüfen
if review.get("status") != "pending":
stats["skipped"] += 1
continue
# expires_at prüfen
expires_at_raw = review.get("expires_at")
if not expires_at_raw:
print(f"[WARN] {review_id}: kein expires_at — übersprungen")
stats["skipped"] += 1
continue
expires_at = parse_dt(expires_at_raw)
if expires_at >= now:
# Noch nicht abgelaufen
remaining = expires_at - now
days = remaining.days
print(f"[OK] {review_id}: noch {days}d übrig")
stats["skipped"] += 1
continue
# Abgelaufen → Status auf expired setzen
old_status = review["status"]
expired_at = now.isoformat()
audit_entry = {
"timestamp": expired_at,
"review_id": review_id,
"old_status": old_status,
"new_status": "expired",
"actor": ACTOR,
"source_file": str(fp),
"expires_at": expires_at_raw,
}
if dry_run:
print(f"[DRY] {review_id}: würde expired gesetzt (abgelaufen seit {(now - expires_at).days}d)")
stats["expired"] += 1
continue
review["status"] = "expired"
review["expired_at"] = expired_at
review["expiration_reason"] = "auto_expired_after_14_days"
review["auto_expired"] = True
# Sicherheitsfelder explizit erhalten
review["obsidian_write_allowed"] = False
review["graphiti_write_allowed"] = False
with open(fp, "w", encoding="utf-8") as f:
json.dump(review, f, ensure_ascii=False, indent=2)
write_audit(audit_entry)
print(f"[EXP] {review_id}: pending → expired (abgelaufen seit {(now - expires_at).days}d)")
stats["expired"] += 1
except Exception as e:
print(f"[ERR] {fp.name}: {e}")
stats["errors"] += 1
return stats
def main():
dry_run = "--dry-run" in sys.argv
mode = "DRY-RUN" if dry_run else "LIVE"
print(f"=== Review Expire-Job [{mode}] — {now_utc().isoformat()} ===")
print(f" Review-Dir: {REVIEW_DIR}")
print(f" Audit-Log: {AUDIT_LOG}")
print()
stats = expire_reviews(dry_run=dry_run)
print()
print(f"=== Ergebnis ===")
print(f" Geprüft: {stats['checked']}")
print(f" Expired: {stats['expired']}")
print(f" Skipped: {stats['skipped']}")
print(f" Fehler: {stats['errors']}")
if stats["errors"] > 0:
sys.exit(1)
if __name__ == "__main__":
main()