Add reliable execution reporting and coordinated dashboard views
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Persist and deliver an execution report; never mark integration accepted."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from control import Client,APIError
|
||||
from worker import atomic_json,session_lock
|
||||
|
||||
|
||||
def deliver_file(client,path,now=None):
|
||||
now=time.time() if now is None else now
|
||||
with session_lock('task-report:'+str(path.resolve())):
|
||||
record=json.loads(path.read_text(encoding='utf-8'))
|
||||
if record['status']!='pending' or record.get('next_retry',0)>now:
|
||||
return record
|
||||
try:
|
||||
receipt=client.call('/api/task-reports',record['payload'])
|
||||
except Exception as exc:
|
||||
record['attempts']=record.get('attempts',0)+1
|
||||
terminal=isinstance(exc,APIError) and exc.status in (400,403,409)
|
||||
record['status']='blocked' if terminal or record['attempts']>=10 else 'pending'
|
||||
record['next_retry']=now+min(60,2**record['attempts'])
|
||||
record['error']='Report delivery requires inspection' if record['status']=='blocked' else 'Temporary delivery interruption'
|
||||
atomic_json(path,record)
|
||||
return record
|
||||
record.update(status='accepted',receipt=receipt,accepted_at=now)
|
||||
record.pop('error',None)
|
||||
atomic_json(path,record)
|
||||
return record
|
||||
|
||||
|
||||
def submit(cfg,payload):
|
||||
root=Path(cfg['outbox_dir']).expanduser().resolve()
|
||||
root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
canonical=json.dumps(payload,sort_keys=True,ensure_ascii=True)
|
||||
report_id=payload.get('report_id') or 'report-'+hashlib.sha256(canonical.encode()).hexdigest()[:32]
|
||||
if not isinstance(report_id,str) or not re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}',report_id):
|
||||
raise ValueError('Invalid report id')
|
||||
payload={**payload,'report_id':report_id}
|
||||
path=root/(hashlib.sha256(report_id.encode()).hexdigest()+'.json')
|
||||
if path.is_symlink():raise ValueError('Report file cannot be a symlink')
|
||||
with session_lock('task-report:'+str(path)):
|
||||
if path.exists():
|
||||
record=json.loads(path.read_text(encoding='utf-8'))
|
||||
if record['payload']!=payload:raise ValueError('Report id already used for different content')
|
||||
else:
|
||||
atomic_json(path,{'status':'pending','created':time.time(),'payload':payload,'attempts':0})
|
||||
return deliver_file(Client(cfg['control']),path)
|
||||
|
||||
|
||||
def flush(cfg):
|
||||
root=Path(cfg['outbox_dir']).expanduser().resolve()
|
||||
root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
client=Client(cfg['control'])
|
||||
counts={'pending':0,'accepted':0,'blocked':0,'superseded':0}
|
||||
records=[]
|
||||
for path in root.glob('*.json'):
|
||||
if not re.fullmatch(r'[a-f0-9]{64}\.json',path.name):continue
|
||||
if path.is_symlink():raise ValueError('Unexpected report symlink')
|
||||
records.append((path,json.loads(path.read_text(encoding='utf-8'))))
|
||||
completed={}
|
||||
for _,record in records:
|
||||
payload=record['payload']
|
||||
if payload['stage']=='execution_completed' and record['status'] in ('pending','accepted'):
|
||||
key=(payload['task_id'],payload['session_id'])
|
||||
completed[key]=max(completed.get(key,0),record['created'])
|
||||
for path,record in records:
|
||||
payload=record['payload'];key=(payload['task_id'],payload['session_id'])
|
||||
if record['status']=='pending' and payload['stage']!='execution_completed' and record['created']<completed.get(key,0):
|
||||
with session_lock('task-report:'+str(path.resolve())):
|
||||
current=json.loads(path.read_text(encoding='utf-8'))
|
||||
if current['status']=='pending':
|
||||
current.update(status='superseded',superseded_at=time.time())
|
||||
atomic_json(path,current)
|
||||
record.update(current)
|
||||
pending=sorted(((path,r) for path,r in records if r['status']=='pending'),
|
||||
key=lambda item:(item[1]['payload']['stage']!='execution_completed',item[1]['created']))
|
||||
for path,record in pending[:100]:
|
||||
try:record.update(deliver_file(client,path))
|
||||
except (BlockingIOError,PermissionError):continue
|
||||
for _,record in records:
|
||||
counts[record['status']]=counts.get(record['status'],0)+1
|
||||
return counts
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--config',required=True)
|
||||
p.add_argument('--task-id',required=True)
|
||||
p.add_argument('--session-id',required=True)
|
||||
p.add_argument('--stage',choices=['execution_completed','blocked','progress','failed'],required=True)
|
||||
p.add_argument('--progress',required=True)
|
||||
p.add_argument('--summary',required=True)
|
||||
p.add_argument('--evidence',action='append',default=[])
|
||||
p.add_argument('--version')
|
||||
p.add_argument('--report-id')
|
||||
p.add_argument('--needs-attention',action='store_true')
|
||||
a=p.parse_args()
|
||||
cfg=json.loads(Path(a.config).read_text(encoding='utf-8-sig'))
|
||||
payload={'task_id':a.task_id,'session_id':a.session_id,'stage':a.stage,
|
||||
'current_progress':a.progress,'summary':a.summary,'evidence':a.evidence,
|
||||
'needs_attention':a.needs_attention}
|
||||
if a.version:payload['version']=a.version
|
||||
if a.report_id:payload['report_id']=a.report_id
|
||||
result=submit(cfg,payload)
|
||||
print(json.dumps({'report_id':result['payload']['report_id'],'delivery':result['status'],
|
||||
'execution_report_only':True,'integration_accepted':False},ensure_ascii=True))
|
||||
return 0 if result['status']=='accepted' else 75
|
||||
|
||||
|
||||
if __name__=='__main__':sys.exit(main())
|
||||
Reference in New Issue
Block a user