pipekit/pipekit/db.py
Paul Trowbridge c34fcb38ed Add scheduling, harden deploy, and update docs
Scheduling: cron-based group runs via a daemon thread (scheduler.py)
started at API startup. Schedules managed inline on the group edit form.
last_fired_at persisted before run to prevent double-fire on restart.
Requires croniter (added to requirements.txt); DB migration adds
last_fired_at column to schedule table.

Deploy: deploy.sh now creates the pipekit system user, chowns the repo,
builds the venv as pipekit, and installs/enables the systemd unit.
systemd/pipekit.service is now a production-ready unit (User=pipekit
uncommented). pipekit secrets set preserves existing file permissions
instead of resetting to 0600. Driver registration is now idempotent
(upsert via get_driver_by_name + update_driver).

Docs: CLAUDE.md and SPEC.md updated to reflect groups, scheduling,
scheduler-in-API-process architecture, TUI deferred (not dropped),
stop-on-failure tradeoff, jrunner as prerequisite, and deploy flow.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-03 21:18:13 -04:00

85 lines
2.8 KiB
Python

"""SQLite connection + schema init.
Higher-level CRUD helpers live in later modules (per resource). This module
only owns: opening a connection, committing transactions, and creating the
schema from schema.sql.
"""
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from .config import get_config
SCHEMA_PATH = Path(__file__).parent / "schema.sql"
def init_db(db_path: Path | None = None) -> None:
path = db_path or get_config().database
path.parent.mkdir(parents=True, exist_ok=True)
ddl = SCHEMA_PATH.read_text()
conn = sqlite3.connect(path)
try:
conn.executescript(ddl)
_apply_migrations(conn)
conn.commit()
finally:
conn.close()
def _apply_migrations(conn: sqlite3.Connection) -> None:
"""Idempotent ALTERs for columns added after initial release. SQLite has
no IF NOT EXISTS on ADD COLUMN, so we introspect first."""
cols = {r[1] for r in conn.execute("PRAGMA table_info(module)")}
if "columns_json" not in cols:
conn.execute("ALTER TABLE module ADD COLUMN columns_json TEXT")
if "dest_description" not in cols:
conn.execute("ALTER TABLE module ADD COLUMN dest_description TEXT")
rl_cols = {r[1] for r in conn.execute("PRAGMA table_info(run_log)")}
if "live_log" not in rl_cols:
conn.execute("ALTER TABLE run_log ADD COLUMN live_log TEXT")
sc_cols = {r[1] for r in conn.execute("PRAGMA table_info(schedule)")}
if "last_fired_at" not in sc_cols:
conn.execute("ALTER TABLE schedule ADD COLUMN last_fired_at TEXT")
@contextmanager
def connect(db_path: Path | None = None):
path = db_path or get_config().database
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def ping() -> tuple[bool, str]:
"""Return (ok, message). Used by pipekit doctor."""
try:
path = get_config().database
if not path.exists():
return False, f"database file missing: {path} (run `pipekit init`)"
with connect(path) as c:
tables = [r[0] for r in c.execute(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
)]
expected = {"connection", "driver", "grp", "group_member", "group_run",
"hook", "module", "run_log", "schedule", "settings", "watermark"}
missing = expected - set(tables)
if missing:
return False, f"schema incomplete — missing: {', '.join(sorted(missing))}"
return True, f"{path} ({len(tables)} tables)"
except Exception as e:
return False, f"{type(e).__name__}: {e}"