Make ContractClock persistence atomic

This commit is contained in:
2026-07-18 04:31:55 -03:00
parent 3a3df9d577
commit e03a616ecf
5 changed files with 78 additions and 35 deletions

View File

@@ -1,5 +1,5 @@
name: contract-clock-studio-v1 name: contract-clock-studio-v1
version: 0.1.0 version: 0.1.1
entrypoint: agent:ContractClockStudioV1 entrypoint: agent:ContractClockStudioV1
expose: expose:
public: false public: false

View File

@@ -43,6 +43,7 @@ ALLOWED_MEDIA_TYPES = ("text/plain", "text/markdown", "application/octet-stream"
DATABASE_ENV = "DATABASE_URL" DATABASE_ENV = "DATABASE_URL"
DB_STATEMENT_TIMEOUT_MS = 5_000 DB_STATEMENT_TIMEOUT_MS = 5_000
DB_CONNECT_TIMEOUT_SECONDS = 3 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") 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( NOTICE_RE = re.compile(
@@ -137,7 +138,7 @@ class ContractClockStudioV1(A2AAgent[ContractClockStudioV1Config, PlatformUserAu
"extract explicit renewal and notice dates, persist the timeline in " "extract explicit renewal and notice dates, persist the timeline in "
"user-scoped managed Postgres, and reopen it later." "user-scoped managed Postgres, and reopen it later."
) )
version = "0.1.0" version = VERSION
config_model = ContractClockStudioV1Config config_model = ContractClockStudioV1Config
auth_model = PlatformUserAuth auth_model = PlatformUserAuth
@@ -183,8 +184,8 @@ class ContractClockStudioV1(A2AAgent[ContractClockStudioV1Config, PlatformUserAu
text: str, text: str,
title: str, title: str,
) -> AnalyzeResult: ) -> AnalyzeResult:
tenant = tenant_key(ctx)
try: try:
tenant = tenant_key(ctx)
clean_id = validate_contract_id(contract_id) clean_id = validate_contract_id(contract_id)
clean_title = validate_title(title) clean_title = validate_title(title)
clean_text = validate_text(text) clean_text = validate_text(text)
@@ -207,8 +208,15 @@ class ContractClockStudioV1(A2AAgent[ContractClockStudioV1Config, PlatformUserAu
} }
receipt_id = production_receipt_id(tenant, "analyze_contract", payload) receipt_id = production_receipt_id(tenant, "analyze_contract", payload)
try: try:
persist_contract(tenant, clean_id, clean_title, payload, receipt_id) persist_contract_with_receipt(
persist_receipt(tenant, receipt_id, "analyze_contract", clean_id, payload, "ok") tenant,
clean_id,
clean_title,
payload,
receipt_id,
"analyze_contract",
"ok",
)
except Exception: except Exception:
return AnalyzeResult( return AnalyzeResult(
ok=False, ok=False,
@@ -309,8 +317,8 @@ class ContractClockStudioV1(A2AAgent[ContractClockStudioV1Config, PlatformUserAu
ctx: RunContext[PlatformUserAuth], ctx: RunContext[PlatformUserAuth],
contract_id: str, contract_id: str,
) -> ContractRecord: ) -> ContractRecord:
tenant = tenant_key(ctx)
try: try:
tenant = tenant_key(ctx)
clean_id = validate_contract_id(contract_id) clean_id = validate_contract_id(contract_id)
record = load_contract(tenant, clean_id) record = load_contract(tenant, clean_id)
except ValidationFailure as exc: except ValidationFailure as exc:
@@ -337,10 +345,12 @@ class ContractClockStudioV1(A2AAgent[ContractClockStudioV1Config, PlatformUserAu
cost_class="deterministic", cost_class="deterministic",
) )
async def list_contracts(self, ctx: RunContext[PlatformUserAuth], limit: int = 20) -> ListContractsResult: async def list_contracts(self, ctx: RunContext[PlatformUserAuth], limit: int = 20) -> ListContractsResult:
try:
tenant = tenant_key(ctx) tenant = tenant_key(ctx)
safe_limit = max(1, min(int(limit), 50)) safe_limit = max(1, min(int(limit), 50))
try:
rows = list_contract_rows(tenant, safe_limit) rows = list_contract_rows(tenant, safe_limit)
except ValidationFailure as exc:
return ListContractsResult(ok=False, code=exc.code, message=exc.message)
except Exception: except Exception:
return ListContractsResult(ok=False, code="persistence_unavailable", message="ContractClock could not list saved timelines.") 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]) 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, 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}", options=f"-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} -c idle_in_transaction_session_timeout={DB_STATEMENT_TIMEOUT_MS}",
) as conn: ) as conn:
with conn.cursor() as cur:
cur.execute("SET LOCAL statement_timeout = %s", (DB_STATEMENT_TIMEOUT_MS,))
yield conn yield conn
def persist_contract(tenant: str, contract_id: str, title: str, payload: dict[str, Any], receipt_id: str) -> None: def persist_contract_with_receipt(
with db_connection() as conn: tenant: str,
with conn.cursor() as cur: contract_id: str,
cur.execute( title: str,
""" payload: dict[str, Any],
INSERT INTO contract_timelines (tenant_key, contract_id, title, payload, receipt_id, updated_at) receipt_id: str,
VALUES (%s, %s, %s, %s::jsonb, %s, NOW()) skill_name: str,
ON CONFLICT (tenant_key, contract_id) status: str,
DO UPDATE SET title = EXCLUDED.title, payload = EXCLUDED.payload, ) -> None:
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:
receipt = { receipt = {
"receipt_id": receipt_id, "receipt_id": receipt_id,
"agent": ContractClockStudioV1.name, "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 db_connection() as conn:
with conn.cursor() as cur: 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( cur.execute(
""" """
INSERT INTO execution_receipts (tenant_key, receipt_id, skill_name, contract_id, status, payload) INSERT INTO execution_receipts (tenant_key, receipt_id, skill_name, contract_id, status, payload)

View File

@@ -1,7 +1,7 @@
{ {
"name": "contract-clock-studio-v1-frontend", "name": "contract-clock-studio-v1-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0 --port 5173", "dev": "vite --host 0.0.0.0 --port 5173",

View File

@@ -1,3 +1,2 @@
# a2a-pack is auto-installed by the deploy build. # a2a-pack is auto-installed by the deploy build.
psycopg[binary]>=3.2 psycopg[binary]>=3.2
pytest>=8.0

View File

@@ -82,21 +82,30 @@ def test_acceptance_success_reload_failure_and_receipt_persistence():
saved = {} saved = {}
receipts = [] 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" assert tenant == "user:123"
saved[(tenant, contract_id)] = { saved[(tenant, contract_id)] = {
"payload": payload, "payload": payload,
"receipt_id": receipt_id, "receipt_id": receipt_id,
"updated_at": "2026-01-01T00:00:00+00:00", "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): def fake_load_contract(tenant, contract_id):
return saved.get((tenant, contract_id)) return saved.get((tenant, contract_id))
def fake_persist_receipt(tenant, receipt_id, skill_name, contract_id, payload, status): with patch(
receipts.append((tenant, receipt_id, skill_name, contract_id, status)) "agent.persist_contract_with_receipt",
side_effect=fake_persist_contract_with_receipt,
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): ), 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")) 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.ok is True
assert result.persisted 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(): def test_browser_upload_bridge_bounds_and_reuses_workflow():
agent = ContractClockStudioV1() agent = ContractClockStudioV1()
encoded = base64.b64encode(SUCCESS_TEXT.encode("utf-8")).decode("ascii") 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( result = asyncio.run(
agent.analyze_contract_browser_upload( agent.analyze_contract_browser_upload(
ctx(), ctx(),
@@ -146,6 +155,31 @@ def test_browser_upload_bridge_bounds_and_reuses_workflow():
assert bad.ok is False and bad.code == "invalid_base64" 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(): def test_receipt_id_is_deterministic_without_secrets():
payload = {"contract_id": "studio-contract-v1", "deadlines": [{"kind": "renewal", "date": "2026-12-31"}]} payload = {"contract_id": "studio-contract-v1", "deadlines": [{"kind": "renewal", "date": "2026-12-31"}]}
receipt = production_receipt_id("user:123", "analyze_contract", payload) receipt = production_receipt_id("user:123", "analyze_contract", payload)