Add revisioned decisions and scoped consultation lifecycle
This commit is contained in:
+40
-4
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user