import hashlib import json from pathlib import Path from typing import Any from uuid import uuid4 import aiosqlite from pydantic import BaseModel from duck_core.tasks.store import utc_now class Approval(BaseModel): id: int | None = None approval_id: str task_id: str action_hash: str normalized_action: dict[str, Any] status: str decision: str | None = None created_at: str updated_at: str def normalize_action(action: dict[str, Any]) -> str: return json.dumps(action, sort_keys=True, separators=(",", ":")) def action_hash(action: dict[str, Any]) -> str: return hashlib.sha256(normalize_action(action).encode()).hexdigest() class ApprovalService: def __init__(self, db_path: str): self.db_path = Path(db_path) async def init(self) -> None: self.db_path.parent.mkdir(parents=True, exist_ok=True) async with aiosqlite.connect(self.db_path) as db: await db.execute( """ create table if not exists approvals ( id integer primary key autoincrement, approval_id text not null unique, task_id text not null, action_hash text not null, normalized_action_json text not null, status text not null, decision text, created_at text not null, updated_at text not null ) """ ) await db.commit() async def create_pending(self, task_id: str, action: dict[str, Any]) -> Approval: await self.init() now = utc_now() approval_id = f"approval_{uuid4().hex[:12]}" normalized = normalize_action(action) digest = action_hash(action) async with aiosqlite.connect(self.db_path) as db: cursor = await db.execute( """ insert into approvals( approval_id, task_id, action_hash, normalized_action_json, status, created_at, updated_at ) values (?, ?, ?, ?, ?, ?, ?) """, (approval_id, task_id, digest, normalized, "pending", now, now), ) await db.commit() row_id = cursor.lastrowid return Approval( id=row_id, approval_id=approval_id, task_id=task_id, action_hash=digest, normalized_action=action, status="pending", created_at=now, updated_at=now, ) async def pending(self) -> list[Approval]: await self.init() async with aiosqlite.connect(self.db_path) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "select * from approvals where status = 'pending' order by created_at" ) rows = await cursor.fetchall() return [self._row_to_approval(row) for row in rows] async def get(self, approval_id: str) -> Approval | None: await self.init() async with aiosqlite.connect(self.db_path) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "select * from approvals where approval_id = ?", (approval_id,) ) row = await cursor.fetchone() return self._row_to_approval(row) if row else None async def allow_once(self, approval_id: str) -> None: await self._decide(approval_id, "resolved", "allow_once") async def allow_forever(self, approval_id: str) -> None: await self._decide(approval_id, "allowed_forever", "allow_forever") async def deny(self, approval_id: str) -> None: await self._decide(approval_id, "resolved", "deny") async def is_allowed_forever(self, action: dict[str, Any]) -> bool: await self.init() digest = action_hash(action) async with aiosqlite.connect(self.db_path) as db: cursor = await db.execute( """ select 1 from approvals where action_hash = ? and status = 'allowed_forever' limit 1 """, (digest,), ) row = await cursor.fetchone() return row is not None async def _decide(self, approval_id: str, status: str, decision: str) -> None: await self.init() async with aiosqlite.connect(self.db_path) as db: await db.execute( """ update approvals set status = ?, decision = ?, updated_at = ? where approval_id = ? """, (status, decision, utc_now(), approval_id), ) await db.commit() def _row_to_approval(self, row: aiosqlite.Row) -> Approval: return Approval( id=row["id"], approval_id=row["approval_id"], task_id=row["task_id"], action_hash=row["action_hash"], normalized_action=json.loads(row["normalized_action_json"]), status=row["status"], decision=row["decision"], created_at=row["created_at"], updated_at=row["updated_at"], )