diff --git a/skills/datastore/scripts/datastore.py b/skills/datastore/scripts/datastore.py index 6fb4a83..b2f81e2 100755 --- a/skills/datastore/scripts/datastore.py +++ b/skills/datastore/scripts/datastore.py @@ -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, diff --git a/skills/datastore/tests/test_datastore.py b/skills/datastore/tests/test_datastore.py index 7d39730..2ef1e09 100644 --- a/skills/datastore/tests/test_datastore.py +++ b/skills/datastore/tests/test_datastore.py @@ -573,4 +573,96 @@ def test_cli_kind_flag_and_positional(temp_datastore: DatastoreEngine): 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 + + +