fix(datastore): auto-close stale sleep sessions and validate event kind in CLI
This commit is contained in:
@@ -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,17 +501,19 @@ 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)
|
||||||
open_sessions = self.con.execute(
|
if end_iso is None:
|
||||||
"SELECT id, start FROM events WHERE kind = 'sleep.session' AND subject = ? AND \"end\" IS NULL AND deleted_at IS NULL",
|
# Check if there is still an active (non-stale) open session for this subject
|
||||||
[subject],
|
open_sessions = self.con.execute(
|
||||||
).fetchall()
|
"SELECT id, start FROM events WHERE kind = 'sleep.session' AND subject = ? AND \"end\" IS NULL AND deleted_at IS NULL",
|
||||||
if open_sessions:
|
[subject],
|
||||||
raise ValueError(
|
).fetchall()
|
||||||
f"Subject '{subject}' already has an open sleep session ({open_sessions[0][0]} started at {open_sessions[0][1]}). Please stop it first."
|
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 = {
|
record = {
|
||||||
"id": event_id,
|
"id": event_id,
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user