Release Fenris 0.3.6
Release / release (push) Failing after 6s

This commit is contained in:
xavierk
2026-09-16 08:05:28 +05:30
parent f06424f3b8
commit 44c57b70dd
29 changed files with 788 additions and 810 deletions
+100 -229
View File
@@ -14,10 +14,14 @@ Freshness constants are defined once here and shared with the TUI (§8.9):
Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7.
"""
import sqlite3
from contextlib import contextmanager
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from .status_composition import StatusComposition
from .projection import compute_projection, ConfidenceState, DISCLOSURES
from .store import SCHEMA_VERSION
@@ -98,7 +102,7 @@ def open_store_readonly(store_path: Path) -> sqlite3.Connection:
# PermissionError before any StoreFault can be raised (issue #54).
raise StoreFault("observation store not readable: %s" % e)
if not exists:
raise StoreFault("observation store not found at %s" % store_path)
raise MissingStore("observation store not found at %s" % store_path)
try:
conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True)
@@ -125,6 +129,10 @@ class StoreFault(Exception):
pass
class MissingStore(StoreFault):
"""No observation history has been created yet."""
class NewerSchema(Exception):
"""Store has a newer user_version (§9.5)."""
def __init__(self, version: int):
@@ -246,27 +254,12 @@ def check_retired_flag(flag: str) -> Optional[str]:
# Formatting
# ---------------------------------------------------------------------------
def _format_projection(proj, freshness: str, service: Dict[str, Any],
drive_facts: List[str], config_error: Optional[str],
store_fault: Optional[str], newer_schema: Optional[str],
journal_hint: Optional[str],
def _format_projection(proj, freshness: str, drive_facts: List[str],
config_error: Optional[str],
sample_count: int = 0, day_count: int = 0) -> str:
"""Format the complete status output."""
"""Format projection details; monitoring status has its own renderer."""
lines = []
# --- Store/system fault overrides (§9.4, §9.5) ---
if store_fault:
lines.append("observation store unreadable")
if journal_hint:
lines.append("")
lines.append("Recent journal entries:")
lines.append(journal_hint)
return "\n".join(lines)
if newer_schema:
lines.append("observation store written by a newer Fenris — upgrade Fenris")
return "\n".join(lines)
# --- Configuration error (§8.3) ---
if config_error:
lines.append("configuration error: %s" % config_error)
@@ -277,7 +270,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
lines.append("no observations yet")
lines.append("")
lines.append("Enable monitoring: fenris monitor resume")
_append_service_facts(lines, service)
return "\n".join(lines)
# --- Single sample: awaiting another sample (issue #73 AC3) ---
@@ -287,7 +279,10 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
lines.append("awaiting another sample")
lines.append("")
lines.append("Collecting usage data — the first projection requires at least two samples.")
_append_service_facts(lines, service)
return "\n".join(lines)
if proj is None:
lines.append("no projection available")
return "\n".join(lines)
# --- Projection headline ---
@@ -332,16 +327,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
lines.append(fact)
lines.append("")
# --- Four separate service facts (§7.3, LC-9) ---
_append_service_facts(lines, service)
# --- Journal hint on failure or staleness (§8.8) ---
if journal_hint:
if freshness in ("missed", "stale"):
lines.append("")
lines.append("Recent journal entries:")
lines.append(journal_hint)
return "\n".join(lines)
@@ -386,36 +371,6 @@ def _format_headline(proj) -> str:
return headline
def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
"""Append service facts and dashboard-clarity monitoring state."""
boot = "enabled" if service.get("boot_enabled") else "disabled"
activity = "active" if service.get("timer_active") else "inactive"
if service.get("last_collect_ok") is True:
collect = "ok"
elif service.get("last_collect_ok") is False:
collect = "FAILED"
if service.get("last_collect_reason"):
collect += " (%s)" % service["last_collect_reason"]
else:
collect = "unknown"
collect_age = ""
if service.get("last_collect_age_s") is not None:
collect_age = " %s" % freshness_age_human(service["last_collect_age_s"])
freshness_str = service.get("freshness", "unknown")
freshness_age = ""
if service.get("freshness_age_s") is not None:
freshness_age = " (%s)" % freshness_age_human(service["freshness_age_s"])
lines.append("boot: %s · timer: %s · last collect: %s%s · freshness: %s%s"
% (boot, activity, collect, collect_age, freshness_str, freshness_age))
lines.append("CONTINUITY: %s" % monitoring_continuity(service))
if service.get("deliberately_paused"):
lines.extend(deliberate_pause_lines())
# ---------------------------------------------------------------------------
# Dashboard clarity parity wording (DC-2, DC-3)
# ---------------------------------------------------------------------------
@@ -430,7 +385,9 @@ _PAUSED_CONSEQUENCE = (
def monitoring_continuity(service: Dict[str, Any]) -> str:
"""Return the boot-persistence wording, independent of timer runtime."""
return _CONTINUITY_ACTIVE if service.get("boot_enabled") else _CONTINUITY_DISABLED
if service.get("boot_enabled") is None:
return "monitoring: boot persistence unknown"
return _CONTINUITY_ACTIVE if service["boot_enabled"] else _CONTINUITY_DISABLED
def deliberate_pause_lines() -> List[str]:
@@ -476,121 +433,92 @@ def format_disclosures() -> str:
# Main status entry point
# ---------------------------------------------------------------------------
def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
query_services: bool = True, query_journal: bool = True) -> str:
"""Render the complete read-only status (§8.8, LC-9).
@contextmanager
def read_status(
store_path: Optional[Path] = None,
clock_now: Optional[datetime] = None,
query_services: bool = True,
collecting: bool = False,
reduced_motion: bool = False,
) -> Iterator[Tuple[Optional[sqlite3.Connection], "StatusComposition"]]:
"""Yield a read-only store snapshot and its composed monitoring status.
This is the single entry point for 'fenris status'. It never auto-samples,
never prompts, and never writes to the store.
Own acquisition, fault classification, and connection lifetime for both
renderers. An absent store is empty; an unreadable or newer store exposes
no connection. Unknown monitoring facts are never coerced to disabled.
"""
if clock_now is None:
clock_now = datetime.now(timezone.utc)
from .status_composition import compose_status
# --- Configuration (§8.3) ---
config_error = None
device = None
try:
config = read_config()
device = config["device"]
except ConfigError as e:
config_error = str(e)
# --- Service state ---
service = {}
clock_now = clock_now or datetime.now(timezone.utc)
service = None
if query_services:
service = query_service_state()
# --- Store open ---
store_fault = None
newer_schema = None
conn = None
if store_path is None:
store_path = Path("/var/lib/fenris/observations.db")
try:
conn = open_store_readonly(store_path)
except StoreFault as e:
store_fault = str(e)
except NewerSchema as e:
newer_schema = str(e)
# --- Store fault / newer schema short-circuit ---
if store_fault or newer_schema:
journal_hint = None
if query_journal:
journal_hint = _journalctl_hint()
service["freshness"] = "unknown"
service["freshness_age_s"] = None
return _format_projection(
None, "unknown", service, [], config_error, store_fault, newer_schema, journal_hint
)
# --- Freshness grading (§8.9) ---
try:
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
row = cursor.fetchone()
newest_ts = row[0] if row else None
except sqlite3.Error:
newest_ts = None
freshness = grade_freshness(newest_ts, clock_now)
# --- Sample count for single-sample state (issue #73 AC3) ---
sample_count = 0
day_count = 0
try:
cursor = conn.execute("SELECT COUNT(*) FROM samples")
sample_count = cursor.fetchone()[0]
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
day_count = cursor.fetchone()[0]
except sqlite3.Error:
pass
# Freshness age for the service fact
freshness_age_s = None
if newest_ts:
try:
ts = datetime.fromisoformat(newest_ts)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
freshness_age_s = int((clock_now - ts).total_seconds())
except (ValueError, TypeError):
service = query_service_state()
except (OSError, RuntimeError):
pass
service["freshness"] = freshness
service["freshness_age_s"] = freshness_age_s
conn = None
store_fault = newer_schema = None
try:
service["deliberately_paused"] = is_deliberately_paused(conn, service)
except sqlite3.Error:
service["deliberately_paused"] = False
try:
conn = open_store_readonly(store_path or Path("/var/lib/fenris/observations.db"))
conn.execute("BEGIN")
except MissingStore:
pass
except (StoreFault, sqlite3.Error) as exc:
store_fault = str(exc)
except NewerSchema as exc:
newer_schema = str(exc)
# --- Drive anomalies (§9.7, FL-7) ---
drive_facts = []
comp = compose_status(
conn, service, clock_now, store_fault=store_fault,
newer_schema=newer_schema, collecting=collecting,
reduced_motion=reduced_motion,
)
if conn is not None and (comp.store_fault or comp.newer_schema):
conn.close()
conn = None
yield conn, comp
finally:
if conn is not None:
conn.close()
def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
query_services: bool = True, query_journal: bool = True) -> str:
"""Render CLI status through the shared read-only acquisition path."""
from .status_composition import render_status_cli
clock_now = clock_now or datetime.now(timezone.utc)
config_error = None
try:
drive_facts = _query_drive_facts(conn)
except sqlite3.Error:
pass
read_config()
except ConfigError as exc:
config_error = str(exc)
# --- Projection (§6 — recomputed on read, never stored) ---
try:
proj = compute_projection(conn, clock_now)
except Exception:
proj = None
# --- Journal hint on failure or staleness (§8.8) ---
journal_hint = None
if query_journal and freshness in ("missed", "stale"):
journal_hint = _journalctl_hint()
# --- Compose output ---
result = _format_projection(
proj, freshness, service, drive_facts, config_error,
None, None, journal_hint, sample_count, day_count,
)
conn.close()
return result
with read_status(store_path, clock_now, query_services) as (conn, comp):
parts = [render_status_cli(comp)]
if not comp.store_fault and not comp.newer_schema:
drive_facts = []
proj = None
if conn is not None:
try:
drive_facts = _query_drive_facts(conn)
proj = compute_projection(conn, clock_now)
except (sqlite3.Error, ValueError, TypeError):
pass
parts.append(_format_projection(
proj, comp.freshness, drive_facts, config_error,
comp.sample_count, comp.day_count,
))
if query_journal and (
comp.store_fault or comp.last_collect_ok is False
or comp.freshness in ("missed", "stale")
):
hint = _journalctl_hint()
if hint:
parts.append("Recent collector logs:\n" + hint)
return "\n\n".join(part for part in parts if part)
def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
@@ -619,65 +547,8 @@ def get_status_composition(
collecting: bool = False,
reduced_motion: bool = False,
) -> 'StatusComposition':
"""Get the shared status composition consumed by both TUI and CLI.
This is the new entry point that centralizes the status lattice.
"""
# Lazy import to avoid circular dependency
from .status_composition import (
StatusComposition,
compose_status,
)
if clock_now is None:
clock_now = datetime.now(timezone.utc)
# --- Configuration (§8.3) ---
# Config errors are surfaced through the store fault mechanism
# --- Service state ---
service = {}
if query_services:
try:
service = query_service_state()
except Exception:
service = None
# --- Store open ---
store_fault = None
newer_schema = None
conn = None
if store_path is None:
store_path = Path("/var/lib/fenris/observations.db")
try:
conn = open_store_readonly(store_path)
except StoreFault as e:
store_fault = str(e)
except NewerSchema as e:
newer_schema = str(e)
# --- Use shared composition ---
if conn is not None:
try:
comp = compose_status(
conn, service, clock_now,
store_fault=store_fault,
newer_schema=newer_schema,
collecting=collecting,
reduced_motion=reduced_motion,
)
finally:
conn.close()
else:
# Store fault or newer schema - compose without store data
comp = compose_status(
None, service, clock_now,
store_fault=store_fault,
newer_schema=newer_schema,
collecting=collecting,
reduced_motion=reduced_motion,
)
return comp
"""Return monitoring status without retaining the read-only snapshot."""
with read_status(
store_path, clock_now, query_services, collecting, reduced_motion,
) as (_, comp):
return comp