diff --git a/skills/datastore/scripts/datastore.py b/skills/datastore/scripts/datastore.py index b2f81e2..7362255 100755 --- a/skills/datastore/scripts/datastore.py +++ b/skills/datastore/scripts/datastore.py @@ -387,9 +387,9 @@ 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( + def _get_open_sleep_sessions(self, subject: str, exclude_id: Optional[str] = None) -> List[Dict[str, Any]]: + """Finds open sleep sessions (end IS NULL) for subject, sorted chronologically.""" + rows = self.con.execute( """ SELECT id, start, payload, note FROM events @@ -398,46 +398,17 @@ class DatastoreEngine: """, [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 + results = [] + for r in rows: + if exclude_id and r[0] == exclude_id: + continue + results.append({ + "id": r[0], + "start": str(r[1]), + "payload": json.loads(r[2]) if isinstance(r[2], str) else (r[2] or {}), + "note": r[3], + }) + return results # -------------------------------------------------------------------------- # Event Operations @@ -501,19 +472,23 @@ class DatastoreEngine: "feedback": feedback, } - # Validation and stale cleanup for sleep sessions + # Check open sleep sessions (notice on every log, never silently deckeln!) + open_warning = None 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." - ) + open_sessions = self._get_open_sleep_sessions(subject) + if open_sessions: + first_open = open_sessions[0] + o_start_dt = datetime.datetime.fromisoformat(str(first_open["start"]).replace("Z", "+00:00")).astimezone(BERLIN_TZ) + open_warning = f"Vorheriges Schlaf-Event ({first_open['id']} ab {o_start_dt.strftime('%d.%m. %H:%M Uhr')}) ist noch offen! Bitte Aufwachzeit nachreichen." + + # If adding a new open interval (end is None) + if end_iso is None: + # If existing open session is recent (< 8 hours on same calendar day) and not allow_duplicate, prevent accidental duplicate live nap + diff_hours = (start_dt - o_start_dt).total_seconds() / 3600.0 + if diff_hours < 8.0 and start_dt.date() == o_start_dt.date() and not allow_duplicate: + raise ValueError( + f"Subject '{subject}' hat bereits ein aktives Schlaf-Event ({first_open['id']} gestartet um {o_start_dt.strftime('%H:%M Uhr')}). Bitte beende es zuerst." + ) record = { "id": event_id, @@ -572,6 +547,12 @@ class DatastoreEngine: # Fetch computed feedback feedback = self._get_event_feedback(kind, subject, start_dt.date()) + if open_warning: + feedback["open_session_warning"] = open_warning + if "day_total_formatted" in feedback and open_warning not in feedback["day_total_formatted"]: + feedback["day_total_formatted"] += f"\n{open_warning}" + elif "day_total_formatted" not in feedback: + feedback["day_total_formatted"] = open_warning return { "status": "success", @@ -584,22 +565,33 @@ class DatastoreEngine: kind: str, subject: str, end_time: Optional[str] = None, + event_id: Optional[str] = None, actor: Optional[str] = None, ) -> Dict[str, Any]: """Finds the open event (end IS NULL) for kind and subject, and closes it.""" - row = self.con.execute( - """ - SELECT id, start FROM events - WHERE kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL - ORDER BY start DESC LIMIT 1 - """, - [kind, subject], - ).fetchone() + if event_id: + row = self.con.execute( + """ + SELECT id, start FROM events + WHERE id = ? AND kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL + """, + [event_id, kind, subject], + ).fetchone() + if not row: + raise ValueError(f"No open event with id='{event_id}' found for kind='{kind}' and subject='{subject}'") + else: + row = self.con.execute( + """ + SELECT id, start FROM events + WHERE kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL + ORDER BY start DESC LIMIT 1 + """, + [kind, subject], + ).fetchone() + if not row: + raise ValueError(f"No open event found for kind='{kind}' and subject='{subject}'") - if not row: - raise ValueError(f"No open event found for kind='{kind}' and subject='{subject}'") - - event_id, start_str = row[0], str(row[1]) + resolved_event_id, start_str = row[0], str(row[1]) now_dt = datetime.datetime.now(datetime.timezone.utc) if end_time: end_dt = datetime.datetime.fromisoformat(end_time) @@ -618,12 +610,12 @@ class DatastoreEngine: SET "end" = ?, updated_at = ? WHERE id = ? """, - [end_iso, now_iso, event_id], + [end_iso, now_iso, resolved_event_id], ) # Update in JSONL domain = kind.split(".")[0] - self._update_jsonl_record(domain, event_id, {"end": end_iso, "updated_at": now_iso}) + self._update_jsonl_record(domain, resolved_event_id, {"end": end_iso, "updated_at": now_iso}) # Calculate duration start_dt = datetime.datetime.fromisoformat(start_str.replace("Z", "+00:00")) @@ -631,7 +623,7 @@ class DatastoreEngine: return { "status": "success", - "event_id": event_id, + "event_id": resolved_event_id, "kind": kind, "subject": subject, "start": start_str, @@ -1117,6 +1109,7 @@ def run_cli(): p_stop.add_argument("kind", nargs="?", default=None, help="e.g. sleep.session") p_stop.add_argument("--kind", dest="kind_flag", help="e.g. sleep.session") p_stop.add_argument("--subject", required=True, help="mila, moritz") + p_stop.add_argument("--id", dest="event_id", help="Specific event ID to close") p_stop.add_argument("--end", help="ISO end time (defaults to now)") # Query @@ -1166,7 +1159,7 @@ def run_cli(): kind = args.kind or args.kind_flag if not kind: parser.error("the 'stop' command requires 'kind' (positional or via --kind)") - res = engine.stop_event(kind=kind, subject=args.subject, end_time=args.end) + res = engine.stop_event(kind=kind, subject=args.subject, end_time=args.end, event_id=getattr(args, "event_id", None)) print(json.dumps(res, indent=2, ensure_ascii=False)) elif args.command == "query": res = engine.query(args.sql) diff --git a/skills/datastore/tests/test_datastore.py b/skills/datastore/tests/test_datastore.py index 2ef1e09..7103bcd 100644 --- a/skills/datastore/tests/test_datastore.py +++ b/skills/datastore/tests/test_datastore.py @@ -145,7 +145,7 @@ def test_baby_sleep_open_and_close(temp_datastore: DatastoreEngine): assert summary["open_sleep"]["sleep_type"] == "nap" # 2. Prevent overlapping second open sleep - with pytest.raises(ValueError, match="already has an open sleep session"): + with pytest.raises(ValueError, match="hat bereits ein aktives Schlaf-Event"): temp_datastore.add_event( kind="sleep.session", subject="mila", @@ -573,8 +573,8 @@ 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.""" +def test_open_session_warning_without_silent_deckeln(temp_datastore: DatastoreEngine): + """Verifies that an unclosed sleep session is NOT silently deckelt, but alerts on every new log.""" # 1. Open night sleep session yesterday at 19:15 res_night = temp_datastore.add_event( kind="sleep.session", @@ -586,54 +586,65 @@ def test_auto_close_stale_sleep_session(temp_datastore: DatastoreEngine): 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 + # 2. Add midday nap today (12:35 to 12:55) res_nap = temp_datastore.add_event( kind="sleep.session", subject="mila", actor="agent:hebamme", start="2026-10-09T12:35:00+02:00", + end="2026-10-09T12:55: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}'") + # 3. Verify night session is STILL open (never silently deckelt!) + rows = temp_datastore.query(f"SELECT id, start, \"end\", estimated 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"]) + assert rows[0]["end"] is None + assert rows[0]["estimated"] is False + + # 4. Verify that feedback gives prominent notice about the open session + feedback = res_nap["feedback"] + assert "open_session_warning" in feedback + assert "ist noch offen" in feedback["open_session_warning"] + assert "ist noch offen" in feedback["day_total_formatted"] -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( +def test_stop_event_by_id(temp_datastore: DatastoreEngine): + """Verifies closing a specific open sleep session by ID when multiple might exist.""" + res1 = 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"}, + payload={"type": "night"}, ) - night_id = res_night["event"]["id"] + id1 = res1["event"]["id"] - # Add bounded nap from 15:20 to 15:35 - res_nap = temp_datastore.add_event( + res2 = 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"}, + start="2026-10-09T12:35:00+02:00", + payload={"type": "nap"}, ) - assert res_nap["status"] == "success" + id2 = res2["event"]["id"] - # 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 + # Close specific event id1 + res_stop = temp_datastore.stop_event( + kind="sleep.session", + subject="mila", + event_id=id1, + end_time="2026-10-09T07:15:00+02:00", + ) + assert res_stop["status"] == "success" + assert res_stop["event_id"] == id1 + + # Verify id1 is closed and id2 is still open + row1 = temp_datastore.query(f"SELECT \"end\" FROM events WHERE id = '{id1}'")[0] + row2 = temp_datastore.query(f"SELECT \"end\" FROM events WHERE id = '{id2}'")[0] + assert row1["end"] is not None + assert row2["end"] is None def test_cli_rejects_invalid_kind(temp_datastore: DatastoreEngine):