From e03a616ecf46815e5b7635677b729c60df1f2460 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 18 Jul 2026 04:31:55 -0300 Subject: [PATCH] Make ContractClock persistence atomic --- a2a.yaml | 2 +- agent.py | 62 ++++++++++++++++++------------- frontend/package.json | 2 +- requirements.txt | 1 - tests/test_full_stack_contract.py | 46 ++++++++++++++++++++--- 5 files changed, 78 insertions(+), 35 deletions(-) diff --git a/a2a.yaml b/a2a.yaml index 58fd66f..b69a71c 100644 --- a/a2a.yaml +++ b/a2a.yaml @@ -1,5 +1,5 @@ name: contract-clock-studio-v1 -version: 0.1.0 +version: 0.1.1 entrypoint: agent:ContractClockStudioV1 expose: public: false diff --git a/agent.py b/agent.py index b4c91f3..12f2873 100644 --- a/agent.py +++ b/agent.py @@ -43,6 +43,7 @@ ALLOWED_MEDIA_TYPES = ("text/plain", "text/markdown", "application/octet-stream" DATABASE_ENV = "DATABASE_URL" DB_STATEMENT_TIMEOUT_MS = 5_000 DB_CONNECT_TIMEOUT_SECONDS = 3 +VERSION = "0.1.1" ISO_DATE_RE = re.compile(r"\b(20\d{2}|19\d{2})-(0[1-9]|1[0-2])-([0-2]\d|3[01])\b") NOTICE_RE = re.compile( @@ -137,7 +138,7 @@ class ContractClockStudioV1(A2AAgent[ContractClockStudioV1Config, PlatformUserAu "extract explicit renewal and notice dates, persist the timeline in " "user-scoped managed Postgres, and reopen it later." ) - version = "0.1.0" + version = VERSION config_model = ContractClockStudioV1Config auth_model = PlatformUserAuth @@ -183,8 +184,8 @@ class ContractClockStudioV1(A2AAgent[ContractClockStudioV1Config, PlatformUserAu text: str, title: str, ) -> AnalyzeResult: - tenant = tenant_key(ctx) try: + tenant = tenant_key(ctx) clean_id = validate_contract_id(contract_id) clean_title = validate_title(title) clean_text = validate_text(text) @@ -207,8 +208,15 @@ class ContractClockStudioV1(A2AAgent[ContractClockStudioV1Config, PlatformUserAu } receipt_id = production_receipt_id(tenant, "analyze_contract", payload) try: - persist_contract(tenant, clean_id, clean_title, payload, receipt_id) - persist_receipt(tenant, receipt_id, "analyze_contract", clean_id, payload, "ok") + persist_contract_with_receipt( + tenant, + clean_id, + clean_title, + payload, + receipt_id, + "analyze_contract", + "ok", + ) except Exception: return AnalyzeResult( ok=False, @@ -309,8 +317,8 @@ class ContractClockStudioV1(A2AAgent[ContractClockStudioV1Config, PlatformUserAu ctx: RunContext[PlatformUserAuth], contract_id: str, ) -> ContractRecord: - tenant = tenant_key(ctx) try: + tenant = tenant_key(ctx) clean_id = validate_contract_id(contract_id) record = load_contract(tenant, clean_id) except ValidationFailure as exc: @@ -337,10 +345,12 @@ class ContractClockStudioV1(A2AAgent[ContractClockStudioV1Config, PlatformUserAu cost_class="deterministic", ) async def list_contracts(self, ctx: RunContext[PlatformUserAuth], limit: int = 20) -> ListContractsResult: - tenant = tenant_key(ctx) - safe_limit = max(1, min(int(limit), 50)) try: + tenant = tenant_key(ctx) + safe_limit = max(1, min(int(limit), 50)) rows = list_contract_rows(tenant, safe_limit) + except ValidationFailure as exc: + return ListContractsResult(ok=False, code=exc.code, message=exc.message) except Exception: return ListContractsResult(ok=False, code="persistence_unavailable", message="ContractClock could not list saved timelines.") return ListContractsResult(ok=True, contracts=[ContractSummary.model_validate(row) for row in rows]) @@ -481,28 +491,18 @@ def db_connection() -> Iterator[Any]: connect_timeout=DB_CONNECT_TIMEOUT_SECONDS, options=f"-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} -c idle_in_transaction_session_timeout={DB_STATEMENT_TIMEOUT_MS}", ) as conn: - with conn.cursor() as cur: - cur.execute("SET LOCAL statement_timeout = %s", (DB_STATEMENT_TIMEOUT_MS,)) yield conn -def persist_contract(tenant: str, contract_id: str, title: str, payload: dict[str, Any], receipt_id: str) -> None: - with db_connection() as conn: - with conn.cursor() as cur: - cur.execute( - """ - INSERT INTO contract_timelines (tenant_key, contract_id, title, payload, receipt_id, updated_at) - VALUES (%s, %s, %s, %s::jsonb, %s, NOW()) - ON CONFLICT (tenant_key, contract_id) - DO UPDATE SET title = EXCLUDED.title, payload = EXCLUDED.payload, - receipt_id = EXCLUDED.receipt_id, updated_at = NOW() - """, - (tenant, contract_id, title, json.dumps(payload), receipt_id), - ) - conn.commit() - - -def persist_receipt(tenant: str, receipt_id: str, skill_name: str, contract_id: str, payload: dict[str, Any], status: str) -> None: +def persist_contract_with_receipt( + tenant: str, + contract_id: str, + title: str, + payload: dict[str, Any], + receipt_id: str, + skill_name: str, + status: str, +) -> None: receipt = { "receipt_id": receipt_id, "agent": ContractClockStudioV1.name, @@ -515,6 +515,16 @@ def persist_receipt(tenant: str, receipt_id: str, skill_name: str, contract_id: } with db_connection() as conn: with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO contract_timelines (tenant_key, contract_id, title, payload, receipt_id, updated_at) + VALUES (%s, %s, %s, %s::jsonb, %s, NOW()) + ON CONFLICT (tenant_key, contract_id) + DO UPDATE SET title = EXCLUDED.title, payload = EXCLUDED.payload, + receipt_id = EXCLUDED.receipt_id, updated_at = NOW() + """, + (tenant, contract_id, title, json.dumps(payload), receipt_id), + ) cur.execute( """ INSERT INTO execution_receipts (tenant_key, receipt_id, skill_name, contract_id, status, payload) diff --git a/frontend/package.json b/frontend/package.json index 67e5365..4e93621 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "contract-clock-studio-v1-frontend", "private": true, - "version": "0.1.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0 --port 5173", diff --git a/requirements.txt b/requirements.txt index fb9a5c1..fb6a7c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ # a2a-pack is auto-installed by the deploy build. psycopg[binary]>=3.2 -pytest>=8.0 diff --git a/tests/test_full_stack_contract.py b/tests/test_full_stack_contract.py index 98e35ee..c3388af 100644 --- a/tests/test_full_stack_contract.py +++ b/tests/test_full_stack_contract.py @@ -82,21 +82,30 @@ def test_acceptance_success_reload_failure_and_receipt_persistence(): saved = {} receipts = [] - def fake_persist_contract(tenant, contract_id, title, payload, receipt_id): + def fake_persist_contract_with_receipt( + tenant, + contract_id, + title, + payload, + receipt_id, + skill_name, + status, + ): assert tenant == "user:123" saved[(tenant, contract_id)] = { "payload": payload, "receipt_id": receipt_id, "updated_at": "2026-01-01T00:00:00+00:00", } + receipts.append((tenant, receipt_id, skill_name, contract_id, status)) def fake_load_contract(tenant, contract_id): return saved.get((tenant, contract_id)) - def fake_persist_receipt(tenant, receipt_id, skill_name, contract_id, payload, status): - receipts.append((tenant, receipt_id, skill_name, contract_id, status)) - - with patch("agent.persist_contract", side_effect=fake_persist_contract), patch("agent.load_contract", side_effect=fake_load_contract), patch("agent.persist_receipt", side_effect=fake_persist_receipt): + with patch( + "agent.persist_contract_with_receipt", + side_effect=fake_persist_contract_with_receipt, + ), patch("agent.load_contract", side_effect=fake_load_contract): result = asyncio.run(agent.analyze_contract(ctx(), contract_id="studio-contract-v1", text=SUCCESS_TEXT, title="Studio Renewal Agreement")) assert result.ok is True assert result.persisted is True @@ -124,7 +133,7 @@ def test_acceptance_success_reload_failure_and_receipt_persistence(): def test_browser_upload_bridge_bounds_and_reuses_workflow(): agent = ContractClockStudioV1() encoded = base64.b64encode(SUCCESS_TEXT.encode("utf-8")).decode("ascii") - with patch("agent.persist_contract", return_value=None), patch("agent.persist_receipt", return_value=None): + with patch("agent.persist_contract_with_receipt", return_value=None): result = asyncio.run( agent.analyze_contract_browser_upload( ctx(), @@ -146,6 +155,31 @@ def test_browser_upload_bridge_bounds_and_reuses_workflow(): assert bad.ok is False and bad.code == "invalid_base64" +def test_tools_return_structured_auth_failure_for_missing_principal(): + anonymous = LocalRunContext( + auth=PlatformUserAuth(sub=""), + caller="tester", + ) + agent = ContractClockStudioV1() + + analyzed = asyncio.run( + agent.analyze_contract( + anonymous, + contract_id="studio-contract-v1", + text=SUCCESS_TEXT, + title="Studio Renewal Agreement", + ) + ) + loaded = asyncio.run( + agent.get_contract_deadlines(anonymous, contract_id="studio-contract-v1") + ) + listed = asyncio.run(agent.list_contracts(anonymous)) + + assert analyzed.ok is False and analyzed.code == "auth_required" + assert loaded.ok is False and loaded.code == "auth_required" + assert listed.ok is False and listed.code == "auth_required" + + def test_receipt_id_is_deterministic_without_secrets(): payload = {"contract_id": "studio-contract-v1", "deadlines": [{"kind": "renewal", "date": "2026-12-31"}]} receipt = production_receipt_id("user:123", "analyze_contract", payload)