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
+18 -9
View File
@@ -18,7 +18,7 @@ def definition(**changes):
def answer(**changes):
value = dict(option_id='a',comment='Keep this exact comment',idempotency_key='key')
value = dict(expected_revision=1,option_id='a',comment='Keep this exact comment',idempotency_key='key')
value.update(changes)
return value
@@ -294,16 +294,17 @@ class DecisionTests(unittest.TestCase):
self.assertEqual(first,self.store.decision(explicit))
with self.assertRaises(Conflict):
self.store.decision(definition(question='Changed'))
self.assertEqual(self.store.decision_notifications()['pending_count'],0)
self.assertEqual(self.store.decision_notifications()['pending_count'],1)
def test_persistence_response_dedup_and_ack(self):
self.store.decision(definition())
self.store.decision_ack({'notification_id':self.store.decision_notifications()['notifications'][0]['notification_id']})
with patch('subprocess.run',side_effect=AssertionError('must not execute')):
first = self.store.decision_respond('choice',answer())
reopened = Store(self.path)
self.assertEqual(first,reopened.decision_respond('choice',answer()))
self.assertEqual(first,reopened.decision(definition()))
self.assertEqual(first['status'],'decided')
self.assertEqual(first['status'],'pending_review')
self.assertFalse(first['response']['authorization'])
self.assertEqual(first['response']['comment'],answer()['comment'])
for changes in [dict(comment='changed'),dict(idempotency_key='other'),dict(option_id=None)]:
@@ -312,7 +313,7 @@ class DecisionTests(unittest.TestCase):
queue = reopened.decision_notifications()
self.assertEqual(queue['pending_count'],1)
notice = queue['notifications'][0]
self.assertEqual(set(notice),{'notification_id','target','decision'})
self.assertEqual(set(notice),{'notification_id','target','decision','event'})
self.assertEqual(notice['target'],'control')
self.assertEqual(notice['decision'],first)
ack = dict(notification_id=notice['notification_id'])
@@ -325,7 +326,7 @@ class DecisionTests(unittest.TestCase):
def test_authorization_is_only_pending_verification(self):
self.store.decision(definition(requires_authorization=True,options=[]))
value = self.store.decision_respond('choice',answer(option_id=None,comment='Approve sudo'))
self.assertEqual(value['status'],'pending_verification')
self.assertEqual(value['status'],'pending_review')
self.assertIs(value['response']['authorization'],False)
def test_invalid_definitions(self):
@@ -342,7 +343,7 @@ class DecisionTests(unittest.TestCase):
def test_invalid_answers_and_ack(self):
self.store.decision(definition())
for changes in [dict(option_id=None),dict(option_id='bad'),dict(comment=None),
for changes in [dict(option_id=None,comment=''),dict(option_id='bad'),dict(comment=None),
dict(comment='x'*8001),dict(idempotency_key=''),dict(authorization=True)]:
with self.subTest(changes=changes):
with self.assertRaises(Invalid): self.store.decision_respond('choice',answer(**changes))
@@ -350,7 +351,7 @@ class DecisionTests(unittest.TestCase):
with self.assertRaises(Invalid): self.store.decision_ack(dict(notification_id='missing'))
with self.assertRaises(Invalid): self.store.decision_ack(dict(notification_id='missing',target='human'))
self.assertEqual(self.store.state()['decisions'][0]['status'],'open')
self.assertEqual(self.store.decision_notifications()['pending_count'],0)
self.assertEqual(self.store.decision_notifications()['pending_count'],1)
self.store.decision(definition(id='free',options=[]))
for option,comment in [(None,' '),('a','text')]:
with self.assertRaises(Invalid):
@@ -358,6 +359,7 @@ class DecisionTests(unittest.TestCase):
def test_concurrent_same_key_has_one_response_and_notification(self):
self.store.decision(definition())
self.store.decision_ack({'notification_id':self.store.decision_notifications()['notifications'][0]['notification_id']})
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
values = list(pool.map(lambda _:self.store.decision_respond('choice',answer()),range(16)))
self.assertTrue(all(v == values[0] for v in values))
@@ -365,6 +367,7 @@ class DecisionTests(unittest.TestCase):
def test_concurrent_distinct_answers_have_one_winner(self):
self.store.decision(definition())
self.store.decision_ack({'notification_id':self.store.decision_notifications()['notifications'][0]['notification_id']})
def respond(index):
try:
return self.store.decision_respond('choice',answer(idempotency_key=str(index)))
@@ -377,8 +380,9 @@ class DecisionTests(unittest.TestCase):
def test_atomic_rollback_on_outbox_failure(self):
self.store.decision(definition())
self.store.decision_ack({'notification_id':self.store.decision_notifications()['notifications'][0]['notification_id']})
with self.store.db() as db:
db.execute("CREATE TRIGGER fail_notice BEFORE INSERT ON decision_notifications BEGIN SELECT RAISE(ABORT,'synthetic'); END")
db.execute("CREATE TRIGGER fail_notice BEFORE INSERT ON decision_workflow_notifications BEGIN SELECT RAISE(ABORT,'synthetic'); END")
with self.assertRaises(Exception): self.store.decision_respond('choice',answer())
self.assertEqual(self.store.state()['decisions'][0]['status'],'open')
self.assertEqual(self.store.decision_notifications()['pending_count'],0)
@@ -387,6 +391,9 @@ class DecisionTests(unittest.TestCase):
for index in range(103):
did = 'choice-'+str(index)
self.store.decision(definition(id=did))
with self.store.db() as db:
nid=db.execute('SELECT id FROM decision_workflow_notifications ORDER BY seq DESC LIMIT 1').fetchone()[0]
self.store.decision_ack({'notification_id':nid})
self.store.decision_respond(did,answer())
queue = self.store.decision_notifications()
self.assertEqual(queue['pending_count'],103)
@@ -441,6 +448,8 @@ class DecisionHTTPTests(unittest.TestCase):
self.assertEqual(self.request(notifications,headers={'Authorization':''})[0],401)
self.assertEqual(self.request('/api/state')[1]['decisions'],[])
self.assertEqual(self.request(create,'POST',definition())[0],200)
created_notice=self.request(notifications)[1]['notifications'][0]
self.request(ack,'POST',dict(notification_id=created_notice['notification_id']))
self.assertEqual(self.request(create,'POST',definition(question='different'))[0],409)
self.assertEqual(self.request(respond,'POST',answer(option_id='bad'))[0],400)
code,value=self.request(respond,'POST',answer())
@@ -452,7 +461,7 @@ class DecisionHTTPTests(unittest.TestCase):
self.assertEqual(self.request(notifications)[1]['pending_count'],0)
self.assertEqual(self.request(notifications+'?target=human')[0],404)
self.assertEqual(self.request(respond+'?x=1','POST',answer())[0],404)
self.assertEqual(self.request('/api/decisions/choice','PATCH',definition())[0],404)
self.assertEqual(self.request('/api/decisions/choice','PATCH',definition())[0],400)
self.assertEqual(self.request('/api/state')[1]['decisions'][0],value)
def test_cross_site_only_public_top_level_navigation(self):