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: