2063 lines
122 KiB
Python
2063 lines
122 KiB
Python
"""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 = {
|
|
'kind':'work', 'delivery_id':None,
|
|
'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.executescript("""
|
|
CREATE TABLE IF NOT EXISTS decision_history(
|
|
seq INTEGER PRIMARY KEY AUTOINCREMENT, decision_id TEXT NOT NULL, data TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS decision_operations(
|
|
decision_id TEXT NOT NULL, action TEXT NOT NULL, key TEXT NOT NULL,
|
|
request TEXT NOT NULL, result TEXT NOT NULL, PRIMARY KEY(decision_id,action,key));
|
|
CREATE TABLE IF NOT EXISTS decision_workflow_notifications(
|
|
seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT UNIQUE NOT NULL,
|
|
decision_id TEXT NOT NULL, event_key TEXT NOT NULL UNIQUE, data TEXT NOT NULL,
|
|
delivered_at REAL, cancelled_at REAL, submission_started_at REAL);
|
|
CREATE TABLE IF NOT EXISTS decision_credentials(
|
|
token_hash TEXT PRIMARY KEY, decision_id TEXT NOT NULL, created REAL NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS decision_facts(
|
|
id TEXT PRIMARY KEY, decision_id TEXT NOT NULL, data TEXT NOT NULL);
|
|
""")
|
|
db.execute('BEGIN IMMEDIATE')
|
|
if 'workflow' not in {r['name'] for r in db.execute('PRAGMA table_info(decisions)')}:
|
|
db.execute('ALTER TABLE decisions ADD COLUMN workflow TEXT')
|
|
for row in db.execute('SELECT * FROM decisions WHERE workflow IS NULL').fetchall():
|
|
value = self._decision(row)
|
|
self._save_decision(db,value)
|
|
self._decision_history(db,value,'version',self._decision_content(value))
|
|
if value['response']:
|
|
self._decision_history(db,value,'submission',value['response'])
|
|
self._decision_notice(db,value,'session_required','control','session:'+value['id'])
|
|
old_notice = db.execute('SELECT * FROM decision_notifications WHERE decision_id=?',(value['id'],)).fetchone()
|
|
if old_notice:
|
|
payload = dict(notification_id=old_notice['id'],target='control',decision=value,
|
|
event=dict(kind='submitted',revision=1,created=value['response'].get('created')))
|
|
db.execute('INSERT OR IGNORE INTO decision_workflow_notifications(id,decision_id,event_key,data,delivered_at) VALUES(?,?,?,?,?)',
|
|
(old_notice['id'],value['id'],'legacy:'+value['id'],json.dumps(payload),old_notice['delivered_at']))
|
|
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 {}
|
|
if old and old.get('role')!='decision' and record['role']=='decision':
|
|
raise Conflict('executor or main session cannot become consultation')
|
|
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','')
|
|
for decision_row in db.execute('SELECT * FROM decisions'):
|
|
bound=self._decision(decision_row)
|
|
if bound['session_id']==sid:
|
|
if (record['role']!='decision' or record['wp']!=bound['id']
|
|
or record['thread_id']!=bound['session_thread_id']):
|
|
raise Conflict('bound session identity immutable')
|
|
elif bound['session_thread_id'] and record['thread_id'].lower()==bound['session_thread_id'].lower():
|
|
raise Conflict('bound thread cannot be reused')
|
|
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 * FROM decisions WHERE id=?',(item['decision_id'],)).fetchone()
|
|
if not decision or self._decision(decision)['status'] not in ('open','needs_reconsideration'):
|
|
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, 'created_at':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)
|
|
if record['kind'] not in ('work','delivery'):
|
|
raise Invalid('invalid task kind')
|
|
if record['kind']=='delivery' and record['delivery_id'] is not None:
|
|
raise Invalid('delivery cannot belong to another delivery')
|
|
for field in ('parent_id','merged_into_id','delivery_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):
|
|
for task_id,task in tasks.items():
|
|
delivery = task.get('delivery_id')
|
|
if delivery is not None:
|
|
if delivery==task_id or delivery not in tasks:
|
|
raise Invalid('invalid delivery reference')
|
|
if task.get('kind','work')=='delivery' or tasks[delivery].get('kind','work')!='delivery':
|
|
raise Conflict('delivery reference requires a delivery task')
|
|
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 'created_at' in data or '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','created_at'} | 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()
|
|
record['created_at'] = old.get('created_at') if row else now
|
|
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):
|
|
from .conversation import delivery_fields
|
|
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
|
|
result.update(delivery_fields(dict(row)))
|
|
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)
|
|
for decision_row in db.execute('SELECT * FROM decisions'):
|
|
bound=self._decision(decision_row)
|
|
if bound['session_id']==sid and bound['status']=='completed':
|
|
raise Conflict('consultation closed')
|
|
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
|
|
from .notices import fact_answer_notice
|
|
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,))]
|
|
notices = [json.loads(row['data']) for row in db.execute(
|
|
"SELECT data FROM decision_workflow_notifications WHERE json_extract(data,'$.target')='decision' "
|
|
"AND json_extract(data,'$.decision.session_id')=? AND json_extract(data,'$.event.kind')='fact_answered'", (sid,))]
|
|
return project([dict(row) for row in rows], links, {fact_answer_notice(n) for n in notices})
|
|
|
|
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"""(?<![\w:/])/(?!/)[^\s"'<>),;]+""",'[路径已隐藏]',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_workflow_notifications',"json_extract(data,'$.event.created')",
|
|
'delivered_at IS NULL AND cancelled_at IS NULL')
|
|
decisions['cancelled_count'] = db.execute('SELECT count(*) FROM decision_workflow_notifications WHERE cancelled_at IS NOT NULL').fetchone()[0]
|
|
decisions['submission_started_unconfirmed_count'] = db.execute('SELECT count(*) FROM decision_workflow_notifications WHERE submission_started_at IS NOT NULL AND delivered_at IS NULL AND cancelled_at IS NULL').fetchone()[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}
|
|
|
|
|
|
DECISION_FIELDS = {'id','title','question','impact','priority','details','recommendation',
|
|
'options','deadline','requires_authorization','task_id','summary','supersedes_id'}
|
|
|
|
@classmethod
|
|
def _decision_content(cls, value):
|
|
return {k:value.get(k) for k in cls.DECISION_FIELDS}
|
|
|
|
@classmethod
|
|
def _decision(cls, row):
|
|
if 'workflow' in row.keys() and row['workflow']:
|
|
return json.loads(row['workflow'])
|
|
result = json.loads(row['data'])
|
|
result.setdefault('summary','')
|
|
result.setdefault('supersedes_id',None)
|
|
response = json.loads(row['response']) if row['response'] else None
|
|
if response:
|
|
response = dict(response,revision=1,snapshot=cls._decision_content(result))
|
|
result.update(response=response,status='pending_review' if response else 'open',revision=1,
|
|
session_id=None,session_thread_id=None,archive_status='none',review=None,
|
|
completion=None,related_decision_ids=[])
|
|
return result
|
|
|
|
def _load_decision(self, db, did):
|
|
row = db.execute('SELECT * FROM decisions WHERE id=?',(identifier(did,'decision_id'),)).fetchone()
|
|
if not row:
|
|
raise Invalid('unknown decision')
|
|
return self._decision(row)
|
|
|
|
def _save_decision(self, db, value):
|
|
db.execute('UPDATE decisions SET workflow=?,response=? WHERE id=?',
|
|
(json.dumps(value,ensure_ascii=False),json.dumps(value['response']) if value['response'] else None,value['id']))
|
|
|
|
def _decision_history(self, db, value, kind, data):
|
|
event = dict(id=secrets.token_hex(16),kind=kind,revision=value['revision'],created=time.time(),data=data)
|
|
db.execute('INSERT INTO decision_history(decision_id,data) VALUES(?,?)',(value['id'],json.dumps(event,ensure_ascii=False)))
|
|
|
|
def _decision_notice(self, db, value, kind, target, event_key, **details):
|
|
nid = secrets.token_hex(16)
|
|
payload = dict(notification_id=nid,target=target,decision=value,
|
|
event=dict(kind=kind,revision=value['revision'],created=time.time(),**details))
|
|
db.execute('INSERT OR IGNORE INTO decision_workflow_notifications(id,decision_id,event_key,data) VALUES(?,?,?,?)',
|
|
(nid,value['id'],event_key,json.dumps(payload,ensure_ascii=False)))
|
|
|
|
def _cancel_decision_notices(self, db, did, kind, request_id=None):
|
|
# Once external submission has begun the immutable input must remain reconcilable.
|
|
sql = "UPDATE decision_workflow_notifications SET cancelled_at=? WHERE decision_id=? AND delivered_at IS NULL AND submission_started_at IS NULL AND json_extract(data,'$.event.kind')=?"
|
|
args = [time.time(),did,kind]
|
|
if request_id is not None:
|
|
sql += " AND json_extract(data,'$.event.request_id')=?"
|
|
args.append(request_id)
|
|
db.execute(sql,args)
|
|
|
|
@staticmethod
|
|
def _decision_revision(value, data):
|
|
rev = data.get('expected_revision')
|
|
if type(rev) is not int or rev < 1:
|
|
raise Invalid('positive expected_revision required')
|
|
if rev != value['revision']:
|
|
raise Conflict('decision revision changed')
|
|
|
|
@staticmethod
|
|
def _decision_fields(data, required, optional=()):
|
|
if not isinstance(data,dict) or not set(required) <= data.keys() or data.keys()-set(required)-set(optional):
|
|
raise Invalid('invalid workflow fields')
|
|
|
|
def _decision_replay(self, db, did, action, data):
|
|
if 'expected_revision' in data and (type(data['expected_revision']) is not int or data['expected_revision'] < 1):
|
|
raise Invalid('positive expected_revision required')
|
|
key = identifier(data.get('idempotency_key'),'idempotency_key')
|
|
row = db.execute('SELECT * FROM decision_operations WHERE decision_id=? AND action=? AND key=?',(did,action,key)).fetchone()
|
|
if row:
|
|
if json.loads(row['request']) != data:
|
|
raise Conflict('idempotency conflict')
|
|
return json.loads(row['result'])
|
|
|
|
def _decision_record(self, db, value, action, data, result=None):
|
|
result = value if result is None else result
|
|
db.execute('INSERT INTO decision_operations VALUES(?,?,?,?,?)',
|
|
(value['id'],action,data['idempotency_key'],json.dumps(data,ensure_ascii=False),json.dumps(result,ensure_ascii=False)))
|
|
return result
|
|
|
|
def decision_get(self, did):
|
|
with self.db() as db:
|
|
return self._load_decision(db,did)
|
|
|
|
def decision_history(self, did):
|
|
with self.db() as db:
|
|
self._load_decision(db,did)
|
|
return {'history':[json.loads(r['data']) for r in db.execute('SELECT data FROM decision_history WHERE decision_id=? ORDER BY seq',(did,))]}
|
|
|
|
def _normalize_decision(self, data):
|
|
fields = self.DECISION_FIELDS
|
|
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),('summary',1000)]:
|
|
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
|
|
supersedes = data.get('supersedes_id')
|
|
value['supersedes_id'] = identifier(supersedes,'supersedes_id') if supersedes is not None else None
|
|
return value
|
|
|
|
def decision(self, data):
|
|
value = self._normalize_decision(data)
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
row = db.execute('SELECT * FROM decisions WHERE id=?',(value['id'],)).fetchone()
|
|
if row:
|
|
if self._normalize_decision(json.loads(row['data'])) != value:
|
|
raise Conflict('decision definition differs')
|
|
return self._decision(row)
|
|
if value['task_id']:
|
|
self._get(db,'tasks',value['task_id'])
|
|
parent = None
|
|
if value['supersedes_id']:
|
|
if value['supersedes_id'] == value['id']:
|
|
raise Invalid('self supersedes')
|
|
parent = self._load_decision(db,value['supersedes_id'])
|
|
db.execute('INSERT INTO decisions(id,data) VALUES(?,?)',(value['id'],json.dumps(value,ensure_ascii=False)))
|
|
value.update(status='open',response=None,revision=1,session_id=None,session_thread_id=None,
|
|
archive_status='none',review=None,completion=None,
|
|
related_decision_ids=[parent['id']] if parent else [])
|
|
if parent:
|
|
parent['related_decision_ids'].append(value['id'])
|
|
self._save_decision(db,parent)
|
|
self._decision_history(db,parent,'linked',{'decision_id':value['id']})
|
|
self._save_decision(db,value)
|
|
self._decision_history(db,value,'version',self._decision_content(value))
|
|
self._decision_notice(db,value,'session_required','control','session:'+value['id'])
|
|
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 value
|
|
|
|
def decision_patch(self, did, data):
|
|
self._decision_fields(data,{'expected_revision','reason'},self.DECISION_FIELDS-{'id','supersedes_id'})
|
|
reason = string(data['reason'],'reason',4000)
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
value = self._load_decision(db,did)
|
|
self._decision_revision(value,data)
|
|
if value['status'] == 'completed':
|
|
raise Conflict('decision closed')
|
|
old = self._decision_content(value)
|
|
changes = {k:v for k,v in data.items() if k in self.DECISION_FIELDS}
|
|
if not changes:
|
|
raise Invalid('content changes required')
|
|
content = self._normalize_decision(dict(old,**changes))
|
|
if content['task_id']:
|
|
self._get(db,'tasks',content['task_id'])
|
|
if content == old:
|
|
return value
|
|
value.update(content)
|
|
value['revision'] += 1
|
|
if value['response']:
|
|
value['status'] = 'needs_reconsideration'
|
|
self._save_decision(db,value)
|
|
self._decision_history(db,value,'version',dict(content=content,reason=reason))
|
|
self._cancel_decision_notices(db,did,'submitted')
|
|
if value['status']=='needs_reconsideration':
|
|
self._attention_auto(db,'decision',(did,),'decisions','需要重新确认:'+value['title'],
|
|
'/decisions?decision='+quote(did,safe=''),time.time(),decision_id=did)
|
|
return value
|
|
|
|
def decision_respond(self, did, data):
|
|
self._decision_fields(data,{'expected_revision','option_id','comment','idempotency_key'})
|
|
option = data['option_id']
|
|
if option is not None:
|
|
identifier(option,'option_id')
|
|
comment = string(data['comment'],'comment',8000,optional=True)
|
|
if not isinstance(data['comment'],str) or (option is None and not comment.strip()):
|
|
raise Invalid('response required')
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
value = self._load_decision(db,did)
|
|
replay = self._decision_replay(db,did,'respond',data)
|
|
if replay is not None:
|
|
return replay
|
|
self._decision_revision(value,data)
|
|
if value['status'] not in ('open','needs_reconsideration'):
|
|
raise Conflict('decision not open')
|
|
if option is not None and option not in {o['id'] for o in value['options']}:
|
|
raise Invalid('invalid choice')
|
|
response = dict(id=secrets.token_hex(16),revision=value['revision'],option_id=option,comment=comment,
|
|
created=time.time(),authorization=False,snapshot=self._decision_content(value))
|
|
value.update(response=response,status='pending_review',review=None)
|
|
self._save_decision(db,value)
|
|
self._decision_history(db,value,'submission',response)
|
|
self._decision_notice(db,value,'submitted','control','submitted:'+response['id'])
|
|
self._attention_revoke(db,{'decision_id':did},response['created'])
|
|
self._event(db,'decision.responded',value['title'])
|
|
return self._decision_record(db,value,'respond',data)
|
|
|
|
def decision_review(self, did, data):
|
|
self._decision_fields(data,{'expected_revision','idempotency_key','outcome','reason','impact','execution_plan'}, {'authorization_evidence'})
|
|
if data['outcome'] not in ('accept','reconsider'):
|
|
raise Invalid('invalid outcome')
|
|
for k in ('reason','impact','execution_plan'):
|
|
string(data[k],k,8000)
|
|
if 'authorization_evidence' in data:
|
|
string(data['authorization_evidence'],'authorization_evidence',8000)
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
value = self._load_decision(db,did)
|
|
replay = self._decision_replay(db,did,'review',data)
|
|
if replay is not None:
|
|
return replay
|
|
self._decision_revision(value,data)
|
|
if value['status'] != 'pending_review' or not value['response'] or value['response']['revision'] != value['revision']:
|
|
raise Conflict('current submission required')
|
|
review = {k:v for k,v in data.items() if k not in ('expected_revision','idempotency_key')}
|
|
review.update(revision=value['revision'],created=time.time())
|
|
value.update(review=review,status='accepted' if data['outcome']=='accept' else 'needs_reconsideration')
|
|
self._save_decision(db,value)
|
|
self._decision_history(db,value,'review',review)
|
|
if value['status']=='needs_reconsideration':
|
|
self._attention_auto(db,'decision',(did,),'decisions','需要重新确认:'+value['title'],
|
|
'/decisions?decision='+quote(did,safe=''),time.time(),decision_id=did)
|
|
self._cancel_decision_notices(db,did,'submitted')
|
|
self._decision_notice(db,value,'reviewed','human','reviewed:'+did+':'+data['idempotency_key'])
|
|
return self._decision_record(db,value,'review',data)
|
|
|
|
@staticmethod
|
|
def _decision_evidence(data):
|
|
evidence = data.get('evidence',[])
|
|
if not isinstance(evidence,list) or len(evidence)>50:
|
|
raise Invalid('invalid evidence')
|
|
return [string(v,'evidence',4000) for v in evidence]
|
|
|
|
@staticmethod
|
|
def _decision_pending(db, value):
|
|
return db.execute("SELECT count(*) FROM messages WHERE session_id=? AND status IN ('queued','claimed','uncertain')",(value['session_id'],)).fetchone()[0]
|
|
|
|
def decision_complete(self, did, data):
|
|
self._decision_fields(data,{'expected_revision','idempotency_key','conclusion','handoff'}, {'evidence'})
|
|
for k in ('conclusion','handoff'):
|
|
string(data[k],k,8000)
|
|
evidence = self._decision_evidence(data)
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
value = self._load_decision(db,did)
|
|
replay = self._decision_replay(db,did,'complete',data)
|
|
if replay is not None:
|
|
return replay
|
|
self._decision_revision(value,data)
|
|
if value['status'] not in ('accepted','needs_reconsideration') or not value['review'] or value['review']['revision'] != value['revision']:
|
|
raise Conflict('current review required')
|
|
if not value['session_id'] or not value['session_thread_id']:
|
|
raise Conflict('bound consultation required')
|
|
if self._decision_pending(db,value):
|
|
raise Conflict('consultation messages pending')
|
|
completion = dict(revision=value['revision'],conclusion=data['conclusion'],handoff=data['handoff'],evidence=evidence,created=time.time())
|
|
value.update(completion=completion,status='completed',archive_status='pending')
|
|
self._save_decision(db,value)
|
|
self._decision_history(db,value,'completion',completion)
|
|
self._attention_revoke(db,{'decision_id':did},time.time())
|
|
self._decision_notice(db,value,'archive_required','control','archive:'+did)
|
|
return self._decision_record(db,value,'complete',data)
|
|
|
|
def decision_bind(self, did, data):
|
|
self._decision_fields(data,{'expected_revision','idempotency_key','session_id','thread_id'})
|
|
sid = identifier(data['session_id'],'session_id')
|
|
thread = data['thread_id']
|
|
if not isinstance(thread,str) or not re.fullmatch(r'[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}',thread):
|
|
raise Invalid('invalid thread UUID')
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
value = self._load_decision(db,did)
|
|
replay = self._decision_replay(db,did,'bind',data)
|
|
if replay is not None:
|
|
return replay
|
|
self._decision_revision(value,data)
|
|
if value['session_id']:
|
|
raise Conflict('binding unavailable')
|
|
session = self._get(db,'sessions',sid)
|
|
if session.get('role') != 'decision' or session.get('wp') != did or session.get('thread_id','').lower() != thread.lower():
|
|
raise Conflict('session identity mismatch')
|
|
for row in db.execute('SELECT * FROM sessions WHERE id<>?',(sid,)):
|
|
if json.loads(row['data']).get('thread_id','').lower()==thread.lower():
|
|
raise Conflict('thread reused')
|
|
for row in db.execute('SELECT * FROM decisions WHERE id<>?',(did,)):
|
|
other = self._decision(row)
|
|
if other['session_id']==sid or (other['session_thread_id'] or '').lower()==thread.lower():
|
|
raise Conflict('decision binding reused')
|
|
value.update(session_id=sid,session_thread_id=thread)
|
|
self._save_decision(db,value)
|
|
self._decision_history(db,value,'binding',dict(session_id=sid,thread_id=thread))
|
|
self._cancel_decision_notices(db,did,'session_required')
|
|
return self._decision_record(db,value,'bind',data)
|
|
|
|
def decision_archive(self, did, data):
|
|
self._decision_fields(data,{'thread_id','idempotency_key','receipt'})
|
|
string(data['receipt'],'receipt',8000)
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
value = self._load_decision(db,did)
|
|
replay = self._decision_replay(db,did,'archive',data)
|
|
if replay is not None:
|
|
return replay
|
|
if (value['status']!='completed' or not value['review'] or not value['completion']
|
|
or not value['completion']['handoff'] or not value['session_thread_id']
|
|
or value['session_thread_id'] != data['thread_id'] or value['archive_status']!='pending'):
|
|
raise Conflict('archive prerequisites missing')
|
|
if self._decision_pending(db,value):
|
|
raise Conflict('consultation messages pending')
|
|
value['archive_status']='archived'
|
|
self._save_decision(db,value)
|
|
self._decision_history(db,value,'archive',dict(thread_id=data['thread_id'],receipt=data['receipt']))
|
|
self._cancel_decision_notices(db,did,'archive_required')
|
|
return self._decision_record(db,value,'archive',data)
|
|
|
|
def decision_bindings(self):
|
|
with self.db() as db:
|
|
bindings=[]
|
|
for row in db.execute('SELECT * FROM decisions'):
|
|
value=self._decision(row)
|
|
if value['session_id']:
|
|
session=self._get(db,'sessions',value['session_id'])
|
|
rows=db.execute("SELECT * FROM messages WHERE session_id=? AND source='human' AND origin='coordination' AND status IN ('queued','claimed','uncertain')", (value['session_id'],)).fetchall()
|
|
now=time.time()
|
|
queued=[m for m in rows if m['status']=='queued' or (m['status']=='claimed' and not m['started'] and (m['lease_until'] or 0)<=now)]
|
|
uncertain=[m for m in rows if m['status']=='uncertain' or (m['status']=='claimed' and m['started'] and (m['lease_until'] or 0)<=now)]
|
|
inflight=[m for m in rows if m not in queued and m not in uncertain]
|
|
demand=dict(queued=len(queued),inflight=len(inflight),uncertain=len(uncertain),
|
|
human_queued=len(queued),oldest_queued_at=min((m['created'] for m in queued),default=None))
|
|
bindings.append(dict(decision_id=value['id'],session_id=value['session_id'],thread_id=value['session_thread_id'],
|
|
worktree=session['worktree'],status=value['status'],archive_status=value['archive_status'],
|
|
pending_messages=self._decision_pending(db,value),demand=demand))
|
|
return {'bindings':bindings}
|
|
|
|
def decision_consultation(self, did, data=None):
|
|
if data is None:
|
|
value=self.decision_get(did)
|
|
if not value['session_id']:
|
|
raise Conflict('consultation not bound')
|
|
return {'messages':self.conversation(value['session_id'])}
|
|
self._decision_fields(data,{'text','client_key'})
|
|
body=string(data['text'],'text',32000)
|
|
key=identifier(data['client_key'],'client_key')
|
|
# Status check and insertion share the same write lock as completion.
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
value=self._load_decision(db,did)
|
|
if not value['session_id'] or value['status']=='completed':
|
|
raise Conflict('consultation unavailable')
|
|
sid=value['session_id']
|
|
old=db.execute('SELECT * FROM messages WHERE session_id=? AND idempotency_key=?',(sid,key)).fetchone()
|
|
if old:
|
|
if old['text']!=body or old['source']!='human' or old['reply_to'] is not None:
|
|
raise Conflict('idempotency conflict')
|
|
return self.public_message(old)
|
|
mid=secrets.token_hex(12)
|
|
db.execute('INSERT INTO messages(id,session_id,text,source,idempotency_key,status,created) VALUES(?,?,?,?,?,?,?)',
|
|
(mid,sid,body,'human',key,'queued',time.time()))
|
|
return self.public_message(db.execute('SELECT * FROM messages WHERE id=?',(mid,)).fetchone())
|
|
|
|
def decision_credential(self, did, data):
|
|
self._decision_fields(data,set())
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
value=self._load_decision(db,did)
|
|
if not value['session_id'] or value['status']=='completed':
|
|
raise Conflict('consultation unavailable')
|
|
token=secrets.token_urlsafe(48)
|
|
db.execute('INSERT INTO decision_credentials VALUES(?,?,?)',(hashlib.sha256(token.encode()).hexdigest(),did,time.time()))
|
|
return {'token':token}
|
|
|
|
def decision_credential_scope(self, token):
|
|
with self.db() as db:
|
|
row=db.execute('SELECT decision_id FROM decision_credentials WHERE token_hash=?',(hashlib.sha256(token.encode()).hexdigest(),)).fetchone()
|
|
return row['decision_id'] if row else None
|
|
|
|
def decision_fact(self, did, data):
|
|
self._decision_fields(data,{'idempotency_key','question'},{'context'})
|
|
question=string(data['question'],'question',8000)
|
|
context=string(data.get('context',''),'context',8000,optional=True)
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
value=self._load_decision(db,did)
|
|
replay=self._decision_replay(db,did,'fact',data)
|
|
if replay is not None:
|
|
return replay
|
|
if value['status']=='completed' or not value['session_id']:
|
|
raise Conflict('consultation unavailable')
|
|
fact=dict(id=secrets.token_hex(16),revision=value['revision'],question=question,context=context,answer=None,created=time.time())
|
|
db.execute('INSERT INTO decision_facts VALUES(?,?,?)',(fact['id'],did,json.dumps(fact,ensure_ascii=False)))
|
|
self._decision_history(db,value,'fact_requested',fact)
|
|
self._decision_notice(db,value,'fact_requested','control','fact:'+fact['id'],request_id=fact['id'],question=question,context=context)
|
|
return self._decision_record(db,value,'fact',data,fact)
|
|
|
|
def decision_fact_answer(self, did, rid, data):
|
|
self._decision_fields(data,{'idempotency_key','answer'},{'evidence'})
|
|
answer=string(data['answer'],'answer',8000)
|
|
evidence=self._decision_evidence(data)
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
value=self._load_decision(db,did)
|
|
action='fact_answer:'+identifier(rid,'request_id')
|
|
replay=self._decision_replay(db,did,action,data)
|
|
if replay is not None:
|
|
return replay
|
|
row=db.execute('SELECT data FROM decision_facts WHERE id=? AND decision_id=?',(rid,did)).fetchone()
|
|
if not row:
|
|
raise Invalid('unknown fact request')
|
|
fact=json.loads(row['data'])
|
|
if fact['answer'] is not None or value['status']=='completed':
|
|
raise Conflict('fact request closed')
|
|
fact.update(answer=answer,evidence=evidence,answered_at=time.time())
|
|
db.execute('UPDATE decision_facts SET data=? WHERE id=?',(json.dumps(fact,ensure_ascii=False),rid))
|
|
self._decision_history(db,value,'fact_answered',fact)
|
|
self._cancel_decision_notices(db,did,'fact_requested',rid)
|
|
self._decision_notice(db,value,'fact_answered','decision','answer:'+rid,request_id=rid,answer=answer,evidence=evidence)
|
|
return self._decision_record(db,value,action,data,fact)
|
|
|
|
def decision_notifications(self):
|
|
with self.db() as db:
|
|
rows=db.execute('SELECT data FROM decision_workflow_notifications WHERE delivered_at IS NULL AND cancelled_at IS NULL ORDER BY seq LIMIT 100').fetchall()
|
|
count=db.execute('SELECT count(*) FROM decision_workflow_notifications WHERE delivered_at IS NULL AND cancelled_at IS NULL').fetchone()[0]
|
|
return {'notifications':[json.loads(row['data']) for row in rows],'pending_count':count}
|
|
|
|
def decision_submitting(self, data):
|
|
self._decision_fields(data,{'notification_id'})
|
|
nid=identifier(data['notification_id'],'notification_id')
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
row=db.execute('SELECT * FROM decision_workflow_notifications WHERE id=?',(nid,)).fetchone()
|
|
if not row:
|
|
raise Invalid('unknown notification')
|
|
if row['cancelled_at'] is not None:
|
|
raise Conflict('notification cancelled')
|
|
db.execute('UPDATE decision_workflow_notifications SET submission_started_at=COALESCE(submission_started_at,?) WHERE id=?',(time.time(),nid))
|
|
return {'notification_id':nid,'status':'submitting'}
|
|
|
|
def decision_ack(self, data):
|
|
self._decision_fields(data,{'notification_id'})
|
|
nid=identifier(data['notification_id'],'notification_id')
|
|
with self.db() as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
row=db.execute('SELECT delivered_at FROM decision_workflow_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_workflow_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
|