ducklm/duck_core/approvals/service.py

154 lines
5.1 KiB
Python

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"],
)