"""Durable coordination data; no model credentials or process execution.""" import hashlib import json import math import re import secrets import sqlite3 import time from contextlib import contextmanager from datetime import datetime from pathlib import Path from urllib.parse import urlsplit, unquote, quote class Invalid(ValueError): pass class Conflict(Invalid): pass def string(value, name, limit=2000, optional=False): if optional and value is None: return '' if not isinstance(value, str) or (not optional and not value.strip()) or len(value) > limit: raise Invalid('invalid ' + name) return value def identifier(value, name='id'): value = string(value, name, 128) if not re.fullmatch(r'[A-Za-z0-9_.:-]+', value): raise Invalid('invalid ' + name) return value ATTENTION_PAGES = ('overview','tasks','cluster','communication','decisions','logs') ATTENTION_BINDINGS = ('decision_id','task_id','hook_source','hook_event_id') TASK_REPORT_DEFAULTS = {'execution_status':'pending','last_report_at':None,'last_report_id':None,'report_evidence':[]} TASK_RELATION_DEFAULTS = { 'goal':'', 'scope':'', 'coupling_assessment':'', 'owner':'', 'integration_owner':'', 'version':'', 'parent_id':None, 'merged_into_id':None, 'depends_on':[], 'parallel_with':[], 'merge_sources':[], 'parallel_scope':'', 'acceptance_criteria':[], 'sync_points':[], 'integration_status':'not_started'} ETA_ACTIVE = {'queued','running','blocked','waiting'} ETA_DEFAULTS = {'eta_estimated_at':None, 'eta_revision':0, 'eta_change_reason':'', 'eta_last_reviewed_at':None, 'eta_review_status':'unestimated', 'eta_review_reason':'', 'eta_extension_count':0} ETA_MANAGED = set(ETA_DEFAULTS) - {'eta_change_reason'} class Store: def __init__(self, path): self.path = str(path) with self.db() as db: db.executescript(''' PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS sessions(id TEXT PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS tasks(id TEXT PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS requests(id TEXT PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS messages( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, text TEXT NOT NULL, source TEXT NOT NULL, reply_to TEXT, idempotency_key TEXT NOT NULL, status TEXT NOT NULL, created REAL NOT NULL, worker_id TEXT, lease_id TEXT, lease_until REAL, response_id TEXT, UNIQUE(session_id,idempotency_key)); CREATE INDEX IF NOT EXISTS messages_session ON messages(session_id,created); CREATE TABLE IF NOT EXISTS hooks( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, event_id TEXT NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL, UNIQUE(source,event_id,status)); CREATE TABLE IF NOT EXISTS hook_notifications( seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, hook_id INTEGER NOT NULL, target TEXT NOT NULL, data TEXT NOT NULL, delivered_at REAL, UNIQUE(hook_id,target)); CREATE INDEX IF NOT EXISTS hook_notifications_pending ON hook_notifications(delivered_at,seq); CREATE TABLE IF NOT EXISTS decisions( id TEXT PRIMARY KEY, data TEXT NOT NULL, response TEXT, response_key TEXT); CREATE TABLE IF NOT EXISTS decision_notifications( seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, decision_id TEXT NOT NULL UNIQUE, data TEXT NOT NULL, delivered_at REAL); CREATE INDEX IF NOT EXISTS decision_notifications_pending ON decision_notifications(delivered_at,seq); CREATE TABLE IF NOT EXISTS eta_reviews( seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, task_id TEXT NOT NULL, revision INTEGER NOT NULL, checkpoint TEXT NOT NULL, created REAL NOT NULL, delivered_at REAL, cancelled_at REAL, completion_body TEXT, completion_result TEXT, UNIQUE(task_id,revision,checkpoint)); CREATE INDEX IF NOT EXISTS eta_reviews_pending ON eta_reviews(delivered_at,cancelled_at,seq); CREATE TABLE IF NOT EXISTS hook_observations( source TEXT NOT NULL, event_id TEXT NOT NULL, first_seen REAL NOT NULL, last_seen REAL NOT NULL, repeat_count INTEGER NOT NULL, PRIMARY KEY(source,event_id)); CREATE TABLE IF NOT EXISTS hook_escalations( source TEXT NOT NULL, event_id TEXT NOT NULL, request TEXT NOT NULL, hook_id INTEGER NOT NULL, PRIMARY KEY(source,event_id)); CREATE TABLE IF NOT EXISTS attention(id TEXT PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS attention_history( seq INTEGER PRIMARY KEY AUTOINCREMENT, attention_id TEXT NOT NULL, data TEXT NOT NULL, created REAL NOT NULL); CREATE TABLE IF NOT EXISTS task_integrations( seq INTEGER PRIMARY KEY AUTOINCREMENT, target_id TEXT NOT NULL, request_key TEXT NOT NULL, request TEXT NOT NULL, created REAL NOT NULL, UNIQUE(target_id,request_key)); CREATE TABLE IF NOT EXISTS task_reports( seq INTEGER PRIMARY KEY AUTOINCREMENT, report_id TEXT NOT NULL UNIQUE, task_id TEXT NOT NULL, request TEXT NOT NULL, data TEXT NOT NULL); CREATE INDEX IF NOT EXISTS task_reports_task ON task_reports(task_id,seq); CREATE TABLE IF NOT EXISTS history_links( session_id TEXT NOT NULL, external_id TEXT NOT NULL, source TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY(session_id,external_id,source)); ''') db.execute('INSERT OR IGNORE INTO metadata(key,value) VALUES(?,?)',('instance_id',secrets.token_hex(16))) self.instance_id=db.execute('SELECT value FROM metadata WHERE key=?',('instance_id',)).fetchone()['value'] hook_columns = {row['name'] for row in db.execute('PRAGMA table_info(hook_notifications)')} if 'superseded_at' not in hook_columns: db.execute('ALTER TABLE hook_notifications ADD COLUMN superseded_at REAL') if 'submission_started_at' not in hook_columns: db.execute('ALTER TABLE hook_notifications ADD COLUMN submission_started_at REAL') columns = {row['name'] for row in db.execute('PRAGMA table_info(messages)')} if 'started' not in columns: db.execute('ALTER TABLE messages ADD COLUMN started INTEGER NOT NULL DEFAULT 0') if 'submission' not in columns: db.execute('ALTER TABLE messages ADD COLUMN submission TEXT') if 'origin' not in columns: db.execute("ALTER TABLE messages ADD COLUMN origin TEXT NOT NULL DEFAULT 'coordination'") @contextmanager def db(self): db = sqlite3.connect(self.path, timeout=15) db.row_factory = sqlite3.Row try: with db: yield db finally: db.close() def _get(self, db, table, key): row = db.execute('SELECT data FROM ' + table + ' WHERE id=?', (key,)).fetchone() if not row: raise Invalid('unknown ' + table) return json.loads(row['data']) def _put(self, db, table, data): db.execute('INSERT OR REPLACE INTO ' + table + '(id,data) VALUES(?,?)', (data['id'], json.dumps(data, ensure_ascii=False))) def _event(self, db, kind, text, session_id=None): data = dict(kind=kind, text=text, session_id=session_id, created=time.time()) cursor = db.execute('INSERT INTO events(data) VALUES(?)', (json.dumps(data, ensure_ascii=False),)) return dict(data, id=cursor.lastrowid) def session(self, data): sid = identifier(data.get('id') or secrets.token_hex(12)) record = dict(id=sid, name=string(data.get('name'), 'name', 200), wp=string(data.get('wp', ''), 'wp', 100, True), role=string(data.get('role', 'worker'), 'role', 100), worktree=string(data.get('worktree', ''), 'worktree', 2000, True)) thread = string(data.get('thread_id', ''), 'thread_id', 200, True) thread_url=string(data.get('thread_url',''), 'thread_url', 250, True) if thread_url and (not thread or thread_url!='codex://threads/'+thread or not re.fullmatch(r'[0-9a-fA-F-]{36}',thread)): raise Invalid('invalid thread_url') with self.db() as db: db.execute('BEGIN IMMEDIATE') old = db.execute('SELECT data FROM sessions WHERE id=?', (sid,)).fetchone() old = json.loads(old['data']) if old else {} record.update(thread_id=thread or old.get('thread_id', ''), last_seen=old.get('last_seen'), created=old.get('created', time.time())) record['thread_url']=thread_url or old.get('thread_url','') self._put(db, 'sessions', record) self._event(db, 'session.registered', '会话记录已登记', sid) return record def request(self, data): record = dict(id=secrets.token_hex(12), name=string(data.get('name'), 'name', 200), wp=string(data.get('wp', ''), 'wp', 100, True), prompt=string(data.get('prompt'), 'prompt', 16000), status='requested', created=time.time()) with self.db() as db: self._put(db, 'requests', record) self._event(db, 'session.requested', '新建请求等待人工或本机桥接接入') return record @staticmethod def _attention_id(kind, *parts): encoded = json.dumps(parts,ensure_ascii=False,separators=(',',':')) return 'attention-'+kind+'-'+hashlib.sha256(encoded.encode('utf-8')).hexdigest()[:24] @staticmethod def _attention_public(item): return {key:item[key] for key in ('id','page','required','reason','href','updated')} @staticmethod def _attention_href(value): value = string(value,'href',1000) decoded = value for _ in range(5): next_value = unquote(decoded) if next_value == decoded: break decoded = next_value if (unquote(decoded) != decoded or any(c in decoded for c in ('\\','//','<','>')) or any(ord(c)<32 or ord(c)==127 for c in decoded)): raise Invalid('invalid attention href') parsed = urlsplit(decoded) if (parsed.scheme or parsed.netloc or parsed.fragment or parsed.path not in {'/'+page for page in ATTENTION_PAGES} or urlsplit(value).path not in {'/'+page for page in ATTENTION_PAGES} or not value.startswith('/')): raise Invalid('invalid attention href') return value def _attention_write(self, db, item, now): old_row = db.execute('SELECT data FROM attention WHERE id=?',(item['id'],)).fetchone() old = json.loads(old_row['data']) if old_row else None if old: if any(old.get(key) != item.get(key) for key in ('page',)+ATTENTION_BINDINGS): raise Conflict('attention subject differs') if {k:v for k,v in old.items() if k!='updated'} == item: return old, True record = dict(item,updated=now) self._put(db,'attention',record) db.execute('INSERT INTO attention_history(attention_id,data,created) VALUES(?,?,?)', (record['id'],json.dumps(record,ensure_ascii=False),now)) return record, False def _attention_revoke(self, db, criteria, now): for row in db.execute('SELECT data FROM attention').fetchall(): record = json.loads(row['data']) if record['required'] and all(record.get(k)==v for k,v in criteria.items()): record.pop('updated',None) record['required'] = False self._attention_write(db,record,now) def attention(self, data): fields = {'id','page','required','reason','href'} | set(ATTENTION_BINDINGS) if (not isinstance(data,dict) or set(data)-fields or not {'id','page','required','reason'} <= set(data)): raise Invalid('invalid attention fields') aid = identifier(data['id']) page = data['page'] if page not in ATTENTION_PAGES or not isinstance(data['required'],bool): raise Invalid('invalid attention page or required') reason = data['reason'] if not isinstance(reason,str): raise Invalid('invalid attention reason') string(reason,'reason',240,optional=not data['required']) if bool('hook_source' in data) != bool('hook_event_id' in data): raise Invalid('hook identity requires both fields') with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT data FROM attention WHERE id=?',(aid,)).fetchone() old = json.loads(row['data']) if row else {} record = dict(id=aid,page=page,required=data['required'],reason=reason, href=self._attention_href(data.get('href',old.get('href','/'+page)))) for field in ATTENTION_BINDINGS: record[field] = identifier(data[field],field) if field in data else old.get(field) for field,table in (('decision_id','decisions'),('task_id','tasks')): if record[field] is not None: self._get(db,table,record[field]) if record['hook_source'] is not None: found = db.execute("SELECT 1 FROM hooks WHERE source=? AND event_id=? AND status='firing'", (record['hook_source'],record['hook_event_id'])).fetchone() if not found: raise Invalid('unknown hook') # Deterministic automatic IDs cannot be pre-bound to unrelated subjects. if aid.startswith('attention-decision-') and ( record['decision_id'] is None or aid != self._attention_id('decision',record['decision_id'])): raise Invalid('reserved attention id') if aid.startswith('attention-hook-') and ( record['hook_source'] is None or aid != self._attention_id('hook',record['hook_source'],record['hook_event_id'])): raise Invalid('reserved attention id') saved, duplicate = self._attention_write(db,record,time.time()) return dict(self._attention_public(saved),duplicate=duplicate) def _attention_state(self, db): items = [] for row in db.execute('SELECT data FROM attention ORDER BY id'): item = json.loads(row['data']) if not item['required']: continue if item.get('decision_id'): decision = db.execute('SELECT response FROM decisions WHERE id=?',(item['decision_id'],)).fetchone() if not decision or decision['response'] is not None: continue if item.get('task_id'): task = db.execute('SELECT data FROM tasks WHERE id=?',(item['task_id'],)).fetchone() if not task or json.loads(task['data']).get('status') == 'cancelled': continue if item.get('hook_source'): resolved = db.execute("SELECT 1 FROM hooks WHERE source=? AND event_id=? AND status='resolved'", (item['hook_source'],item['hook_event_id'])).fetchone() if resolved: continue items.append(self._attention_public(item)) items.sort(key=lambda item:(item['updated'],item['id']),reverse=True) return {'items':items,'pages':{page:any(i['page']==page for i in items) for page in ATTENTION_PAGES}} def _attention_auto(self, db, kind, parts, page, reason, href, now, **bindings): aid = self._attention_id(kind,*parts) # Creating the source is the only trigger: reads and retries never recreate a withdrawn item. item = dict(id=aid,page=page,required=True,reason=reason[:240],href=href) item.update({key:bindings.get(key) for key in ATTENTION_BINDINGS}) return self._attention_write(db,item,now) @staticmethod def _task_defaults(record): return {**{k:list(v) if isinstance(v,list) else v for k,v in TASK_RELATION_DEFAULTS.items()}, **ETA_DEFAULTS, **TASK_REPORT_DEFAULTS, 'report_evidence':[], 'current_progress':'', 'progress_updated':None, **record} @staticmethod def _task_ids(value, name, known, self_id=None): if not isinstance(value,list) or len(value)>30: raise Invalid('invalid '+name) result = [] for value_id in value: identifier(value_id,name) if value_id not in known or value_id==self_id: raise Invalid('invalid '+name+' reference') if value_id not in result: result.append(value_id) return sorted(result) def _task_relations(self, record, known): record = self._task_defaults(record) for field in ('goal','scope','coupling_assessment','owner','integration_owner','version','parallel_scope'): limit = 4000 if field in ('goal','scope','coupling_assessment') else 2000 if field=='parallel_scope' else 200 if not isinstance(record[field],str): raise Invalid('invalid '+field) string(record[field],field,limit,True) for field in ('parent_id','merged_into_id'): value = record[field] if value is not None: identifier(value,field) if value==record['id'] or value not in known: raise Invalid('invalid '+field+' reference') for field in ('depends_on','parallel_with','merge_sources'): record[field] = self._task_ids(record[field],field,known,record['id']) if set(record['depends_on']) & set(record['parallel_with']) and not record['parallel_scope'].strip(): raise Invalid('parallel dependency requires scoped work and waiting point') criteria = record['acceptance_criteria'] if not isinstance(criteria,list) or len(criteria)>20: raise Invalid('invalid acceptance_criteria') record['acceptance_criteria'] = [string(value,'acceptance criterion',1000) for value in criteria] points = record['sync_points'] if not isinstance(points,list) or len(points)>20: raise Invalid('invalid sync_points') normalized, ids = [], set() for point in points: if not isinstance(point,dict) or set(point)!={'id','label','task_ids','status'}: raise Invalid('invalid sync point fields') point_id = identifier(point['id'],'sync point id') if point_id in ids or point['status'] not in ('waiting','ready','completed'): raise Invalid('invalid sync point') ids.add(point_id) normalized.append(dict(id=point_id,label=string(point['label'],'sync point label',200), task_ids=self._task_ids(point['task_ids'],'sync point tasks',known), status=point['status'])) record['sync_points'] = normalized if record['integration_status'] not in ('not_started','waiting','integrating','integrated','verified'): raise Invalid('invalid integration_status') return record @staticmethod def _validate_task_graphs(tasks): graphs = [ {key:([value['parent_id']] if value.get('parent_id') else []) for key,value in tasks.items()}, {key:value.get('depends_on',[]) for key,value in tasks.items()}, {key:([value['merged_into_id']] if value.get('merged_into_id') else []) for key,value in tasks.items()}, ] # Each relation is its own graph: containment does not impose execution order. for graph in graphs: indegree = {key:0 for key in graph} for source,targets in graph.items(): for target in targets: if target not in graph or target==source: raise Invalid('invalid task relation reference') indegree[target] += 1 ready = [key for key,count in indegree.items() if count==0] visited = 0 while ready: source = ready.pop() visited += 1 for target in graph[source]: indegree[target] -= 1 if indegree[target]==0: ready.append(target) if visited != len(graph): raise Conflict('task relation cycle') for key,task in tasks.items(): sources = task.get('merge_sources',[]) if len(sources)>30: raise Invalid('too many merge sources') for source in sources: if source not in tasks or source==key or tasks[source].get('merged_into_id') != key: raise Conflict('merge relationship is not reciprocal') target = task.get('merged_into_id') if target is not None and key not in tasks[target].get('merge_sources',[]): raise Conflict('merge relationship is not reciprocal') def _stage_task_relations(self, db, record, old, explicit): tasks = {row['id']:self._task_defaults(json.loads(row['data'])) for row in db.execute('SELECT id,data FROM tasks')} original = dict(tasks) record = self._task_relations(record,set(tasks)) tasks[record['id']] = record key = record['id'] def mutable(task_id): # Copy before touching another task so change detection remains reliable. tasks[task_id] = dict(tasks[task_id]) return tasks[task_id] if 'merged_into_id' in explicit: previous, target = old.get('merged_into_id'),record['merged_into_id'] if previous and target and previous!=target: raise Conflict('source already merged elsewhere') if previous!=target: if previous: parent = mutable(previous) parent['merge_sources'] = [x for x in parent['merge_sources'] if x!=key] if target: parent = mutable(target) parent['merge_sources'] = sorted(set(parent['merge_sources'])|{key}) if 'merge_sources' in explicit: previous = set(old.get('merge_sources',[])) wanted = set(record['merge_sources']) for source in previous-wanted: child = mutable(source) if child.get('merged_into_id') != key: raise Conflict('source relationship differs') child['merged_into_id'] = None for source in wanted: child = mutable(source) if child.get('merged_into_id') not in (None,key): raise Conflict('source already merged elsewhere') child['merged_into_id'] = key self._validate_task_graphs(tasks) changed = {task_id:task for task_id,task in tasks.items() if task_id==key or task!=original.get(task_id)} return tasks[key], changed def integrate(self, target_id, data): target_id = identifier(target_id,'target_id') if (not isinstance(data,dict) or set(data)-{'source_ids','owner','status','version','reason'} or not {'source_ids','owner','status','reason'} <= set(data)): raise Invalid('invalid integration fields') owner = string(data['owner'],'owner',200) reason = string(data['reason'],'reason',2000) status = data['status'] if status not in ('waiting','integrating','integrated','verified'): raise Invalid('invalid integration status') if 'version' in data: if not isinstance(data['version'],str): raise Invalid('invalid version') string(data['version'],'version',200,True) with self.db() as db: db.execute('BEGIN IMMEDIATE') tasks = {row['id']:self._task_defaults(json.loads(row['data'])) for row in db.execute('SELECT id,data FROM tasks')} if target_id not in tasks: raise Invalid('unknown integration target') sources = self._task_ids(data['source_ids'],'source_ids',set(tasks),target_id) if not sources: raise Invalid('integration sources required') request = dict(source_ids=sources,owner=owner,status=status,reason=reason) if 'version' in data: request['version'] = data['version'] encoded = json.dumps(request,ensure_ascii=False,sort_keys=True,separators=(',',':')) request_key = hashlib.sha256(encoded.encode('utf-8')).hexdigest() prior = db.execute('SELECT request FROM task_integrations WHERE target_id=? AND request_key=?', (target_id,request_key)).fetchone() if prior: if any(tasks[source].get('merged_into_id')!=target_id or source not in tasks[target_id]['merge_sources'] for source in sources): raise Conflict('previous integration relationship changed') return {'task':tasks[target_id],'sources':[tasks[source] for source in sources],'duplicate':True} target = dict(tasks[target_id]) target['merge_sources'] = sorted(set(target['merge_sources'])|set(sources)) target['integration_owner'] = owner target['integration_status'] = status if 'version' in data: target['version'] = data['version'] target, changed = self._stage_task_relations(db,target,tasks[target_id],{'merge_sources'}) now = time.time() for task_id,task in changed.items(): task['updated'] = now self._put(db,'tasks',task) tasks[task_id] = task db.execute('INSERT INTO task_integrations(target_id,request_key,request,created) VALUES(?,?,?,?)', (target_id,request_key,encoded,now)) self._event(db,'task.integration_updated',json.dumps(dict(target_id=target_id,**request), ensure_ascii=False),target.get('session_id')) return {'task':tasks[target_id],'sources':[tasks[source] for source in sources],'duplicate':False} @staticmethod def _eta_deadline(record): start, upper = record.get('eta_estimated_at'), record.get('eta_max_minutes') if any(isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v) for v in (start,upper)) or upper <= 0: return None deadline = start + upper * 60 return deadline if math.isfinite(deadline) else None def _eta_change(self, record, old, data, now): for field, default in ETA_DEFAULTS.items(): record[field] = old.get(field,default) lo, hi = record.get('eta_min_minutes'), record.get('eta_max_minutes') if lo is not None or hi is not None: if (any(isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v) or v < 0 for v in (lo,hi)) or lo > hi or not math.isfinite(now + hi*60)): raise Invalid('invalid ETA range') if record['status'] in ETA_ACTIVE and hi <= 0: raise Invalid('active ETA upper bound must be positive') string(record.get('eta_basis'),'eta_basis',2000) if 'eta_change_reason' in data: string(data['eta_change_reason'],'eta_change_reason',4000) changed = (lo,hi) != (old.get('eta_min_minutes'),old.get('eta_max_minutes')) # Legacy estimates acquire a clock only when explicitly supplied. initialize = hi is not None and not old.get('eta_revision') and ( 'eta_min_minutes' in data or 'eta_max_minutes' in data) if not changed and not initialize: return False first = not old.get('eta_revision') reason = data.get('eta_change_reason') or (record.get('eta_basis') if first else None) string(reason,'eta_change_reason',4000) new_deadline = now + hi*60 if hi is not None else None old_deadline = self._eta_deadline(old) extension = (not first and new_deadline is not None and record['status'] in ETA_ACTIVE and (old_deadline is None or new_deadline > old_deadline)) if extension and old.get('eta_extension_count',0) >= 3: return True record.update(eta_estimated_at=now if hi is not None else None, eta_revision=old.get('eta_revision',0)+1, eta_change_reason=reason, eta_review_status='scheduled' if hi is not None else 'unestimated', eta_extension_count=old.get('eta_extension_count',0)+int(extension)) return False def _cancel_eta(self, db, record, now): if record.get('status') not in ETA_ACTIVE or self._eta_deadline(record) is None: db.execute('UPDATE eta_reviews SET cancelled_at=? WHERE task_id=? AND cancelled_at IS NULL', (now,record['id'])) else: db.execute('UPDATE eta_reviews SET cancelled_at=? WHERE task_id=? AND revision<>? AND cancelled_at IS NULL', (now,record['id'],record.get('eta_revision',0))) def task(self, data, key=None): if key is not None and 'id' in data and data['id'] != key: raise Invalid('task id differs from target') if 'progress_updated' in data or (ETA_MANAGED | set(TASK_REPORT_DEFAULTS)) & set(data): raise Invalid('server managed task field') key = identifier(key or data.get('id') or secrets.token_hex(12)) rejected = False with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT data FROM tasks WHERE id=?', (key,)).fetchone() old = json.loads(row['data']) if row else {} record = dict(old, **data) record['id'] = key allowed = {'id','name','session_id','status','phase','eta_min_minutes','eta_max_minutes', 'eta_basis','decision','updated','current_progress','progress_updated'} | set(ETA_DEFAULTS) | set(TASK_RELATION_DEFAULTS) | set(TASK_REPORT_DEFAULTS) if set(record) - allowed: raise Invalid('unknown task field') record['name'] = string(record.get('name'), 'name', 200) record.setdefault('status', 'queued') record.setdefault('phase', 'planning') if record['status'] not in ETA_ACTIVE | {'completed','failed','cancelled'}: raise Invalid('invalid status') if record['phase'] not in {'planning','implementation','validation','integration','done'}: raise Invalid('invalid phase') if record['status'] == 'completed' and record['phase'] != 'done': raise Invalid('completed task must have done phase') if record.get('session_id'): self._get(db, 'sessions', identifier(record['session_id'])) for field in ('eta_basis','decision'): record[field] = string(record.get(field, ''), field, 4000, True) progress = record.get('current_progress','') if not isinstance(progress,str): raise Invalid('invalid current_progress') record['current_progress'] = string(progress,'current_progress',1000,True) record, related = self._stage_task_relations(db,record,old,set(data)) now = time.time() rejected = self._eta_change(record,old,data,now) if rejected: record = self._task_defaults(old) record['eta_review_status'] = 'needs_decision' record['eta_review_reason'] = data['eta_change_reason'] record['eta_last_reviewed_at'] = now if old.get('eta_review_status') != 'needs_decision': self._event(db,'eta.needs_decision',record['name'],record.get('session_id')) self._put(db,'tasks',record) else: record['progress_updated'] = now if 'current_progress' in data else old.get('progress_updated') record['updated'] = now self._put(db, 'tasks', record) for related_id,related_task in related.items(): if related_id != key: related_task['updated'] = now self._put(db,'tasks',related_task) self._cancel_eta(db,record,now) if record['status'] == 'cancelled': self._attention_revoke(db,{'task_id':record['id']},now) self._event(db, 'task.updated', record['name'], record.get('session_id')) if rejected: raise Conflict('ETA extension requires decision') return record def task_report(self, data): required = {'report_id','task_id','session_id','stage','current_progress','summary','evidence'} if not isinstance(data,dict) or required-set(data) or set(data)-required-{'version','needs_attention'}: raise Invalid('invalid task report fields') request = {key:identifier(data[key],key) for key in ('report_id','task_id','session_id')} if data['stage'] not in ('execution_completed','blocked','progress','failed'): raise Invalid('invalid report stage') request['stage'] = data['stage'] for field,limit in (('current_progress',1000),('summary',2000)): if not isinstance(data[field],str): raise Invalid('invalid '+field) request[field] = string(data[field],field,limit,True) evidence = data['evidence'] if not isinstance(evidence,list) or len(evidence)>20: raise Invalid('invalid report evidence') request['evidence'] = [string(value,'evidence',1000) for value in evidence] if 'version' in data: if not isinstance(data['version'],str): raise Invalid('invalid version') request['version'] = string(data['version'],'version',200,True) attention = data.get('needs_attention',False) if not isinstance(attention,bool) or (attention and not request['summary'].strip()): raise Invalid('invalid report attention') request['needs_attention'] = attention with self.db() as db: db.execute('BEGIN IMMEDIATE') old = db.execute('SELECT request,data FROM task_reports WHERE report_id=?', (request['report_id'],)).fetchone() if old: if json.loads(old['request']) != request: raise Conflict('report id already used') return {'task':self._task_defaults(self._get(db,'tasks',request['task_id'])), 'report':json.loads(old['data']),'duplicate':True} self._get(db,'sessions',request['session_id']) task = self._task_defaults(self._get(db,'tasks',request['task_id'])) if task.get('session_id') != request['session_id']: raise Conflict('report session does not own task') now = time.time() report = dict(request,created=now) protected = (task.get('status') in ('completed','cancelled') or task.get('integration_status') in ('integrated','verified') or (request['stage']=='progress' and task.get('execution_status')=='completed' and task.get('status')=='waiting' and task.get('phase')=='integration')) if not protected: stage = request['stage'] if stage == 'execution_completed': task.update(execution_status='completed',status='waiting',phase='integration') elif stage == 'progress': task.update(execution_status='running',status='running') else: task.update(execution_status=stage,status=stage) task['current_progress'] = request['current_progress'] task['progress_updated'] = now if 'version' in request: task['version'] = request['version'] task.update(last_report_at=now,last_report_id=request['report_id'], report_evidence=list(request['evidence']),updated=now) db.execute('INSERT INTO task_reports(report_id,task_id,request,data) VALUES(?,?,?,?)', (request['report_id'],request['task_id'],json.dumps(request,ensure_ascii=False), json.dumps(report,ensure_ascii=False))) self._put(db,'tasks',task) self._cancel_eta(db,task,now) if attention: aid = self._attention_id('task-report',task['id']) item = dict(id=aid,page='tasks',required=True,reason=request['summary'][:240], href='/tasks?task='+quote(task['id'],safe='')) item.update({key:task['id'] if key=='task_id' else None for key in ATTENTION_BINDINGS}) self._attention_write(db,item,now) return {'task':task,'report':report,'duplicate':False} def task_reports(self, task_id): task_id = identifier(task_id,'task_id') with self.db() as db: db.execute('BEGIN') self._get(db,'tasks',task_id) rows = db.execute('SELECT data FROM task_reports WHERE task_id=? ORDER BY seq DESC LIMIT 50', (task_id,)).fetchall() return {'reports':[json.loads(row['data']) for row in rows]} def eta_tick(self, now=None): now = time.time() if now is None else now if isinstance(now,bool) or not isinstance(now,(int,float)) or not math.isfinite(now): raise Invalid('invalid clock') created = 0 with self.db() as db: db.execute('BEGIN IMMEDIATE') for row in db.execute('SELECT data FROM tasks').fetchall(): task = self._task_defaults(json.loads(row['data'])) deadline = self._eta_deadline(task) if task.get('status') not in ETA_ACTIVE or deadline is None or not task['eta_revision']: continue elapsed = now - task['eta_estimated_at'] duration = task['eta_max_minutes'] * 60 pending = False for checkpoint,fraction in [('half',0.5),('near',0.8),('overdue',1)]: if elapsed < duration*fraction: continue inserted = db.execute( 'INSERT OR IGNORE INTO eta_reviews(id,task_id,revision,checkpoint,created) VALUES(?,?,?,?,?)', (secrets.token_hex(16),task['id'],task['eta_revision'],checkpoint,now)).rowcount created += inserted pending = pending or bool(inserted) if task['eta_review_status'] != 'needs_decision': if now >= deadline: task['eta_review_status'] = 'overdue' elif pending: task['eta_review_status'] = 'pending' self._put(db,'tasks',task) return {'created':created} def eta_notifications(self): with self.db() as db: db.execute('BEGIN') # Current task data, not the progress snapshot at checkpoint creation. rows = db.execute( "SELECT r.*,t.data AS task_data FROM eta_reviews r JOIN tasks t ON t.id=r.task_id " "WHERE r.delivered_at IS NULL AND r.cancelled_at IS NULL AND r.completion_body IS NULL " "AND r.revision=json_extract(t.data,'$.eta_revision') " "AND json_extract(t.data,'$.status') IN ('queued','running','blocked','waiting') " "ORDER BY r.seq").fetchall() return {'notifications':[{'notification_id':row['id'],'target':'control', 'task':self._task_defaults(json.loads(row['task_data'])), 'checkpoint':row['checkpoint'],'revision':row['revision']} for row in rows[:100]], 'pending_count':len(rows)} def eta_ack(self, data): if not isinstance(data,dict) or set(data) != {'notification_id'}: raise Invalid('invalid acknowledgment fields') nid = identifier(data['notification_id'],'notification_id') with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT delivered_at FROM eta_reviews WHERE id=?',(nid,)).fetchone() if not row: raise Invalid('unknown ETA review') duplicate = row['delivered_at'] is not None if not duplicate: db.execute('UPDATE eta_reviews SET delivered_at=? WHERE id=?',(time.time(),nid)) return {'notification_id':nid,'status':'delivered','duplicate':duplicate} def eta_complete(self, review_id, data): review_id = identifier(review_id,'review_id') allowed = {'result','reason','current_progress','eta_min_minutes','eta_max_minutes'} if not isinstance(data,dict) or set(data)-allowed or not {'result','reason'} <= set(data): raise Invalid('invalid review fields') result = data['result'] if result not in ('keep','revise','unknown'): raise Invalid('invalid review result') string(data['reason'],'reason',4000) ranges = {'eta_min_minutes','eta_max_minutes'} & set(data) if (result == 'revise' and len(ranges) != 2) or (result != 'revise' and ranges): raise Invalid('ETA range is only required for revise') if 'current_progress' in data: if not isinstance(data['current_progress'],str): raise Invalid('invalid current_progress') string(data['current_progress'],'current_progress',1000,True) rejected = False with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT * FROM eta_reviews WHERE id=?',(review_id,)).fetchone() if not row: raise Invalid('unknown ETA review') if row['completion_body'] is not None: if json.loads(row['completion_body']) == data: return json.loads(row['completion_result']) raise Conflict('review already completed') old = self._get(db,'tasks',row['task_id']) if (old.get('status') not in ETA_ACTIVE or old.get('eta_revision') != row['revision'] or row['cancelled_at'] is not None): raise Conflict('obsolete ETA review') now = time.time() task = self._task_defaults(old) if result == 'revise': if data['eta_min_minutes'] is None or data['eta_max_minutes'] is None: raise Invalid('revise requires numeric range') update = {'eta_min_minutes':data['eta_min_minutes'],'eta_max_minutes':data['eta_max_minutes'], 'eta_change_reason':data['reason']} task.update(update) rejected = self._eta_change(task,old,update,now) if rejected: task = self._task_defaults(old) task['eta_review_status'] = 'needs_decision' task['eta_review_reason'] = data['reason'] task['eta_last_reviewed_at'] = now if old.get('eta_review_status') != 'needs_decision': self._event(db,'eta.needs_decision',task['name'],task.get('session_id')) self._put(db,'tasks',task) else: if result == 'unknown' and task['eta_review_status'] != 'needs_decision': task['eta_review_status'] = 'unknown' elif result in ('keep','revise') and task['eta_revision'] == old['eta_revision'] and task['eta_review_status'] != 'needs_decision': task['eta_review_status'] = 'overdue' if now >= self._eta_deadline(task) else 'reviewed' task['eta_last_reviewed_at'] = now task['eta_review_reason'] = data['reason'] task['updated'] = now if 'current_progress' in data: task['current_progress'] = data['current_progress'] task['progress_updated'] = now self._put(db,'tasks',task) self._cancel_eta(db,task,now) db.execute('UPDATE eta_reviews SET completion_body=?,completion_result=? WHERE id=?', (json.dumps(data,ensure_ascii=False),json.dumps(task,ensure_ascii=False),review_id)) self._event(db,'eta.reviewed',task['name'],task.get('session_id')) if rejected: raise Conflict('ETA extension requires decision') return task @staticmethod def public_message(row): result = {k: row[k] for k in ('id','session_id','text','source','reply_to','status','created','response_id')} result['outcome'] = row['status'] if row['status'] in ('completed','failed','uncertain') else None result['origin'] = row['origin'] result['execution'] = 'started' if row['started'] else 'not_started' result['submission'] = json.loads(row['submission']) if row['submission'] else None return result def send(self, data): sid = identifier(data.get('session_id')) body = string(data.get('text'), 'text', 32000) source = data.get('source', 'human') if source not in ('human','agent'): raise Invalid('invalid source') key = identifier(data.get('idempotency_key'), 'idempotency_key') reply = data.get('reply_to') with self.db() as db: db.execute('BEGIN IMMEDIATE') self._get(db, 'sessions', sid) old = db.execute('SELECT * FROM messages WHERE session_id=? AND idempotency_key=?', (sid,key)).fetchone() if old: if (old['text'],old['source'],old['reply_to']) != (body,source,reply): raise Conflict('idempotency conflict') return self.public_message(old) if reply: target = db.execute('SELECT session_id FROM messages WHERE id=?', (identifier(reply),)).fetchone() if not target or target['session_id'] != sid: raise Invalid('reply must belong to same session') mid = secrets.token_hex(12) db.execute('INSERT INTO messages(id,session_id,text,source,reply_to,idempotency_key,status,created) VALUES(?,?,?,?,?,?,?,?)', (mid,sid,body,source,reply,key,'queued' if source == 'human' else 'delivered',time.time())) self._event(db, 'message.sent', '消息已持久保存', sid) return self.public_message(db.execute('SELECT * FROM messages WHERE id=?', (mid,)).fetchone()) def submission(self, data): sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id')) mid, lease = identifier(data.get('message_id')), identifier(data.get('lease_id')) phase = data.get('state') ranks = {'submitting': 1, 'accepted': 2, 'observed': 3} if phase not in ranks: raise Invalid('invalid submission state') thread = string(data.get('thread_id'), 'thread_id', 200) digest = string(data.get('input_sha256'), 'input_sha256', 64) if not re.fullmatch(r'[0-9a-f]{64}', digest): raise Invalid('invalid input_sha256') update = dict(state=phase, thread_id=thread, input_sha256=digest) for field in ('receipt_id', 'turn_id'): if data.get(field) is not None: update[field] = string(data[field], field, 200) if phase == 'accepted' and not update.get('receipt_id'): raise Invalid('accepted requires receipt_id') if phase == 'observed' and not update.get('turn_id'): raise Invalid('observed requires turn_id') with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (mid,sid)).fetchone() if (not row or not row['started'] or row['worker_id'] != worker or row['lease_id'] != lease or row['status'] not in ('claimed','completed','failed') or (row['status'] == 'claimed' and row['lease_until'] <= time.time())): raise Conflict('expired or invalid submission claim') session = self._get(db, 'sessions', sid) if session.get('thread_id') != thread: raise Conflict('submission requires matching bound thread') old = json.loads(row['submission']) if row['submission'] else {} for field in ('thread_id','input_sha256','receipt_id','turn_id'): if old.get(field) and update.get(field) and old[field] != update[field]: raise Conflict('submission identity conflict') merged = dict(old, **update) if ranks.get(old.get('state'),0) > ranks[phase]: merged['state'] = old['state'] db.execute('UPDATE messages SET submission=? WHERE id=?', (json.dumps(merged),mid)) return {'submission': merged} def mirror(self, data): sid = identifier(data.get('session_id')) thread = string(data.get('thread_id'), 'thread_id', 200) items = data.get('items') if not isinstance(items, list) or len(items) > 100: raise Invalid('invalid mirror batch') prepared = [] for item in items: if not isinstance(item, dict): raise Invalid('invalid mirror item') external = string(item.get('id'), 'external item id', 500) source = item.get('source') if source not in ('human','agent'): raise Invalid('invalid mirror source') body = string(item.get('text'), 'text', 262144) created = item.get('created', time.time()) if isinstance(created, bool) or not isinstance(created, (int,float)) or not math.isfinite(created) or created < 0: raise Invalid('invalid mirror timestamp') key = 'mirror:' + hashlib.sha256((thread+'\0'+source+'\0'+external).encode()).hexdigest() related = item.get('related_message_id') if related is not None: related = identifier(related) phase = string(item.get('phase',''), 'phase', 100, True) turn_status = item.get('turn_status','') if turn_status not in ('','inProgress','completed','failed','interrupted'): raise Invalid('invalid turn status') turn_id = string(item.get('turn_id',''), 'turn_id', 200, True) input_text = string(item.get('input_text'), 'input_text', 262144) if related else None prepared.append((key,source,body,created,external,related,phase,turn_status,turn_id,bool(item.get('allow_update',False)),input_text)) with self.db() as db: db.execute('BEGIN IMMEDIATE') session = self._get(db, 'sessions', sid) if not session.get('thread_id') or session['thread_id'] != thread: raise Conflict('mirror requires matching bound thread') imported = 0 deferred = [] for key,source,body,created,external,related,phase,turn_status,turn_id,allow_update,input_text in prepared: local_id = None if related: original = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (related,sid)).fetchone() if not original or original['source'] != 'human': raise Conflict('unknown related input') submission = json.loads(original['submission']) if original['submission'] else {} if (not original['started'] or original['origin'] != 'coordination' or submission.get('thread_id') != thread or submission.get('input_sha256') != hashlib.sha256(input_text.encode('utf-8')).hexdigest() or not turn_id or (submission.get('turn_id') and submission['turn_id'] != turn_id)): raise Conflict('unproven history association') if not submission.get('turn_id'): submission.update(turn_id=turn_id, state='observed') db.execute('UPDATE messages SET submission=? WHERE id=?', (json.dumps(submission),related)) if source == 'human': if body != original['text']: raise Conflict('related input text mismatch') local_id = related elif phase == 'final_answer' and turn_status in ('completed','failed','interrupted'): if not original['started']: raise Conflict('cannot reconcile unstarted execution') if original['status'] == 'claimed' and original['lease_until'] > time.time(): deferred.append(external) continue outcome = 'completed' if turn_status == 'completed' else 'failed' local_id = original['response_id'] or secrets.token_hex(12) if original['response_id']: db.execute("UPDATE messages SET text=?,status=? WHERE id=?", (body,'delivered' if outcome=='completed' else 'failed',local_id)) else: db.execute('INSERT INTO messages(id,session_id,text,source,reply_to,idempotency_key,status,created,origin) VALUES(?,?,?,?,?,?,?,?,?)', (local_id,sid,body,'agent',related,'reply:'+related,'delivered' if outcome=='completed' else 'failed',created,'session_history')) imported += 1 db.execute('UPDATE messages SET status=?,response_id=? WHERE id=?', (outcome,local_id,related)) if local_id: link=dict(message_id=local_id,thread_id=thread,turn_id=turn_id,phase=phase,text_sha256=hashlib.sha256(body.encode()).hexdigest()) db.execute('INSERT OR REPLACE INTO history_links VALUES(?,?,?,?)',(sid,external,source,json.dumps(link))) continue old = db.execute('SELECT * FROM messages WHERE session_id=? AND idempotency_key=?', (sid,key)).fetchone() if old: if old['source'] != source or old['origin'] != 'session_history' or (old['text'] != body and not allow_update): raise Conflict('mirror item conflict') if old['text'] != body: db.execute('UPDATE messages SET text=? WHERE id=?',(body,old['id'])) continue local_id = secrets.token_hex(12) db.execute('INSERT INTO messages(id,session_id,text,source,idempotency_key,status,created,origin) VALUES(?,?,?,?,?,?,?,?)', (local_id,sid,body,source,key,'delivered',created,'session_history')) link=dict(message_id=local_id,thread_id=thread,turn_id=turn_id,phase=phase,text_sha256=hashlib.sha256(body.encode()).hexdigest()) db.execute('INSERT OR REPLACE INTO history_links VALUES(?,?,?,?)',(sid,external,source,json.dumps(link))) imported += 1 # History synchronization is not proof of an available execution bridge. session['history_synced_at'] = time.time() self._put(db, 'sessions', session) return {'imported': imported, 'received': len(items), 'deferred': deferred} def messages(self, sid): sid = identifier(sid) with self.db() as db: self._get(db, 'sessions', sid) rows = db.execute('SELECT * FROM (SELECT * FROM messages WHERE session_id=? ORDER BY created DESC LIMIT 500) ORDER BY created', (sid,)).fetchall() return [self.public_message(row) for row in rows] def conversation(self, sid): from .conversation import project sid = identifier(sid) with self.db() as db: self._get(db, 'sessions', sid) rows = db.execute('SELECT * FROM (SELECT * FROM messages WHERE session_id=? ORDER BY created DESC LIMIT 500) ORDER BY created', (sid,)).fetchall() links = [json.loads(row['data']) for row in db.execute('SELECT data FROM history_links WHERE session_id=?', (sid,))] return project([dict(row) for row in rows], links) def claim(self, data): sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id')) seconds = data.get('lease_seconds', 300) if isinstance(seconds, bool) or not isinstance(seconds, int) or not 10 <= seconds <= 3600: raise Invalid('invalid lease_seconds') now = time.time() with self.db() as db: db.execute('BEGIN IMMEDIATE') session = self._get(db, 'sessions', sid) session['last_seen'] = now self._put(db, 'sessions', session) db.execute("UPDATE messages SET status='uncertain' WHERE session_id=? AND status='claimed' AND started=1 AND lease_until<=?", (sid,now)) if db.execute("SELECT id FROM messages WHERE session_id=? AND status='uncertain'", (sid,)).fetchone(): return {'message': None, 'needs_reconciliation': True} # Serialize each session, even when several bridge workers poll it. if db.execute("SELECT id FROM messages WHERE session_id=? AND status='claimed' AND lease_until>?", (sid,now)).fetchone(): return {'message': None} row = db.execute("SELECT * FROM messages WHERE session_id=? AND (status='queued' OR (status='claimed' AND lease_until<=?)) ORDER BY created LIMIT 1", (sid,now)).fetchone() if not row: return {'message': None} lease = secrets.token_hex(24) db.execute("UPDATE messages SET status='claimed',worker_id=?,lease_id=?,lease_until=? WHERE id=?", (worker,lease,now+seconds,row['id'])) result = self.public_message(db.execute('SELECT * FROM messages WHERE id=?', (row['id'],)).fetchone()) result.update(lease_id=lease, lease_until=now+seconds) return {'message': result} def start(self, data): sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id')) mid, lease = identifier(data.get('message_id')), identifier(data.get('lease_id')) with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (mid,sid)).fetchone() if not row or row['worker_id'] != worker or row['lease_id'] != lease or row['status'] != 'claimed' or row['lease_until'] <= time.time(): raise Conflict('expired or invalid claim') db.execute('UPDATE messages SET started=1 WHERE id=?', (mid,)) return {'started': True} def uncertain(self, data): sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id')) mid, lease = identifier(data.get('message_id')), identifier(data.get('lease_id')) with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (mid,sid)).fetchone() if not row or row['worker_id'] != worker or row['lease_id'] != lease or row['status'] not in ('claimed','uncertain'): raise Conflict('invalid claim') db.execute("UPDATE messages SET status='uncertain',started=1 WHERE id=?", (mid,)) self._event(db, 'message.uncertain', '执行结果不确定;会话已暂停自动领取,等待人工核查', sid) return {'needs_reconciliation': True} def reconcile(self, data): sid, mid = identifier(data.get('session_id')), identifier(data.get('message_id')) action = data.get('action') resolution = string(data.get('resolution'), 'resolution', 4000) if action not in ('retry','failed'): raise Invalid('invalid reconciliation action') with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (mid,sid)).fetchone() if not row or row['status'] != 'uncertain': raise Conflict('not awaiting reconciliation') db.execute('UPDATE messages SET status=?,started=0,worker_id=NULL,lease_id=NULL,lease_until=NULL WHERE id=?', ('queued' if action == 'retry' else 'failed',mid)) self._event(db, 'message.reconciled', action + ': ' + resolution, sid) return {'status': 'queued' if action == 'retry' else 'failed'} def renew(self, data): sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id')) mid, lease = identifier(data.get('message_id')), identifier(data.get('lease_id')) seconds = data.get('lease_seconds', 300) if isinstance(seconds, bool) or not isinstance(seconds, int) or not 10 <= seconds <= 3600: raise Invalid('invalid lease_seconds') now = time.time() with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (mid,sid)).fetchone() if not row or row['worker_id'] != worker or row['lease_id'] != lease or row['status'] != 'claimed' or row['lease_until'] <= now: raise Conflict('expired or invalid claim') db.execute('UPDATE messages SET lease_until=? WHERE id=?', (now+seconds,mid)) session = self._get(db, 'sessions', sid) session['last_seen'] = now self._put(db, 'sessions', session) return {'lease_until': now+seconds} def complete(self, data): sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id')) mid, lease = identifier(data.get('message_id')), identifier(data.get('lease_id')) body = string(data.get('text'), 'text', 262144) outcome = data.get('outcome', 'completed') if outcome not in ('completed','failed'): raise Invalid('invalid outcome') thread = string(data.get('thread_id'), 'thread_id', 200, outcome == 'failed') now = time.time() with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (mid,sid)).fetchone() if not row or row['worker_id'] != worker or row['lease_id'] != lease: raise Conflict('invalid claim') if row['status'] in ('completed','failed'): response = db.execute('SELECT * FROM messages WHERE id=?', (row['response_id'],)).fetchone() if not response or response['text'] != body or row['status'] != outcome: raise Conflict('completion conflict') return self.public_message(response) if row['status'] != 'claimed' or row['lease_until'] <= now: raise Conflict('expired claim') session = self._get(db, 'sessions', sid) if thread and session.get('thread_id') and session['thread_id'] != thread: raise Conflict('thread identity conflict') rid = secrets.token_hex(12) db.execute('INSERT INTO messages(id,session_id,text,source,reply_to,idempotency_key,status,created) VALUES(?,?,?,?,?,?,?,?)', (rid,sid,body,'agent',mid,'reply:'+mid,'delivered' if outcome == 'completed' else 'failed',now)) db.execute("UPDATE messages SET status=?,response_id=? WHERE id=?", (outcome,rid,mid)) session.update(thread_id=thread or session.get('thread_id',''),last_seen=now) self._put(db, 'sessions', session) self._event(db, 'message.' + outcome, '本机桥接已回传结果' if outcome == 'completed' else '本机桥接执行失败', sid) return self.public_message(db.execute('SELECT * FROM messages WHERE id=?', (rid,)).fetchone()) def event(self, data): kind = string(data.get('kind'), 'kind', 100) body = string(data.get('text'), 'text', 8000) sid = data.get('session_id') with self.db() as db: if sid: self._get(db, 'sessions', identifier(sid)) return self._event(db, kind, body, sid) @staticmethod def _hook_record(data): allowed = {'event_id','source','task_id','session_id','severity','status', 'summary','impact','evidence','action','next_step'} required = {'event_id','source','severity','status','summary'} if not isinstance(data, dict) or set(data) - allowed or required - set(data): raise Invalid('invalid hook fields') if data['severity'] not in ('info','warning','error','critical') or data['status'] not in ('firing','resolved'): raise Invalid('invalid hook severity or status') record = dict(event_id=identifier(data['event_id'], 'event_id'), source=identifier(data['source'], 'source'), severity=data['severity'],status=data['status'], summary=string(data['summary'], 'summary', 1000)) for field in ('task_id','session_id'): record[field] = identifier(data[field], field) if data.get(field) is not None else None for field in ('impact','action','next_step'): value = data.get(field, '') if not isinstance(value, str) or len(value) > 2000: raise Invalid('invalid ' + field) record[field] = value evidence = data.get('evidence', []) if not isinstance(evidence, list) or len(evidence) > 8: raise Invalid('invalid evidence') record['evidence'] = [string(value, 'evidence item', 1000) for value in evidence] return record @staticmethod def _notification(row): return {'notification_id': row['id'], 'target': row['target'], 'hook': json.loads(row['data']), 'status': 'delivered' if row['delivered_at'] is not None else 'superseded' if row['superseded_at'] is not None else 'pending', 'submission_started_at':row['submission_started_at'], 'authorization': False} def hook(self, data): # Hooks are untrusted observations, never executable commands or approvals. record = self._hook_record(data) with self.db() as db: db.execute('BEGIN IMMEDIATE') if record['status'] == 'resolved': firing = db.execute('SELECT id,data FROM hooks WHERE source=? AND event_id=? AND status=?', (record['source'],record['event_id'],'firing')).fetchone() if not firing: raise Conflict('resolution requires a firing event') original = json.loads(firing['data']) escalation = db.execute( "SELECT data FROM hooks WHERE source=? AND event_id=? AND status='escalated'", (record['source'],record['event_id'])).fetchone() record['severity'] = 'critical' if escalation else original['severity'] for field in ('task_id','session_id'): if record[field] is not None and record[field] != original[field]: raise Conflict('resolution object conflict') record[field] = original[field] for field, table in (('task_id','tasks'),('session_id','sessions')): if record[field] is not None: self._get(db, table, record[field]) old = db.execute('SELECT id,data FROM hooks WHERE source=? AND event_id=? AND status=?', (record['source'],record['event_id'],record['status'])).fetchone() if old: saved = json.loads(old['data']) if {k:v for k,v in saved.items() if k != 'created'} != record: raise Conflict('hook idempotency conflict') if record['status'] == 'firing': self._observe_hook(db,saved,time.time(),repeat=True) notifications = [self._notification(row) for row in db.execute( 'SELECT * FROM hook_notifications WHERE hook_id=? ORDER BY seq', (old['id'],))] return {'hook':saved,'duplicate':True,'notifications':notifications} record['created'] = time.time() cursor = db.execute('INSERT INTO hooks(source,event_id,status,data) VALUES(?,?,?,?)', (record['source'],record['event_id'],record['status'],json.dumps(record,ensure_ascii=False))) hook_id = cursor.lastrowid if record['status'] == 'firing': self._observe_hook(db,record,record['created']) else: self._observe_hook(db,original,record['created']) self._attention_revoke(db,{'hook_source':record['source'],'hook_event_id':record['event_id']}, record['created']) # Persist one event per transition, with bounded text rather than repeated evidence logs. self._event(db, 'hook.' + record['status'], json.dumps({k:record[k] for k in ('source','event_id','severity','status','summary')}, ensure_ascii=False), record['session_id']) targets = ('control','human') if record['severity'] == 'critical' else ('control',) notice = dict(record,log_id=str(hook_id if record['status']=='firing' else firing['id'])) if record['status']=='resolved' and escalation: notice['escalated'] = True notifications = [] for target in targets: notification_id = secrets.token_hex(12) db.execute('INSERT INTO hook_notifications(id,hook_id,target,data) VALUES(?,?,?,?)', (notification_id,hook_id,target,json.dumps(notice,ensure_ascii=False))) row = db.execute('SELECT * FROM hook_notifications WHERE id=?',(notification_id,)).fetchone() notifications.append(self._notification(row)) return {'hook':record,'duplicate':False,'notifications':notifications} @staticmethod def _observe_hook(db, firing, now, repeat=False): # Counts live separately so original firing/resolution audit rows stay immutable. existing = db.execute('SELECT 1 FROM hook_observations WHERE source=? AND event_id=?', (firing['source'],firing['event_id'])).fetchone() if existing: db.execute('UPDATE hook_observations SET last_seen=max(last_seen,?),repeat_count=repeat_count+? ' 'WHERE source=? AND event_id=?', (now,int(repeat),firing['source'],firing['event_id'])) else: db.execute('INSERT INTO hook_observations(source,event_id,first_seen,last_seen,repeat_count) VALUES(?,?,?,?,?)', (firing['source'],firing['event_id'],firing['created'],max(now,firing['created']),1+int(repeat))) def hook_escalate(self, data): if (not isinstance(data,dict) or set(data)-{'source','event_id','reason','impact'} or not {'source','event_id','reason'} <= set(data)): raise Invalid('invalid escalation fields') request = {'source':identifier(data['source'],'source'), 'event_id':identifier(data['event_id'],'event_id'), 'reason':string(data['reason'],'reason',2000)} if 'impact' in data: if not isinstance(data['impact'],str): raise Invalid('invalid impact') request['impact'] = string(data['impact'],'impact',2000,True) with self.db() as db: db.execute('BEGIN IMMEDIATE') original_row = db.execute("SELECT * FROM hooks WHERE source=? AND event_id=? AND status='firing'", (request['source'],request['event_id'])).fetchone() resolved = db.execute("SELECT 1 FROM hooks WHERE source=? AND event_id=? AND status='resolved'", (request['source'],request['event_id'])).fetchone() if not original_row or resolved: raise Conflict('escalation requires active firing') original = json.loads(original_row['data']) request.setdefault('impact',original['impact']) prior = db.execute('SELECT * FROM hook_escalations WHERE source=? AND event_id=?', (request['source'],request['event_id'])).fetchone() if prior: if json.loads(prior['request']) != request: raise Conflict('escalation already recorded') row = db.execute('SELECT data FROM hooks WHERE id=?',(prior['hook_id'],)).fetchone() notifications = [self._notification(n) for n in db.execute( 'SELECT * FROM hook_notifications WHERE hook_id=? ORDER BY seq',(prior['hook_id'],))] return {'hook':json.loads(row['data']),'duplicate':True,'notifications':notifications} if original['severity'] == 'critical': raise Conflict('event already critical') record = dict(original,severity='critical',status='firing', summary=('持续故障:'+original['summary'])[:1000], impact=request['impact'],created=time.time()) hook_id = db.execute('INSERT INTO hooks(source,event_id,status,data) VALUES(?,?,?,?)', (request['source'],request['event_id'],'escalated', json.dumps(record,ensure_ascii=False))).lastrowid db.execute('INSERT INTO hook_escalations(source,event_id,request,hook_id) VALUES(?,?,?,?)', (request['source'],request['event_id'],json.dumps(request,ensure_ascii=False),hook_id)) self._observe_hook(db,original,record['created']) self._event(db,'hook.escalated',json.dumps(request,ensure_ascii=False),record['session_id']) self._attention_auto(db,'hook',(record['source'],record['event_id']),'logs', self._safe_log_text(record['summary'],240), '/logs?incident='+str(original_row['id']),record['created'], hook_source=record['source'],hook_event_id=record['event_id']) notifications = [] notice = dict(record,escalated=True,log_id=str(original_row['id'])) for target in ('control','human'): nid = secrets.token_hex(12) db.execute('INSERT INTO hook_notifications(id,hook_id,target,data) VALUES(?,?,?,?)', (nid,hook_id,target,json.dumps(notice,ensure_ascii=False))) notifications.append(self._notification( db.execute('SELECT * FROM hook_notifications WHERE id=?',(nid,)).fetchone())) return {'hook':record,'duplicate':False,'notifications':notifications} @staticmethod def _safe_log_text(value, limit=400): # This is a human summary projection, never a raw log export. value = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', str(value)) value = re.sub(r'-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)', '[密钥已隐藏]',value,flags=re.I) value = re.sub(r'(?i)\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+','[凭据已隐藏]',value) value = re.sub(r"""(?ix) \b[\w-]*(?:token|secret|password|passwd|api[_-]?key|private[_-]?key|credential|authorization|cookie|密钥|令牌|密码)[\w-]* ["']?\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;}&]+) """,'[凭据已隐藏]',value) value = re.sub(r'(?i)(https?://)[^/\s:@]+:[^/\s@]+@',r'\1[凭据已隐藏]@',value) value = re.sub(r'\b(?:sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)\b', '[凭据已隐藏]',value) value = re.sub(r'\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b', '[凭据已隐藏]',value) value = re.sub(r'\b[A-Za-z0-9_+/=-]{32,}\b','[标识已隐藏]',value) value = re.sub(r"""(?i)file://[^\s"'<>]+""",'[路径已隐藏]',value) value = re.sub(r"""(?i)\b[A-Z]:[\\/][^\r\n"'<>|]*""",'[路径已隐藏]',value) value = re.sub(r"""\\\\[^\s"'<>]+""",'[路径已隐藏]',value) value = re.sub(r"""(?),;]+""",'[路径已隐藏]',value) value = re.sub(r'[\x00-\x1f\x7f]+',' ',value) return value[:limit] + ('…' if len(value)>limit else '') def logs(self, level=None, status=None): if level is not None and level not in ('info','warning','error','critical'): raise Invalid('invalid log level') if status is not None and status not in ('active','resolved'): raise Invalid('invalid log status') with self.db() as db: db.execute('BEGIN') rows = db.execute(""" SELECT f.id, f.data AS firing, r.data AS resolution, e.data AS escalation, o.first_seen,o.last_seen,o.repeat_count FROM hooks f LEFT JOIN hooks r ON r.source=f.source AND r.event_id=f.event_id AND r.status='resolved' LEFT JOIN hooks e ON e.source=f.source AND e.event_id=f.event_id AND e.status='escalated' LEFT JOIN hook_observations o ON o.source=f.source AND o.event_id=f.event_id WHERE f.status='firing' """).fetchall() logs = [] for row in rows: firing = json.loads(row['firing']) resolution = json.loads(row['resolution']) if row['resolution'] else None effective = json.loads(row['escalation']) if row['escalation'] else firing item_level = effective['severity'] if (firing['source']=='bridge-mirror' and resolution is not None and not row['escalation'] and 0 <= resolution['created']-firing['created'] <= 120): item_level = 'warning' item_status = 'resolved' if resolution else 'active' if (level is not None and level != item_level) or (status is not None and status != item_status): continue latest = resolution or effective first = row['first_seen'] if row['first_seen'] is not None else firing['created'] last = max(row['last_seen'] or first,latest['created']) item = {'id':str(row['id']),'level':item_level,'status':item_status, 'first_seen':first,'last_seen':last, 'resolved_at':resolution['created'] if resolution else None, 'repeat_count':row['repeat_count'] if row['repeat_count'] is not None else 1} for field in ('source','event_id','summary','impact'): item[field] = self._safe_log_text(effective[field]) for field in ('action','next_step'): item[field] = self._safe_log_text(latest[field]) item['evidence'] = [self._safe_log_text(v,240) for v in (latest['evidence'] or effective['evidence'])] logs.append(item) logs.sort(key=lambda item:(item['last_seen'],int(item['id'])),reverse=True) return {'logs':logs[:100],'total_count':len(logs),'has_more':len(logs)>100} def hook_submitting(self, data): if not isinstance(data,dict) or set(data)!={'notification_id'}: raise Invalid('invalid submission marker fields') nid = identifier(data['notification_id'],'notification_id') with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT * FROM hook_notifications WHERE id=?',(nid,)).fetchone() if not row: raise Invalid('unknown notification') if row['superseded_at'] is not None and row['delivered_at'] is None: raise Conflict('notification superseded') duplicate = row['submission_started_at'] is not None started = row['submission_started_at'] if duplicate else time.time() if not duplicate: db.execute('UPDATE hook_notifications SET submission_started_at=? WHERE id=?',(started,nid)) return {'notification_id':nid,'submission_started_at':started,'duplicate':duplicate, 'status':'delivered' if row['delivered_at'] is not None else 'submitting'} def hook_coalesce(self, data): if not isinstance(data,dict) or data: raise Invalid('coalesce requires empty object') with self.db() as db: db.execute('BEGIN IMMEDIATE') now = time.time() rows = db.execute(""" SELECT n.*,h.source,h.event_id,h.status AS transition FROM hook_notifications n JOIN hooks h ON h.id=n.hook_id WHERE n.delivered_at IS NULL AND n.superseded_at IS NULL AND n.submission_started_at IS NULL AND EXISTS (SELECT 1 FROM hooks r WHERE r.source=h.source AND r.event_id=h.event_id AND r.status='resolved') ORDER BY n.seq """).fetchall() count = 0 for row in rows: if row['transition']=='resolved': # A started/unknown submission may already be in the host: retain its recovery. received = db.execute(""" SELECT 1 FROM hook_notifications n JOIN hooks h ON h.id=n.hook_id WHERE h.source=? AND h.event_id=? AND n.target=? AND h.status IN ('firing','escalated') AND (n.delivered_at IS NOT NULL OR n.submission_started_at IS NOT NULL) LIMIT 1 """,(row['source'],row['event_id'],row['target'])).fetchone() if received: continue elif row['transition'] not in ('firing','escalated'): continue count += db.execute( 'UPDATE hook_notifications SET superseded_at=? WHERE id=? AND delivered_at IS NULL ' 'AND superseded_at IS NULL AND submission_started_at IS NULL',(now,row['id'])).rowcount total = db.execute('SELECT count(*) FROM hook_notifications WHERE superseded_at IS NOT NULL').fetchone()[0] return {'superseded_count':count,'total_superseded_count':total, 'scope':'service_owned_unsubmitted_notifications'} def queue_health(self, now=None): now = time.time() if now is None else now if isinstance(now,bool) or not isinstance(now,(int,float)) or not math.isfinite(now): raise Invalid('invalid clock') since = now-60 with self.db() as db: db.execute('BEGIN') def stats(table, created, pending): row = db.execute( 'SELECT sum(CASE WHEN '+pending+' THEN 1 ELSE 0 END) AS pending_count, ' 'min(CASE WHEN '+pending+' THEN '+created+' END) AS oldest_pending_at, ' 'sum(CASE WHEN '+created+' BETWEEN ? AND ? THEN 1 ELSE 0 END) AS created_last_60s, ' 'sum(CASE WHEN delivered_at BETWEEN ? AND ? THEN 1 ELSE 0 END) AS delivered_last_60s ' 'FROM '+table,(since,now,since,now)).fetchone() return {key:(row[key] if key=='oldest_pending_at' else row[key] or 0) for key in row.keys()} hooks = stats('hook_notifications',"json_extract(data,'$.created')", 'delivered_at IS NULL AND superseded_at IS NULL') hooks['submission_started_unconfirmed_count'] = db.execute( 'SELECT count(*) FROM hook_notifications WHERE submission_started_at IS NOT NULL ' 'AND delivered_at IS NULL AND superseded_at IS NULL').fetchone()[0] hooks['superseded_count'] = db.execute( 'SELECT count(*) FROM hook_notifications WHERE superseded_at IS NOT NULL').fetchone()[0] eta_pending = ("delivered_at IS NULL AND cancelled_at IS NULL AND completion_body IS NULL " "AND EXISTS (SELECT 1 FROM tasks t WHERE t.id=eta_reviews.task_id " "AND json_extract(t.data,'$.eta_revision')=eta_reviews.revision " "AND json_extract(t.data,'$.status') IN ('queued','running','blocked','waiting'))") eta = stats('eta_reviews','created',eta_pending) eta['cancelled_count'] = db.execute( 'SELECT count(*) FROM eta_reviews WHERE cancelled_at IS NOT NULL').fetchone()[0] decisions = stats('decision_notifications',"json_extract(data,'$.response.created')", 'delivered_at IS NULL') decisions['cancelled_count'] = 0 reports = db.execute( "SELECT count(*) AS received_total, sum(CASE WHEN json_extract(data,'$.created') " "BETWEEN ? AND ? THEN 1 ELSE 0 END) AS received_last_60s FROM task_reports",(since,now)).fetchone() return {'scope':'service_owned_outboxes','delivery_semantics':'host_accepted_not_model_completed', 'sampled_at':now,'hooks':hooks,'eta':eta,'decisions':decisions, 'task_reports':{key:reports[key] or 0 for key in reports.keys()}} def hook_notifications(self): with self.db() as db: db.execute('BEGIN') count = db.execute('SELECT count(*) FROM hook_notifications WHERE delivered_at IS NULL AND superseded_at IS NULL').fetchone()[0] rows = db.execute('SELECT * FROM hook_notifications WHERE delivered_at IS NULL AND superseded_at IS NULL ORDER BY seq LIMIT 100').fetchall() return {'notifications':[self._notification(row) for row in rows], 'pending_count':count,'has_more':count > len(rows)} def hook_ack(self, data): if not isinstance(data, dict) or set(data) != {'notification_id'}: raise Invalid('invalid hook acknowledgment fields') notification_id = identifier(data['notification_id'], 'notification_id') with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT * FROM hook_notifications WHERE id=?',(notification_id,)).fetchone() if not row: raise Invalid('unknown notification') already = row['delivered_at'] is not None if not already: db.execute('UPDATE hook_notifications SET delivered_at=? WHERE id=?',(time.time(),notification_id)) return {'notification_id':notification_id,'status':'delivered','duplicate':already} @staticmethod def _decision(row): result = json.loads(row['data']) response = json.loads(row['response']) if row['response'] else None result['response'] = response result['status'] = ('pending_verification' if result['requires_authorization'] else 'decided') if response else 'open' return result def decision(self, data): fields = {'id','title','question','impact','priority','details','recommendation', 'options','deadline','requires_authorization','task_id'} if not isinstance(data, dict) or set(data) - fields or not {'id','title','question'} <= set(data): raise Invalid('invalid decision fields') value = {'id':identifier(data['id'])} for name, limit in [('title',200),('question',4000),('impact',4000), ('details',8000),('recommendation',4000)]: raw = data.get(name, '') if not isinstance(raw, str): raise Invalid('invalid ' + name) value[name] = string(raw,name,limit,optional=name not in ('title','question')) priority = data.get('priority','normal') if priority not in ('normal','high','urgent'): raise Invalid('invalid priority') value['priority'] = priority options = data.get('options',[]) if not isinstance(options,list) or len(options) > 20: raise Invalid('invalid options') value['options'] = [] ids = set() for option in options: if not isinstance(option,dict) or set(option) != {'id','label','description'}: raise Invalid('invalid option fields') oid = identifier(option['id'],'option id') if oid in ids or not isinstance(option['description'],str): raise Invalid('invalid option') ids.add(oid) value['options'].append({'id':oid,'label':string(option['label'],'label',200), 'description':string(option['description'],'description',2000,optional=True)}) deadline = data.get('deadline') if deadline is not None: string(deadline,'deadline',64) if not re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})?',deadline): raise Invalid('invalid deadline') try: datetime.fromisoformat(deadline.replace('Z','+00:00')) except ValueError: raise Invalid('invalid deadline') value['deadline'] = deadline auth = data.get('requires_authorization',False) if not isinstance(auth,bool): raise Invalid('invalid requires_authorization') value['requires_authorization'] = auth task = data.get('task_id') value['task_id'] = identifier(task,'task_id') if task is not None else None with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT * FROM decisions WHERE id=?',(value['id'],)).fetchone() if row: if json.loads(row['data']) != value: raise Conflict('decision definition differs') return self._decision(row) if task is not None: self._get(db,'tasks',task) db.execute('INSERT INTO decisions(id,data) VALUES(?,?)', (value['id'],json.dumps(value,ensure_ascii=False))) self._event(db,'decision.created',value['title']) self._attention_auto(db,'decision',(value['id'],),'decisions','待决策:'+value['title'], '/decisions?decision='+quote(value['id'],safe=''),time.time(), decision_id=value['id']) return dict(value,status='open',response=None) def decision_respond(self, decision_id, data): decision_id = identifier(decision_id,'decision_id') if not isinstance(data,dict) or set(data) != {'option_id','comment','idempotency_key'}: raise Invalid('invalid response fields') option = data['option_id'] if option is not None: identifier(option,'option_id') if not isinstance(data['comment'],str): raise Invalid('invalid comment') comment = string(data['comment'],'comment',8000,optional=True) key = identifier(data['idempotency_key'],'idempotency_key') with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT * FROM decisions WHERE id=?',(decision_id,)).fetchone() if not row: raise Invalid('unknown decision') value = self._decision(row) if row['response']: old = value['response'] if row['response_key'] == key and old['option_id'] == option and old['comment'] == comment: return value raise Conflict('decision already answered') valid_options = {o['id'] for o in value['options']} if (valid_options and option not in valid_options) or (not valid_options and option is not None): raise Invalid('invalid choice') if option is None and not comment.strip(): raise Invalid('response required') response = {'id':secrets.token_hex(16),'option_id':option,'comment':comment, 'created':time.time(),'authorization':False} value['response'] = response value['status'] = 'pending_verification' if value['requires_authorization'] else 'decided' db.execute('UPDATE decisions SET response=?,response_key=? WHERE id=?', (json.dumps(response,ensure_ascii=False),key,decision_id)) db.execute('INSERT INTO decision_notifications(id,decision_id,data) VALUES(?,?,?)', (secrets.token_hex(16),decision_id,json.dumps(value,ensure_ascii=False))) self._attention_revoke(db,{'decision_id':decision_id},response['created']) self._event(db,'decision.responded',value['title']) return value def decision_notifications(self): with self.db() as db: db.execute('BEGIN') count = db.execute('SELECT count(*) FROM decision_notifications WHERE delivered_at IS NULL').fetchone()[0] rows = db.execute('SELECT * FROM decision_notifications WHERE delivered_at IS NULL ORDER BY seq LIMIT 100').fetchall() return {'notifications':[{'notification_id':row['id'],'target':'control', 'decision':json.loads(row['data'])} for row in rows], 'pending_count':count} def decision_ack(self, data): if not isinstance(data,dict) or set(data) != {'notification_id'}: raise Invalid('invalid acknowledgment fields') nid = identifier(data['notification_id'],'notification_id') with self.db() as db: db.execute('BEGIN IMMEDIATE') row = db.execute('SELECT delivered_at FROM decision_notifications WHERE id=?',(nid,)).fetchone() if not row: raise Invalid('unknown notification') duplicate = row['delivered_at'] is not None if not duplicate: db.execute('UPDATE decision_notifications SET delivered_at=? WHERE id=?',(time.time(),nid)) return {'notification_id':nid,'status':'delivered','duplicate':duplicate} def state(self): with self.db() as db: db.execute('BEGIN') result = {table: [json.loads(row['data']) for row in db.execute('SELECT data FROM ' + table + ' ORDER BY id')] for table in ('sessions','tasks','requests')} result['tasks'] = [self._task_defaults(task) for task in result['tasks']] result['attention'] = self._attention_state(db) result['decisions'] = [self._decision(row) for row in db.execute('SELECT * FROM decisions ORDER BY id')] result['events'] = [dict(json.loads(row['data']),id=row['id']) for row in db.execute('SELECT * FROM events ORDER BY id DESC LIMIT 100')] result['hooks'] = { 'pending_notifications': db.execute('SELECT count(*) FROM hook_notifications WHERE delivered_at IS NULL AND superseded_at IS NULL').fetchone()[0], 'active_firing': db.execute("SELECT count(*) FROM hooks f WHERE f.status='firing' AND NOT EXISTS (SELECT 1 FROM hooks r WHERE r.source=f.source AND r.event_id=f.event_id AND r.status='resolved')").fetchone()[0], 'recent': [json.loads(row['data']) for row in db.execute('SELECT data FROM hooks ORDER BY id DESC LIMIT 20')], } result['summary'] = {row['status']: row['n'] for row in db.execute('SELECT json_extract(data,\'$.status\') status,count(*) n FROM tasks GROUP BY status')} now = time.time() for session in result['sessions']: session['connection'] = ('connected' if session.get('thread_id') and session.get('last_seen') and now-session['last_seen'] < 120 else 'waiting') result['updated'] = now result['instance_id'] = self.instance_id return result