import os,shutil,hashlib,json,sqlite3,datetime,subprocess
from pathlib import Path
now=datetime.datetime.now(datetime.timezone.utc); stamp=now.strftime('%Y%m%dT%H%M%SZ')
backup=Path('/opt/struktur/reports/aa049-backup')/stamp; backup.mkdir(parents=True,exist_ok=True)
paths=['/opt/struktur/obsidian-graphiti-import/aa043/aa043_single_writer.py','/opt/struktur/graphiti/youtube_graphiti_queue.py','/opt/struktur/graphiti/service/main.py','/opt/struktur/graphiti/docker-compose.yml','/opt/struktur/youtube-research/knowledge.db','/opt/struktur/graphiti/request-data/requests.db','/etc/systemd/system/youtube-research-graphiti-worker.service','/etc/systemd/system/obsidian-graphiti-import.service','/etc/systemd/system/obsidian-graphiti-import.timer']
items=[]
for src in paths:
p=Path(src)
if not p.exists(): items.append({'path':src,'exists':False}); continue
dst=backup/('files'+src)
dst.parent.mkdir(parents=True,exist_ok=True); shutil.copy2(p,dst)
h=hashlib.sha256(p.read_bytes()).hexdigest(); st=p.stat()
items.append({'path':src,'backup':str(dst),'bytes':st.st_size,'mtime_utc':datetime.datetime.fromtimestamp(st.st_mtime,datetime.timezone.utc).isoformat(),'sha256':h})
# redacted env inventory, no secret values
for src in ['/etc/graphiti/graphiti.env','/opt/struktur/graphiti/.env']:
p=Path(src)
if p.exists():
lines=[]
for line in p.read_text(errors='replace').splitlines():
if '=' in line: lines.append(line.split('=',1)[0]+'=[REDACTED]')
else: lines.append(line)
q=backup/('env'+src); q.parent.mkdir(parents=True,exist_ok=True); q.write_text('\n'.join(lines)+'\n'); items.append({'path':src,'redacted_backup':str(q),'bytes':p.stat().st_size})
# read-only queue snapshot
c=sqlite3.connect('file:/opt/struktur/youtube-research/knowledge.db?mode=ro',uri=True); c.row_factory=sqlite3.Row
snap={'captured_at_utc':now.isoformat(),'db':'/opt/struktur/youtube-research/knowledge.db','table':'graphiti_import_queue','statuses':[],'source_statuses':[],'open_youtube':None,'open_non_youtube':None}
for r in c.execute('select graphiti_status,count(*) rows,count(distinct video_id) ids from graphiti_import_queue group by graphiti_status order by graphiti_status'): snap['statuses'].append(dict(r))
for r in c.execute('select source_type,graphiti_status,count(*) rows,count(distinct video_id) ids from graphiti_import_queue group by source_type,graphiti_status order by source_type,graphiti_status'): snap['source_statuses'].append(dict(r))
snap['open_youtube']=dict(c.execute("select count(*) rows,count(distinct video_id) ids from graphiti_import_queue where source_type='youtube' and video_id is not null and graphiti_status in ('queued','retry_wait') and (next_attempt_at is null or next_attempt_at<=datetime('now'))").fetchone())
snap['open_non_youtube']=dict(c.execute("select count(*) rows from graphiti_import_queue where source_type!='youtube' and graphiti_status in ('queued','retry_wait')").fetchone())
(backup/'manifest.json').write_text(json.dumps({'captured_at_utc':now.isoformat(),'backup_dir':str(backup),'files':items,'queue_snapshot':snap},indent=2))
print(json.dumps({'backup_dir':str(backup),'manifest':str(backup/'manifest.json'),'queue_snapshot':snap},ensure_ascii=False))