from __future__ import annotations import json import sqlite3 from datetime import datetime, timezone from pathlib import Path from typing import Any from .config import settings def utc_now() -> str: return datetime.now(timezone.utc).isoformat() def connect() -> sqlite3.Connection: settings.db_path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(settings.db_path) conn.row_factory = sqlite3.Row return conn def init_db() -> None: with connect() as conn: conn.execute( """ create table if not exists jobs ( id text primary key, type text not null, status text not null, params_json text not null, command_json text not null, cwd text not null, description text not null default '', pid integer, exit_code integer, created_at text not null, started_at text, finished_at text, log_path text not null, error text ) """ ) conn.execute( """ create table if not exists profiles ( id integer primary key autoincrement, name text not null, kind text not null, data_json text not null, updated_at text not null, unique(name, kind) ) """ ) def _job_from_row(row: sqlite3.Row) -> dict[str, Any]: data = dict(row) data["params"] = json.loads(data.pop("params_json")) data["command"] = json.loads(data.pop("command_json")) return data def insert_job(job: dict[str, Any]) -> None: with connect() as conn: conn.execute( """ insert into jobs ( id, type, status, params_json, command_json, cwd, description, pid, exit_code, created_at, started_at, finished_at, log_path, error ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( job["id"], job["type"], job["status"], json.dumps(job["params"], ensure_ascii=False), json.dumps(job["command"], ensure_ascii=False), job["cwd"], job.get("description", ""), job.get("pid"), job.get("exit_code"), job["created_at"], job.get("started_at"), job.get("finished_at"), job["log_path"], job.get("error"), ), ) def update_job(job_id: str, **fields: Any) -> None: if not fields: return allowed = { "status", "pid", "exit_code", "started_at", "finished_at", "error", } updates = {key: value for key, value in fields.items() if key in allowed} if not updates: return assignments = ", ".join(f"{key}=?" for key in updates) with connect() as conn: conn.execute(f"update jobs set {assignments} where id=?", [*updates.values(), job_id]) def get_job(job_id: str) -> dict[str, Any] | None: with connect() as conn: row = conn.execute("select * from jobs where id=?", (job_id,)).fetchone() return _job_from_row(row) if row else None def list_jobs(limit: int = 100) -> list[dict[str, Any]]: with connect() as conn: rows = conn.execute( "select * from jobs order by created_at desc limit ?", (limit,) ).fetchall() return [_job_from_row(row) for row in rows] def upsert_profile(name: str, kind: str, data: dict[str, Any]) -> dict[str, Any]: updated_at = utc_now() with connect() as conn: conn.execute( """ insert into profiles (name, kind, data_json, updated_at) values (?, ?, ?, ?) on conflict(name, kind) do update set data_json=excluded.data_json, updated_at=excluded.updated_at """, (name, kind, json.dumps(data, ensure_ascii=False), updated_at), ) return {"name": name, "kind": kind, "data": data, "updated_at": updated_at} def list_profiles(kind: str | None = None) -> list[dict[str, Any]]: sql = "select name, kind, data_json, updated_at from profiles" params: tuple = () if kind: sql += " where kind=?" params = (kind,) sql += " order by kind, name" with connect() as conn: rows = conn.execute(sql, params).fetchall() return [ { "name": row["name"], "kind": row["kind"], "data": json.loads(row["data_json"]), "updated_at": row["updated_at"], } for row in rows ] def log_tail(log_path: str | Path, max_bytes: int = 8192) -> str: path = Path(log_path) if not path.exists(): return "" size = path.stat().st_size with path.open("rb") as handle: if size > max_bytes: handle.seek(size - max_bytes) return handle.read().decode("utf-8", errors="replace")