fix(datastore): auto-close stale sleep sessions and validate event kind in CLI

This commit is contained in:
Moritz Graf
2026-10-09 18:46:39 +02:00
parent c9cbc56441
commit 88d49f85e5
2 changed files with 164 additions and 11 deletions
+72 -11
View File
@@ -23,12 +23,15 @@ import os
import re
import subprocess
import sys
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from zoneinfo import ZoneInfo
import duckdb
logger = logging.getLogger("datastore")
def generate_ulid() -> str:
try:
from ulid import ULID
@@ -384,6 +387,58 @@ class DatastoreEngine:
}
return None
def _auto_close_stale_sleep_sessions(self, subject: str, current_start_dt: datetime.datetime) -> List[str]:
"""Auto-closes open sleep sessions that started >10h ago or on a prior calendar day."""
open_sessions = self.con.execute(
"""
SELECT id, start, payload, note
FROM events
WHERE kind = 'sleep.session' AND subject = ? AND "end" IS NULL AND deleted_at IS NULL
ORDER BY start ASC
""",
[subject],
).fetchall()
closed_ids = []
for o_id, o_start, o_payload, o_note in open_sessions:
o_start_dt = datetime.datetime.fromisoformat(str(o_start).replace("Z", "+00:00")).astimezone(BERLIN_TZ)
diff_hours = (current_start_dt - o_start_dt).total_seconds() / 3600.0
# Stale if open for >= 10 hours or started on an earlier day
if diff_hours >= 10.0 or (current_start_dt.date() > o_start_dt.date() and current_start_dt.hour >= 6):
if o_start_dt.hour >= 17:
next_day = o_start_dt.date() + datetime.timedelta(days=1)
auto_end_dt = datetime.datetime.combine(next_day, datetime.time(7, 0), tzinfo=BERLIN_TZ)
if auto_end_dt > current_start_dt:
auto_end_dt = current_start_dt
else:
auto_end_dt = o_start_dt + datetime.timedelta(minutes=45)
if auto_end_dt > current_start_dt:
auto_end_dt = current_start_dt
auto_end_iso = auto_end_dt.isoformat()
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
note_suffix = "Auto-beendet (vergessenes Aufwachen)"
updated_note = f"{o_note} | {note_suffix}" if o_note else note_suffix
self.con.execute(
"""
UPDATE events
SET "end" = ?, estimated = true, note = ?, updated_at = ?
WHERE id = ?
""",
[auto_end_iso, updated_note, now_iso, o_id],
)
self._update_jsonl_record("sleep", o_id, {
"end": auto_end_iso,
"estimated": True,
"note": updated_note,
"updated_at": now_iso,
})
closed_ids.append(o_id)
logger.info(f"Auto-closed stale open sleep session {o_id} for {subject} at {auto_end_iso}")
return closed_ids
# --------------------------------------------------------------------------
# Event Operations
# --------------------------------------------------------------------------
@@ -446,17 +501,19 @@ class DatastoreEngine:
"feedback": feedback,
}
# Validation for open intervals in sleep
if kind == "sleep.session" and end_iso is None:
# Check if there is already an open session for this subject
open_sessions = self.con.execute(
"SELECT id, start FROM events WHERE kind = 'sleep.session' AND subject = ? AND \"end\" IS NULL AND deleted_at IS NULL",
[subject],
).fetchall()
if open_sessions:
raise ValueError(
f"Subject '{subject}' already has an open sleep session ({open_sessions[0][0]} started at {open_sessions[0][1]}). Please stop it first."
)
# Validation and stale cleanup for sleep sessions
if kind == "sleep.session":
self._auto_close_stale_sleep_sessions(subject, start_dt)
if end_iso is None:
# Check if there is still an active (non-stale) open session for this subject
open_sessions = self.con.execute(
"SELECT id, start FROM events WHERE kind = 'sleep.session' AND subject = ? AND \"end\" IS NULL AND deleted_at IS NULL",
[subject],
).fetchall()
if open_sessions:
raise ValueError(
f"Subject '{subject}' already has an open sleep session ({open_sessions[0][0]} started at {open_sessions[0][1]}). Please stop it first."
)
record = {
"id": event_id,
@@ -1085,6 +1142,10 @@ def run_cli():
kind = args.kind or args.kind_flag
if not kind:
parser.error("the 'add' command requires 'kind' (positional or via --kind)")
if kind.lower() in ("true", "false", "yes", "no"):
parser.error(f"Invalid kind '{kind}'. Did you mean '--estimated' without an argument?")
if "." not in kind:
parser.error(f"Invalid kind '{kind}'. Expected domain.event (e.g. 'nutrition.intake', 'sleep.session', 'feeding.bottle').")
payload = json.loads(args.payload)
res = engine.add_event(
kind=kind,