Add revisioned decisions and scoped consultation lifecycle
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
"""Decision lifecycle contract tests use isolated stores and HTTP servers only."""
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from agent_control.store import Store, Invalid, Conflict
|
||||
import test_decisions as fixtures
|
||||
from test_decisions import definition, answer
|
||||
|
||||
THREAD='00000000-0000-4000-8000-000000000001'
|
||||
|
||||
def review(**changes):
|
||||
data=dict(expected_revision=1,idempotency_key='review',outcome='accept',reason='Checked facts',impact='Limited scope',execution_plan='Owner verifies execution separately')
|
||||
data.update(changes)
|
||||
return data
|
||||
|
||||
def completion(**changes):
|
||||
data=dict(expected_revision=1,idempotency_key='complete',conclusion='Reviewed outcome',handoff='Owner retains implementation task',evidence=['Synthetic verification'])
|
||||
data.update(changes)
|
||||
return data
|
||||
|
||||
def bind(store,did='choice',sid='consult',thread=THREAD):
|
||||
store.session(dict(id=sid,name='Consultation',role='decision',wp=did,thread_id=thread,worktree='/synthetic'))
|
||||
return store.decision_bind(did,dict(expected_revision=1,idempotency_key='bind',session_id=sid,thread_id=thread))
|
||||
|
||||
class WorkflowTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory()
|
||||
self.path=Path(self.tmp.name)/'state.sqlite3'
|
||||
self.store=Store(self.path)
|
||||
self.store.decision(definition())
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_accept_complete_archive_requires_review_and_real_receipt(self):
|
||||
bind(self.store)
|
||||
with self.assertRaises(Conflict): self.store.decision_complete('choice',completion())
|
||||
with self.assertRaises(Conflict): self.store.decision_review('choice',review())
|
||||
self.store.decision_respond('choice',answer())
|
||||
accepted=self.store.decision_review('choice',review())
|
||||
self.assertEqual(accepted['status'],'accepted')
|
||||
self.assertFalse(accepted['response']['authorization'])
|
||||
self.assertEqual(accepted,self.store.decision_review('choice',review()))
|
||||
completed=self.store.decision_complete('choice',completion())
|
||||
self.assertEqual((completed['status'],completed['archive_status']),('completed','pending'))
|
||||
receipt=dict(thread_id=THREAD,idempotency_key='archive',receipt='Host tool receipt synthetic')
|
||||
with self.assertRaises(Conflict): self.store.decision_archive('choice',dict(receipt,thread_id='other'))
|
||||
archived=self.store.decision_archive('choice',receipt)
|
||||
self.assertEqual(archived['archive_status'],'archived')
|
||||
self.assertEqual(archived,Store(self.path).decision_archive('choice',receipt))
|
||||
with self.assertRaises(Conflict): self.store.decision_archive('choice',dict(receipt,receipt='changed'))
|
||||
kinds=[x['kind'] for x in self.store.decision_history('choice')['history']]
|
||||
self.assertEqual(kinds,['version','binding','submission','review','completion','archive'])
|
||||
|
||||
def test_reconsider_and_free_text_with_options(self):
|
||||
bind(self.store)
|
||||
submitted=self.store.decision_respond('choice',answer(option_id=None,comment='Alternative approach'))
|
||||
self.assertEqual(submitted['status'],'pending_review')
|
||||
reconsidered=self.store.decision_review('choice',review(outcome='reconsider'))
|
||||
self.assertEqual(reconsidered['status'],'needs_reconsideration')
|
||||
self.assertEqual(self.store.decision_complete('choice',completion())['status'],'completed')
|
||||
with self.assertRaises(Conflict): self.store.decision_respond('choice',answer(idempotency_key='again'))
|
||||
|
||||
def test_complete_without_bound_session_cannot_request_archive(self):
|
||||
self.store.decision_respond('choice',answer())
|
||||
self.store.decision_review('choice',review())
|
||||
with self.assertRaises(Conflict): self.store.decision_complete('choice',completion())
|
||||
self.assertEqual(self.store.decision_get('choice')['status'],'accepted')
|
||||
self.assertFalse(any(n['event']['kind']=='archive_required' for n in self.store.decision_notifications()['notifications']))
|
||||
|
||||
def test_versions_preserve_submission_and_original_create_definition(self):
|
||||
submitted=self.store.decision_respond('choice',answer())
|
||||
value=self.store.decision_patch('choice',dict(expected_revision=1,reason='New facts',options=[dict(id='b',label='B',description='Different')]))
|
||||
self.assertEqual((value['revision'],value['status']),(2,'needs_reconsideration'))
|
||||
self.assertEqual(value['response'],submitted['response'])
|
||||
self.assertEqual(value['response']['snapshot']['options'][0]['id'],'a')
|
||||
self.assertEqual(self.store.decision(definition()),value)
|
||||
with self.assertRaises(Conflict): self.store.decision_review('choice',review(expected_revision=2))
|
||||
with self.assertRaises(Conflict): self.store.decision_respond('choice',answer(idempotency_key='new'))
|
||||
with self.assertRaises(Invalid): self.store.decision_respond('choice',answer(expected_revision=2,idempotency_key='new'))
|
||||
fresh=self.store.decision_respond('choice',answer(expected_revision=2,option_id='b',idempotency_key='new'))
|
||||
self.assertEqual(fresh['response']['revision'],2)
|
||||
history=self.store.decision_history('choice')['history']
|
||||
self.assertEqual(len([e for e in history if e['kind']=='submission']),2)
|
||||
self.assertEqual(history[1]['data']['snapshot']['options'][0]['id'],'a')
|
||||
|
||||
def test_reconsideration_attention_tracks_status_without_losing_response(self):
|
||||
self.assertTrue(self.store.state()['attention']['pages']['decisions'])
|
||||
first=self.store.decision_respond('choice',answer())
|
||||
self.assertFalse(self.store.state()['attention']['pages']['decisions'])
|
||||
value=self.store.decision_review('choice',review(outcome='reconsider'))
|
||||
self.assertEqual(value['response'],first['response'])
|
||||
self.assertTrue(self.store.state()['attention']['pages']['decisions'])
|
||||
self.store.decision_respond('choice',answer(idempotency_key='again'))
|
||||
self.assertFalse(self.store.state()['attention']['pages']['decisions'])
|
||||
value=self.store.decision_patch('choice',dict(expected_revision=1,reason='Material new facts',question='New question?'))
|
||||
self.assertIsNotNone(value['response'])
|
||||
self.assertTrue(self.store.state()['attention']['pages']['decisions'])
|
||||
self.store.decision_respond('choice',answer(expected_revision=2,idempotency_key='new-revision'))
|
||||
self.assertFalse(self.store.state()['attention']['pages']['decisions'])
|
||||
|
||||
def test_concurrent_patch_has_single_winner(self):
|
||||
def patch(index):
|
||||
try: return self.store.decision_patch('choice',dict(expected_revision=1,reason='Evidence',question='Q'+str(index)))
|
||||
except Conflict: return None
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
|
||||
results=list(pool.map(patch,range(12)))
|
||||
self.assertEqual(sum(r is not None for r in results),1)
|
||||
self.assertEqual(self.store.decision_get('choice')['revision'],2)
|
||||
for bad in [True,0,'2',None]:
|
||||
with self.assertRaises(Invalid): self.store.decision_patch('choice',dict(expected_revision=bad,reason='why',title='new'))
|
||||
with self.assertRaises(Invalid): self.store.decision_patch('choice',dict(expected_revision=2,reason='why',response={}))
|
||||
|
||||
def test_related_decisions_are_explicit_and_tasks_validated(self):
|
||||
with self.assertRaises(Invalid): self.store.decision(definition(id='bad',supersedes_id='bad'))
|
||||
with self.assertRaises(Invalid): self.store.decision(definition(id='bad',supersedes_id='missing'))
|
||||
child=self.store.decision(definition(id='next',supersedes_id='choice',summary='Follow up'))
|
||||
self.assertEqual(child['related_decision_ids'],['choice'])
|
||||
self.assertEqual(self.store.decision_get('choice')['related_decision_ids'],['next'])
|
||||
with self.assertRaises(Invalid): self.store.decision_patch('choice',dict(expected_revision=1,reason='why',task_id='missing'))
|
||||
self.assertEqual(len(self.store.state()['decisions']),2)
|
||||
|
||||
def test_bind_isolation_and_identity_is_immutable(self):
|
||||
with self.assertRaises(Conflict): self.store.decision_consultation('choice')
|
||||
self.store.session(dict(id='worker',name='Executor',thread_id=THREAD))
|
||||
self.store.session(dict(id='consult',name='Consult',role='decision',wp='choice',thread_id=THREAD))
|
||||
data=dict(expected_revision=1,idempotency_key='bind',session_id='consult',thread_id=THREAD)
|
||||
with self.assertRaises(Conflict): self.store.decision_bind('choice',data)
|
||||
self.store.session(dict(id='worker',name='Executor',thread_id='00000000-0000-4000-8000-000000000002'))
|
||||
bound=self.store.decision_bind('choice',data)
|
||||
self.assertEqual(self.store.decision_bind('choice',data),bound)
|
||||
with self.assertRaises(Conflict): self.store.decision_bind('choice',dict(data,idempotency_key='other'))
|
||||
with self.assertRaises(Conflict): self.store.session(dict(id='consult',name='Mutated',role='worker',wp='choice',thread_id=THREAD))
|
||||
with self.assertRaises(Conflict): self.store.session(dict(id='other',name='Reuse',thread_id=THREAD))
|
||||
self.assertEqual(self.store.decision_bindings()['bindings'][0]['thread_id'],THREAD)
|
||||
|
||||
def test_completion_blocks_pending_inputs_and_consultation_is_readonly_after(self):
|
||||
bind(self.store)
|
||||
msg=self.store.decision_consultation('choice',dict(text='Question',client_key='message'))
|
||||
self.assertEqual(msg,self.store.decision_consultation('choice',dict(text='Question',client_key='message')))
|
||||
with self.assertRaises(Invalid): self.store.decision_consultation('choice',dict(text='x',client_key='x',session_id='other'))
|
||||
self.store.decision_respond('choice',answer())
|
||||
self.store.decision_review('choice',review())
|
||||
for status in ['queued','claimed','uncertain']:
|
||||
with self.store.db() as db: db.execute('UPDATE messages SET status=?',(status,))
|
||||
with self.assertRaises(Conflict): self.store.decision_complete('choice',completion())
|
||||
with self.store.db() as db: db.execute("UPDATE messages SET status='completed'")
|
||||
self.store.decision_complete('choice',completion())
|
||||
with self.assertRaises(Conflict): self.store.decision_consultation('choice',dict(text='Late',client_key='late'))
|
||||
with self.assertRaises(Conflict): self.store.send(dict(session_id='consult',text='Late',idempotency_key='late'))
|
||||
self.assertTrue(self.store.decision_consultation('choice')['messages'])
|
||||
with self.store.db() as db: db.execute("UPDATE messages SET status='uncertain'")
|
||||
with self.assertRaises(Conflict): self.store.decision_archive('choice',dict(thread_id=THREAD,idempotency_key='archive',receipt='receipt'))
|
||||
|
||||
def test_consultation_send_and_completion_are_atomic(self):
|
||||
bind(self.store)
|
||||
self.store.decision_respond('choice',answer())
|
||||
self.store.decision_review('choice',review())
|
||||
def run(action):
|
||||
try:
|
||||
if action=='send':
|
||||
return action,self.store.decision_consultation('choice',dict(text='Concurrent input',client_key='race'))
|
||||
return action,self.store.decision_complete('choice',completion())
|
||||
except Conflict:
|
||||
return action,None
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
outcomes=dict(pool.map(run,['send','complete']))
|
||||
self.assertEqual(sum(v is not None for v in outcomes.values()),1)
|
||||
if outcomes['send'] is not None:
|
||||
self.assertEqual(self.store.decision_get('choice')['status'],'accepted')
|
||||
else:
|
||||
self.assertEqual(self.store.decision_get('choice')['status'],'completed')
|
||||
self.assertEqual(self.store.messages('consult'),[])
|
||||
|
||||
def test_notification_snapshots_and_cancel_only_unsent(self):
|
||||
notice=self.store.decision_notifications()['notifications'][0]
|
||||
self.store.decision_submitting(dict(notification_id=notice['notification_id']))
|
||||
bind(self.store)
|
||||
self.assertEqual(self.store.decision_notifications()['notifications'][0],notice)
|
||||
self.store.decision_respond('choice',answer())
|
||||
submitted=next(n for n in self.store.decision_notifications()['notifications'] if n['event']['kind']=='submitted')
|
||||
self.store.decision_submitting(dict(notification_id=submitted['notification_id']))
|
||||
self.store.decision_patch('choice',dict(expected_revision=1,reason='Change',question='New?'))
|
||||
self.assertIn(submitted,self.store.decision_notifications()['notifications'])
|
||||
self.assertEqual(submitted['event']['revision'],1)
|
||||
self.assertEqual(submitted['decision']['question'],'Which approach?')
|
||||
|
||||
def test_fact_requests_dedup_answer_isolation_and_notification(self):
|
||||
bind(self.store)
|
||||
request=dict(idempotency_key='facts',question='Which version?',context='Need evidence')
|
||||
fact=self.store.decision_fact('choice',request)
|
||||
self.assertEqual(fact,Store(self.path).decision_fact('choice',request))
|
||||
self.store.decision(definition(id='other'))
|
||||
with self.assertRaises(Invalid): self.store.decision_fact_answer('other',fact['id'],dict(idempotency_key='answer',answer='x'))
|
||||
body=dict(idempotency_key='answer',answer='Verified version',evidence=['fixture'])
|
||||
result=self.store.decision_fact_answer('choice',fact['id'],body)
|
||||
self.assertEqual(result,self.store.decision_fact_answer('choice',fact['id'],body))
|
||||
notices=self.store.decision_notifications()['notifications']
|
||||
self.assertFalse(any(n['event']['kind']=='fact_requested' for n in notices))
|
||||
notice=next(n for n in notices if n['event']['kind']=='fact_answered')
|
||||
self.assertEqual((notice['target'],notice['decision']['session_thread_id']),('decision',THREAD))
|
||||
self.assertEqual(self.store.decision_get('choice')['status'],'open')
|
||||
self.assertFalse(any(e['kind']=='review' for e in self.store.decision_history('choice')['history']))
|
||||
|
||||
def test_queue_health_reads_workflow_outbox_and_submission_markers(self):
|
||||
initial=self.store.queue_health()['decisions']
|
||||
self.assertEqual(initial['pending_count'],1)
|
||||
self.assertIsNotNone(initial['oldest_pending_at'])
|
||||
nid=self.store.decision_notifications()['notifications'][0]['notification_id']
|
||||
self.store.decision_submitting(dict(notification_id=nid))
|
||||
self.assertEqual(self.store.queue_health()['decisions']['submission_started_unconfirmed_count'],1)
|
||||
self.store.decision_ack(dict(notification_id=nid))
|
||||
self.assertEqual(self.store.queue_health()['decisions']['pending_count'],0)
|
||||
self.store.decision_respond('choice',answer())
|
||||
self.store.decision_review('choice',review())
|
||||
health=self.store.queue_health()['decisions']
|
||||
self.assertEqual(health['pending_count'],1)
|
||||
self.assertEqual(health['cancelled_count'],1)
|
||||
|
||||
def test_legacy_response_migration_never_accepts_or_loses_snapshot(self):
|
||||
path=Path(self.tmp.name)/'old.sqlite3'
|
||||
db=sqlite3.connect(path)
|
||||
db.execute('CREATE TABLE decisions(id TEXT PRIMARY KEY,data TEXT NOT NULL,response TEXT,response_key TEXT)')
|
||||
original=self.store._normalize_decision(definition())
|
||||
original.pop('summary');original.pop('supersedes_id')
|
||||
response=dict(id='legacy-response',option_id='a',comment='Original',created=1,authorization=False)
|
||||
db.execute('INSERT INTO decisions VALUES(?,?,?,?)',('choice',json.dumps(original),json.dumps(response),'old-key'))
|
||||
db.commit();db.close()
|
||||
migrated=Store(path)
|
||||
current=migrated.decision_get('choice')
|
||||
self.assertEqual((current['status'],current['revision']),('pending_review',1))
|
||||
self.assertEqual(current['response']['snapshot']['options'],original['options'])
|
||||
self.assertIsNone(current['review'])
|
||||
self.assertEqual(len(Store(path).decision_history('choice')['history']),2)
|
||||
|
||||
class WorkflowHTTPTests(unittest.TestCase):
|
||||
setUp=fixtures.DecisionHTTPTests.setUp
|
||||
tearDown=fixtures.DecisionHTTPTests.tearDown
|
||||
request=fixtures.DecisionHTTPTests.request
|
||||
def test_limited_credential_route_matrix_and_closed_writes(self):
|
||||
store=self.server.store
|
||||
store.decision(definition());bind(store)
|
||||
code,issued=self.request('/api/decisions/choice/consultation-credential','POST',{})
|
||||
self.assertEqual(code,200)
|
||||
token=issued['token'];headers={'Authorization':'Bearer '+token}
|
||||
with store.db() as db:
|
||||
rows=[dict(r) for r in db.execute('SELECT * FROM decision_credentials')]
|
||||
self.assertEqual(rows[0]['token_hash'],hashlib.sha256(token.encode()).hexdigest())
|
||||
self.assertNotIn(token,json.dumps(rows))
|
||||
for path in ['/api/decisions/choice','/api/decisions/choice/history','/api/decisions/choice/consultation']:
|
||||
self.assertEqual(self.request(path,headers=headers)[0],200)
|
||||
for method,path,payload in [('GET','/api/state',None),('GET','/api/decisions/other',None),('GET','/api/decision-bridge-bindings',None),
|
||||
('POST','/api/decisions/choice/respond',answer()),('POST','/api/decisions/choice/review',review()),
|
||||
('POST','/api/decisions/choice/complete',completion()),('POST','/api/sessions',{}),
|
||||
('POST','/api/decisions/choice/consultation',dict(text='x',client_key='x')),
|
||||
('POST','/api/decisions/choice/consultation-credential',{}),
|
||||
('POST','/api/decisions/choice/bind-session',{}),('POST','/api/decisions/choice/archive-receipt',{})]:
|
||||
with self.subTest(path=path): self.assertEqual(self.request(path,method,payload,headers)[0],403)
|
||||
for field,value in [('task_id','other-task'),('supersedes_id','other'),('requires_authorization',False)]:
|
||||
self.assertEqual(self.request('/api/decisions/choice','PATCH',dict(expected_revision=1,reason='Out of scope',**{field:value}),headers)[0],403)
|
||||
self.assertEqual(self.request('/api/decisions/choice','PATCH',dict(expected_revision=1,reason='Evidence',summary='Updated'),headers)[0],200)
|
||||
self.assertEqual(self.request('/api/decisions/choice/facts','POST',dict(idempotency_key='fact',question='Fact?'),headers)[0],200)
|
||||
store.decision_respond('choice',answer(expected_revision=2));store.decision_review('choice',review(expected_revision=2));store.decision_complete('choice',completion(expected_revision=2))
|
||||
self.assertEqual(self.request('/api/decisions/choice','PATCH',dict(expected_revision=2,reason='Late',summary='x'),headers)[0],403)
|
||||
self.assertEqual(self.request('/api/decisions/choice/facts','POST',dict(idempotency_key='late',question='Late?'),headers)[0],403)
|
||||
self.assertEqual(self.request('/api/decisions/choice/history',headers=headers)[0],200)
|
||||
|
||||
def test_workflow_http_endpoints_and_conflicts(self):
|
||||
self.request('/api/decisions','POST',definition())
|
||||
self.assertEqual(self.request('/api/decisions/choice/consultation')[0],409)
|
||||
bind(self.server.store)
|
||||
self.assertEqual(self.request('/api/decision-bridge-bindings')[0],200)
|
||||
self.assertEqual(self.request('/api/decisions/choice/respond','POST',answer())[0],200)
|
||||
self.assertEqual(self.request('/api/decisions/choice/review','POST',review())[0],200)
|
||||
self.assertEqual(self.request('/api/decisions/choice/complete','POST',completion())[0],200)
|
||||
self.assertEqual(self.request('/api/decisions/choice/archive-receipt','POST',dict(thread_id=THREAD,idempotency_key='archive',receipt='tool receipt'))[0],200)
|
||||
self.assertEqual(self.request('/api/decisions/choice/history')[1]['history'][-1]['kind'],'archive')
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
Reference in New Issue
Block a user