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
+64 -3
View File
@@ -23,12 +23,15 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import logging
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import duckdb import duckdb
logger = logging.getLogger("datastore")
def generate_ulid() -> str: def generate_ulid() -> str:
try: try:
from ulid import ULID from ulid import ULID
@@ -384,6 +387,58 @@ class DatastoreEngine:
} }
return None 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 # Event Operations
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -446,9 +501,11 @@ class DatastoreEngine:
"feedback": feedback, "feedback": feedback,
} }
# Validation for open intervals in sleep # Validation and stale cleanup for sleep sessions
if kind == "sleep.session" and end_iso is None: if kind == "sleep.session":
# Check if there is already an open session for this subject 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( 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", "SELECT id, start FROM events WHERE kind = 'sleep.session' AND subject = ? AND \"end\" IS NULL AND deleted_at IS NULL",
[subject], [subject],
@@ -1085,6 +1142,10 @@ def run_cli():
kind = args.kind or args.kind_flag kind = args.kind or args.kind_flag
if not kind: if not kind:
parser.error("the 'add' command requires 'kind' (positional or via --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) payload = json.loads(args.payload)
res = engine.add_event( res = engine.add_event(
kind=kind, kind=kind,
+92
View File
@@ -573,4 +573,96 @@ def test_cli_kind_flag_and_positional(temp_datastore: DatastoreEngine):
assert stop_data["duration_minutes"] == 60 assert stop_data["duration_minutes"] == 60
def test_auto_close_stale_sleep_session(temp_datastore: DatastoreEngine):
"""Verifies that an open sleep session from yesterday is auto-closed when a new sleep session is logged."""
# 1. Open night sleep session yesterday at 19:15
res_night = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-08T19:15:00+02:00",
payload={"type": "night", "location": "bett"},
)
assert res_night["status"] == "success"
night_id = res_night["event"]["id"]
# 2. Add midday nap today at 12:35 without closing the night session first
res_nap = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-09T12:35:00+02:00",
payload={"type": "nap", "location": "bett"},
)
assert res_nap["status"] == "success"
nap_id = res_nap["event"]["id"]
assert nap_id != night_id
# 3. Query the night session to verify it was auto-closed with estimated=True and end=07:00
rows = temp_datastore.query(f"SELECT id, start, \"end\", estimated, note FROM events WHERE id = '{night_id}'")
assert len(rows) == 1
night_row = rows[0]
assert night_row["end"] is not None
assert "07:00:00" in str(night_row["end"])
assert night_row["estimated"] is True
assert "Auto-beendet" in str(night_row["note"])
def test_auto_close_stale_sleep_session_on_bounded_nap(temp_datastore: DatastoreEngine):
"""Verifies that logging a bounded interval today auto-closes a forgotten night session from yesterday."""
res_night = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-08T19:15:00+02:00",
payload={"type": "night", "location": "bett"},
)
night_id = res_night["event"]["id"]
# Add bounded nap from 15:20 to 15:35
res_nap = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-09T15:20:00+02:00",
end="2026-10-09T15:35:00+02:00",
payload={"type": "nap", "location": "bett"},
)
assert res_nap["status"] == "success"
# Verify night session was closed
rows = temp_datastore.query(f"SELECT id, \"end\", estimated FROM events WHERE id = '{night_id}'")
assert rows[0]["end"] is not None
assert rows[0]["estimated"] is True
def test_cli_rejects_invalid_kind(temp_datastore: DatastoreEngine):
"""Verifies CLI rejects boolean words or strings without dots as kind."""
import subprocess
import sys
from pathlib import Path
script_path = Path(__file__).resolve().parent.parent / "scripts" / "datastore.py"
data_dir = str(temp_datastore.data_dir)
proc = subprocess.run(
[
sys.executable,
str(script_path),
"add",
"true",
"--subject",
"moritz",
"--payload",
'{"item": "Snack", "qty": 100}',
],
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
capture_output=True,
text=True,
)
assert proc.returncode != 0
assert "Invalid kind 'true'" in proc.stderr