Add revisioned decisions and scoped consultation lifecycle

This commit is contained in:
aiagent
2026-10-19 22:28:53 -07:00
parent 0fcfe1c024
commit 6e4178dc79
27 changed files with 1431 additions and 192 deletions
+40 -4
View File
@@ -198,10 +198,23 @@ class Handler(BaseHTTPRequestHandler):
return True
def auth(self):
self.decision_scope = None
supplied = self.headers.get('Authorization', '')
if not hmac.compare_digest(supplied.encode('utf-8'), ('Bearer ' + self.server.token).encode('utf-8')):
if hmac.compare_digest(supplied.encode('utf-8'), ('Bearer ' + self.server.token).encode('utf-8')):
return True
scope = self.server.store.decision_credential_scope(supplied[7:]) if supplied.startswith('Bearer ') else None
if scope is None:
self.output(401, {'error':'authentication required'})
return False
path = urlsplit(self.path).path
base = '/api/decisions/' + scope
allowed = ((self.command=='GET' and path in (base,base+'/history',base+'/consultation'))
or (self.command=='PATCH' and path==base)
or (self.command=='POST' and path==base+'/facts'))
if not allowed or (self.command!='GET' and self.server.store.decision_get(scope)['status']=='completed'):
self.output(403, {'error':'credential scope rejected'})
return False
self.decision_scope = scope
return True
def do_GET(self):
@@ -219,6 +232,12 @@ class Handler(BaseHTTPRequestHandler):
state['metrics'] = self.server.metrics.snapshot()
state['eta_clock'] = dict(self.server.eta_clock.status)
self.output(200, state)
elif parts.path == '/api/decision-bridge-bindings' and not parts.query:
self.output(200,self.server.store.decision_bindings())
elif re.fullmatch(r'/api/decisions/[A-Za-z0-9_.:-]+(?:/(?:history|consultation))?',parts.path) and not parts.query:
segments=parts.path.split('/')
method = {'history':self.server.store.decision_history,'consultation':self.server.store.decision_consultation}.get(segments[-1]) if len(segments)==5 else self.server.store.decision_get
self.output(200,method(segments[3]))
elif parts.path == '/api/queue-health' and not parts.query:
self.output(200,self.server.store.queue_health())
elif parts.path == '/api/task-reports':
@@ -246,6 +265,8 @@ class Handler(BaseHTTPRequestHandler):
self.output(200, {'messages':reader(query['session_id'][0])})
else:
self.output(404, {'error':'not found'})
except Conflict:
self.output(409, {'error':'state conflict'})
except (Invalid,ValueError):
self.output(400, {'error':'invalid request'})
except Exception:
@@ -295,8 +316,13 @@ class Handler(BaseHTTPRequestHandler):
data = json.loads(self.rfile.read(length))
if not isinstance(data, dict):
raise Invalid('object required')
if getattr(self,'decision_scope',None) and self.command=='PATCH':
allowed={'expected_revision','reason','title','summary','question','details','impact','recommendation','options','deadline','priority'}
if data.keys()-allowed:
self.output(403, {'error':'credential content scope rejected'})
return
store = self.server.store
routes = {'/api/task-reports':store.task_report, '/api/attention':store.attention, '/api/eta-notifications/ack':store.eta_ack, '/api/decisions':store.decision, '/api/decision-notifications/ack':store.decision_ack,
routes = {'/api/decision-notifications/submitting':store.decision_submitting, '/api/task-reports':store.task_report, '/api/attention':store.attention, '/api/eta-notifications/ack':store.eta_ack, '/api/decisions':store.decision, '/api/decision-notifications/ack':store.decision_ack,
'/api/hook-notifications/submitting':store.hook_submitting,
'/api/hook-notifications/coalesce':store.hook_coalesce, '/api/hooks':store.hook, '/api/hooks/escalate':store.hook_escalate, '/api/hooks/ack':store.hook_ack,
'/api/sessions':store.session, '/api/session-requests':store.request,
@@ -310,8 +336,18 @@ class Handler(BaseHTTPRequestHandler):
result = store.integrate(self.path.split('/')[3],data)
elif self.command == 'POST' and re.fullmatch(r'/api/eta-reviews/[A-Za-z0-9_.:-]+/complete',self.path):
result = store.eta_complete(self.path.split('/')[3],data)
elif self.command == 'POST' and re.fullmatch(r'/api/decisions/[A-Za-z0-9_.:-]+/respond',self.path):
result = store.decision_respond(self.path.split('/')[3],data)
elif self.command == 'PATCH' and re.fullmatch(r'/api/decisions/[A-Za-z0-9_.:-]+',self.path):
result = store.decision_patch(self.path.split('/')[3],data)
elif self.command == 'POST' and re.fullmatch(r'/api/decisions/[A-Za-z0-9_.:-]+/facts/[A-Za-z0-9_.:-]+/answer',self.path):
segments=self.path.split('/')
result = store.decision_fact_answer(segments[3],segments[5],data)
elif self.command == 'POST' and re.fullmatch(r'/api/decisions/[A-Za-z0-9_.:-]+/(respond|review|complete|archive-receipt|bind-session|consultation|consultation-credential|facts)',self.path):
actions = {'respond':store.decision_respond,'review':store.decision_review,'complete':store.decision_complete,
'archive-receipt':store.decision_archive,'bind-session':store.decision_bind,
'consultation':store.decision_consultation,'consultation-credential':store.decision_credential,'facts':store.decision_fact}
if self.path.endswith('/bind-session') and data.get('session_id')==self.server.primary_session_id:
raise Conflict('primary session cannot be bound')
result = actions[self.path.split('/')[-1]](self.path.split('/')[3],data)
elif self.command == 'POST' and self.path in routes:
result = routes[self.path](data)
else:
+453 -64
View File
@@ -128,6 +128,37 @@ class Store:
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']
@@ -184,9 +215,19 @@ class Store:
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
@@ -301,8 +342,8 @@ class Store:
if not item['required']:
continue
if item.get('decision_id'):
decision = db.execute('SELECT response FROM decisions WHERE id=?',(item['decision_id'],)).fetchone()
if not decision or decision['response'] is not None:
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()
@@ -865,6 +906,10 @@ class Store:
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):
@@ -1471,9 +1516,10 @@ class Store:
eta = stats('eta_reviews','created',eta_pending)
eta['cancelled_count'] = db.execute(
'SELECT count(*) FROM eta_reviews WHERE cancelled_at IS NOT NULL').fetchone()[0]
decisions = stats('decision_notifications',"json_extract(data,'$.response.created')",
'delivered_at IS NULL')
decisions['cancelled_count'] = 0
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()
@@ -1504,22 +1550,103 @@ class Store:
return {'notification_id':notification_id,'status':'delivered','duplicate':already}
@staticmethod
def _decision(row):
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
result['response'] = response
result['status'] = ('pending_verification' if result['requires_authorization'] else 'decided') if response else 'open'
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 decision(self, data):
fields = {'id','title','question','impact','priority','details','recommendation',
'options','deadline','requires_authorization','task_id'}
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)]:
('details',8000),('recommendation',4000),('summary',1000)]:
raw = data.get(name, '')
if not isinstance(raw, str):
raise Invalid('invalid ' + name)
@@ -1558,83 +1685,345 @@ class Store:
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 json.loads(row['data']) != value:
if self._normalize_decision(json.loads(row['data'])) != value:
raise Conflict('decision definition differs')
return self._decision(row)
if task is not None:
self._get(db,'tasks',task)
db.execute('INSERT INTO decisions(id,data) VALUES(?,?)',
(value['id'],json.dumps(value,ensure_ascii=False)))
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 dict(value,status='open',response=None)
'/decisions?decision='+quote(value['id'],safe=''),time.time(),decision_id=value['id'])
return value
def decision_respond(self, decision_id, data):
decision_id = identifier(decision_id,'decision_id')
if not isinstance(data,dict) or set(data) != {'option_id','comment','idempotency_key'}:
raise Invalid('invalid response fields')
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')
if not isinstance(data['comment'],str):
raise Invalid('invalid comment')
comment = string(data['comment'],'comment',8000,optional=True)
key = identifier(data['idempotency_key'],'idempotency_key')
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')
row = db.execute('SELECT * FROM decisions WHERE id=?',(decision_id,)).fetchone()
if not row:
raise Invalid('unknown decision')
value = self._decision(row)
if row['response']:
old = value['response']
if row['response_key'] == key and old['option_id'] == option and old['comment'] == comment:
return value
raise Conflict('decision already answered')
valid_options = {o['id'] for o in value['options']}
if (valid_options and option not in valid_options) or (not valid_options and option is not None):
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')
if option is None and not comment.strip():
raise Invalid('response required')
response = {'id':secrets.token_hex(16),'option_id':option,'comment':comment,
'created':time.time(),'authorization':False}
value['response'] = response
value['status'] = 'pending_verification' if value['requires_authorization'] else 'decided'
db.execute('UPDATE decisions SET response=?,response_key=? WHERE id=?',
(json.dumps(response,ensure_ascii=False),key,decision_id))
db.execute('INSERT INTO decision_notifications(id,decision_id,data) VALUES(?,?,?)',
(secrets.token_hex(16),decision_id,json.dumps(value,ensure_ascii=False)))
self._attention_revoke(db,{'decision_id':decision_id},response['created'])
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 value
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'])
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)))
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:
db.execute('BEGIN')
count = db.execute('SELECT count(*) FROM decision_notifications WHERE delivered_at IS NULL').fetchone()[0]
rows = db.execute('SELECT * FROM decision_notifications WHERE delivered_at IS NULL ORDER BY seq LIMIT 100').fetchall()
return {'notifications':[{'notification_id':row['id'],'target':'control',
'decision':json.loads(row['data'])} for row in rows],
'pending_count':count}
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_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')
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 delivered_at FROM decision_notifications WHERE id=?',(nid,)).fetchone()
row=db.execute('SELECT * FROM decision_workflow_notifications WHERE id=?',(nid,)).fetchone()
if not row:
raise Invalid('unknown notification')
duplicate = row['delivered_at'] is not None
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_notifications SET delivered_at=? WHERE id=?',(time.time(),nid))
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):