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>
85 lines
2.8 KiB
Python
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}"
|