Make ContractClock persistence atomic
This commit is contained in:
2
a2a.yaml
2
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
|
||||
|
||||
60
agent.py
60
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:
|
||||
try:
|
||||
tenant = tenant_key(ctx)
|
||||
safe_limit = max(1, min(int(limit), 50))
|
||||
try:
|
||||
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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
# a2a-pack is auto-installed by the deploy build.
|
||||
psycopg[binary]>=3.2
|
||||
pytest>=8.0
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user