Make ContractClock persistence atomic
This commit is contained in:
2
a2a.yaml
2
a2a.yaml
@@ -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
|
||||||
|
|||||||
62
agent.py
62
agent.py
@@ -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:
|
||||||
tenant = tenant_key(ctx)
|
|
||||||
safe_limit = max(1, min(int(limit), 50))
|
|
||||||
try:
|
try:
|
||||||
|
tenant = tenant_key(ctx)
|
||||||
|
safe_limit = max(1, min(int(limit), 50))
|
||||||
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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user