Compare commits
2 Commits
9c1f9f94e7
...
8b76a2dbf7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b76a2dbf7 | ||
|
|
f8ff971627 |
12
config.json
12
config.json
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"cooldowns": {
|
||||
"email": 1800,
|
||||
"calendar": 0,
|
||||
"calendar": 1800,
|
||||
"kb_index": 14400,
|
||||
"git": 14400
|
||||
}
|
||||
@@ -48,15 +48,7 @@
|
||||
"WebFetch", "WebSearch",
|
||||
"Bash(python3 *)", "Bash(.venv/bin/python3 *)",
|
||||
"Bash(pip *)", "Bash(pytest *)",
|
||||
"Bash(git add *)", "Bash(git commit *)",
|
||||
"Bash(git push)", "Bash(git push *)",
|
||||
"Bash(git pull)", "Bash(git pull *)",
|
||||
"Bash(git status)", "Bash(git status *)",
|
||||
"Bash(git diff)", "Bash(git diff *)",
|
||||
"Bash(git log)", "Bash(git log *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git branch)", "Bash(git branch *)",
|
||||
"Bash(git stash)", "Bash(git stash *)",
|
||||
"Bash(git *)",
|
||||
"Bash(npm *)", "Bash(node *)", "Bash(npx *)",
|
||||
"Bash(systemctl --user *)",
|
||||
"Bash(trash *)", "Bash(mkdir *)", "Bash(cp *)",
|
||||
|
||||
114
src/heartbeat.py
114
src/heartbeat.py
@@ -8,7 +8,7 @@ a Claude CLI session is triggered to handle them.
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, date, timezone
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -63,11 +63,11 @@ def run_heartbeat(config: dict | None = None) -> str:
|
||||
results.append(email_result)
|
||||
checks["email"] = now.isoformat()
|
||||
|
||||
# Check 2: Calendar (critical — pierces quiet hours)
|
||||
if check_flags.get("calendar"):
|
||||
cal_result = _check_calendar(state)
|
||||
# Check 2: Calendar — daily summary + next-event reminder (no quiet hours bypass)
|
||||
if check_flags.get("calendar") and not is_quiet and _should_run("calendar", checks, now, cooldowns):
|
||||
cal_result = _check_calendar_smart(state, quiet_hours)
|
||||
if cal_result:
|
||||
critical.append(cal_result)
|
||||
results.append(cal_result)
|
||||
checks["calendar"] = now.isoformat()
|
||||
|
||||
# Check 3: KB index freshness + auto-reindex
|
||||
@@ -171,14 +171,77 @@ def _check_email(state: dict) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _check_calendar(state: dict) -> str | None:
|
||||
"""Check upcoming calendar events via tools/calendar_check.py. Parses JSON."""
|
||||
def _check_calendar_smart(state: dict, quiet_hours: tuple) -> str | None:
|
||||
"""Smart calendar check: daily summary once, then only next-event reminders.
|
||||
|
||||
- First run after quiet hours: full day summary (all events today)
|
||||
- Subsequent runs: only the nearest event within 45 min, deduplicated
|
||||
"""
|
||||
script = TOOLS_DIR / "calendar_check.py"
|
||||
if not script.exists():
|
||||
return None
|
||||
|
||||
today_str = date.today().isoformat()
|
||||
cal_state = state.setdefault("calendar", {})
|
||||
sent_summary_date = cal_state.get("daily_summary_date")
|
||||
reminded_events = cal_state.get("reminded_events", {})
|
||||
|
||||
# Clean old reminded_events (from previous days)
|
||||
reminded_events = {
|
||||
k: v for k, v in reminded_events.items() if v == today_str
|
||||
}
|
||||
cal_state["reminded_events"] = reminded_events
|
||||
|
||||
is_first_run_today = sent_summary_date != today_str
|
||||
|
||||
if is_first_run_today:
|
||||
# Daily summary: all events for today
|
||||
return _calendar_daily_summary(cal_state, today_str)
|
||||
else:
|
||||
# Next-event reminder (within 45 min, deduplicated)
|
||||
return _calendar_next_reminder(cal_state, today_str, reminded_events)
|
||||
|
||||
|
||||
def _calendar_daily_summary(cal_state: dict, today_str: str) -> str | None:
|
||||
"""Fetch all today's events and return a daily summary."""
|
||||
script = TOOLS_DIR / "calendar_check.py"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python3", str(script), "soon"],
|
||||
["python3", str(script), "today"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
return None
|
||||
data = json.loads(output)
|
||||
today_events = data.get("today", [])
|
||||
if not today_events:
|
||||
cal_state["daily_summary_date"] = today_str
|
||||
return None
|
||||
|
||||
parts = []
|
||||
for event in today_events:
|
||||
time = event.get("time", "")
|
||||
name = event.get("summary", "?")
|
||||
parts.append(f" {time} — {name}")
|
||||
|
||||
cal_state["daily_summary_date"] = today_str
|
||||
return "📅 Program azi:\n" + "\n".join(parts)
|
||||
except Exception as e:
|
||||
log.warning("Calendar daily summary failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _calendar_next_reminder(cal_state: dict, today_str: str,
|
||||
reminded_events: dict) -> str | None:
|
||||
"""Remind only for the next upcoming event within 45 min, if not already reminded."""
|
||||
script = TOOLS_DIR / "calendar_check.py"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python3", str(script), "soon", "1"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
@@ -191,21 +254,28 @@ def _check_calendar(state: dict) -> str | None:
|
||||
upcoming = data.get("upcoming", [])
|
||||
if not upcoming:
|
||||
return None
|
||||
parts = []
|
||||
for event in upcoming:
|
||||
mins = event.get("minutes_until", "?")
|
||||
name = event.get("summary", "?")
|
||||
time = event.get("time", "")
|
||||
parts.append(f"in {mins} min — {name} ({time})")
|
||||
return "Calendar: " + "; ".join(parts)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback: treat as plain text
|
||||
output = result.stdout.strip()
|
||||
if output:
|
||||
return f"Calendar: {output}"
|
||||
return None
|
||||
|
||||
event = upcoming[0]
|
||||
mins = event.get("minutes_until", 999)
|
||||
name = event.get("summary", "?")
|
||||
time = event.get("time", "")
|
||||
|
||||
# Only remind if within 45 minutes
|
||||
if isinstance(mins, (int, float)) and mins > 45:
|
||||
return None
|
||||
|
||||
# Dedup key: event name + time
|
||||
dedup_key = f"{name}@{time}"
|
||||
if dedup_key in reminded_events:
|
||||
return None
|
||||
|
||||
# Mark as reminded
|
||||
reminded_events[dedup_key] = today_str
|
||||
cal_state["reminded_events"] = reminded_events
|
||||
|
||||
return f"⏰ in {mins} min — {name} ({time})"
|
||||
except Exception as e:
|
||||
log.warning("Calendar check failed: %s", e)
|
||||
log.warning("Calendar next reminder failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.heartbeat import (
|
||||
_check_calendar,
|
||||
_calendar_daily_summary,
|
||||
_calendar_next_reminder,
|
||||
_check_calendar_smart,
|
||||
_check_email,
|
||||
_check_git,
|
||||
_check_kb_index,
|
||||
@@ -207,52 +209,113 @@ class TestCheckEmail:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckCalendar:
|
||||
"""Test calendar check via tools/calendar_check.py with JSON parsing."""
|
||||
class TestCheckCalendarSmart:
|
||||
"""Test smart calendar: daily summary + next-event reminders with dedup."""
|
||||
|
||||
def test_no_script(self, tmp_env):
|
||||
assert _check_calendar({}) is None
|
||||
assert _check_calendar_smart({}, (23, 8)) is None
|
||||
|
||||
def test_json_with_upcoming(self, tmp_env):
|
||||
def test_first_run_sends_daily_summary(self, tmp_env):
|
||||
"""First run after quiet hours should return full day summary."""
|
||||
script = tmp_env["tools"] / "calendar_check.py"
|
||||
script.write_text("pass")
|
||||
output = json.dumps({
|
||||
"today": [
|
||||
{"summary": "Standup", "time": "09:00"},
|
||||
{"summary": "Lunch", "time": "12:00"},
|
||||
],
|
||||
"tomorrow": []
|
||||
})
|
||||
mock_result = MagicMock(returncode=0, stdout=output)
|
||||
state = {}
|
||||
with patch("src.heartbeat.subprocess.run", return_value=mock_result):
|
||||
result = _check_calendar_smart(state, (23, 8))
|
||||
assert "Program azi" in result
|
||||
assert "09:00 — Standup" in result
|
||||
assert "12:00 — Lunch" in result
|
||||
assert state["calendar"]["daily_summary_date"] == date.today().isoformat()
|
||||
|
||||
def test_second_run_sends_next_event_only(self, tmp_env):
|
||||
"""After daily summary, only remind for next event within 45 min."""
|
||||
script = tmp_env["tools"] / "calendar_check.py"
|
||||
script.write_text("pass")
|
||||
output = json.dumps({
|
||||
"upcoming": [
|
||||
{"summary": "NLP Session", "minutes_until": 45, "time": "15:00"},
|
||||
{"summary": "Meeting", "minutes_until": 30, "time": "10:00"},
|
||||
]
|
||||
})
|
||||
mock_result = MagicMock(returncode=0, stdout=output)
|
||||
state = {"calendar": {"daily_summary_date": date.today().isoformat(), "reminded_events": {}}}
|
||||
with patch("src.heartbeat.subprocess.run", return_value=mock_result):
|
||||
result = _check_calendar({})
|
||||
assert result == "Calendar: in 45 min — NLP Session (15:00)"
|
||||
result = _check_calendar_smart(state, (23, 8))
|
||||
assert "in 30 min — Meeting (10:00)" in result
|
||||
assert "Meeting@10:00" in state["calendar"]["reminded_events"]
|
||||
|
||||
def test_json_no_upcoming(self, tmp_env):
|
||||
def test_dedup_prevents_repeat_reminder(self, tmp_env):
|
||||
"""Same event should not be reminded twice."""
|
||||
script = tmp_env["tools"] / "calendar_check.py"
|
||||
script.write_text("pass")
|
||||
output = json.dumps({"upcoming": []})
|
||||
output = json.dumps({
|
||||
"upcoming": [
|
||||
{"summary": "Meeting", "minutes_until": 25, "time": "10:00"},
|
||||
]
|
||||
})
|
||||
mock_result = MagicMock(returncode=0, stdout=output)
|
||||
today_str = date.today().isoformat()
|
||||
state = {"calendar": {
|
||||
"daily_summary_date": today_str,
|
||||
"reminded_events": {"Meeting@10:00": today_str}
|
||||
}}
|
||||
with patch("src.heartbeat.subprocess.run", return_value=mock_result):
|
||||
assert _check_calendar({}) is None
|
||||
result = _check_calendar_smart(state, (23, 8))
|
||||
assert result is None
|
||||
|
||||
def test_plaintext_fallback(self, tmp_env):
|
||||
def test_event_beyond_45_min_not_reminded(self, tmp_env):
|
||||
"""Events more than 45 min away should not trigger a reminder."""
|
||||
script = tmp_env["tools"] / "calendar_check.py"
|
||||
script.write_text("pass")
|
||||
mock_result = MagicMock(returncode=0, stdout="Meeting at 3pm\n")
|
||||
output = json.dumps({
|
||||
"upcoming": [
|
||||
{"summary": "Later", "minutes_until": 90, "time": "14:00"},
|
||||
]
|
||||
})
|
||||
mock_result = MagicMock(returncode=0, stdout=output)
|
||||
state = {"calendar": {"daily_summary_date": date.today().isoformat(), "reminded_events": {}}}
|
||||
with patch("src.heartbeat.subprocess.run", return_value=mock_result):
|
||||
assert _check_calendar({}) == "Calendar: Meeting at 3pm"
|
||||
result = _check_calendar_smart(state, (23, 8))
|
||||
assert result is None
|
||||
|
||||
def test_empty_output(self, tmp_env):
|
||||
def test_no_events_today(self, tmp_env):
|
||||
"""No events returns None and still marks summary as sent."""
|
||||
script = tmp_env["tools"] / "calendar_check.py"
|
||||
script.write_text("pass")
|
||||
mock_result = MagicMock(returncode=0, stdout="\n")
|
||||
output = json.dumps({"today": [], "tomorrow": []})
|
||||
mock_result = MagicMock(returncode=0, stdout=output)
|
||||
state = {}
|
||||
with patch("src.heartbeat.subprocess.run", return_value=mock_result):
|
||||
assert _check_calendar({}) is None
|
||||
result = _check_calendar_smart(state, (23, 8))
|
||||
assert result is None
|
||||
assert state["calendar"]["daily_summary_date"] == date.today().isoformat()
|
||||
|
||||
def test_old_reminded_events_cleaned(self, tmp_env):
|
||||
"""Reminded events from previous days are cleaned up."""
|
||||
script = tmp_env["tools"] / "calendar_check.py"
|
||||
script.write_text("pass")
|
||||
output = json.dumps({"today": [{"summary": "X", "time": "09:00"}], "tomorrow": []})
|
||||
mock_result = MagicMock(returncode=0, stdout=output)
|
||||
state = {"calendar": {
|
||||
"daily_summary_date": "2020-01-01",
|
||||
"reminded_events": {"OldEvent@09:00": "2020-01-01"}
|
||||
}}
|
||||
with patch("src.heartbeat.subprocess.run", return_value=mock_result):
|
||||
_check_calendar_smart(state, (23, 8))
|
||||
assert "OldEvent@09:00" not in state["calendar"]["reminded_events"]
|
||||
|
||||
def test_subprocess_exception(self, tmp_env):
|
||||
script = tmp_env["tools"] / "calendar_check.py"
|
||||
script.write_text("pass")
|
||||
with patch("src.heartbeat.subprocess.run", side_effect=OSError("fail")):
|
||||
assert _check_calendar({}) is None
|
||||
assert _check_calendar_smart({}, (23, 8)) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -401,7 +464,7 @@ class TestRunHeartbeat:
|
||||
|
||||
def test_all_ok(self, tmp_env):
|
||||
with patch("src.heartbeat._check_email", return_value=None), \
|
||||
patch("src.heartbeat._check_calendar", return_value=None), \
|
||||
patch("src.heartbeat._check_calendar_smart", return_value=None), \
|
||||
patch("src.heartbeat._check_kb_index", return_value=None), \
|
||||
patch("src.heartbeat._check_git", return_value=None), \
|
||||
patch("src.heartbeat._run_claude_extra", return_value=None):
|
||||
@@ -410,7 +473,7 @@ class TestRunHeartbeat:
|
||||
|
||||
def test_with_results(self, tmp_env):
|
||||
with patch("src.heartbeat._check_email", return_value="Email: 2 necitite (X, Y)"), \
|
||||
patch("src.heartbeat._check_calendar", return_value=None), \
|
||||
patch("src.heartbeat._check_calendar_smart", return_value=None), \
|
||||
patch("src.heartbeat._check_kb_index", return_value="KB: 1 fișiere reindexate"), \
|
||||
patch("src.heartbeat._check_git", return_value=None), \
|
||||
patch("src.heartbeat._is_quiet_hour", return_value=False), \
|
||||
@@ -418,29 +481,22 @@ class TestRunHeartbeat:
|
||||
result = run_heartbeat()
|
||||
assert result == "Email: 2 necitite (X, Y) | KB: 1 fișiere reindexate"
|
||||
|
||||
def test_quiet_hours_suppresses_normal(self, tmp_env):
|
||||
def test_quiet_hours_suppresses_all(self, tmp_env):
|
||||
"""During quiet hours, calendar is also suppressed (no longer critical)."""
|
||||
with patch("src.heartbeat._check_email", return_value="Email: 5 necitite (A)"), \
|
||||
patch("src.heartbeat._check_calendar", return_value=None), \
|
||||
patch("src.heartbeat._check_calendar_smart", return_value="Calendar stuff") as mock_cal, \
|
||||
patch("src.heartbeat._check_kb_index", return_value=None), \
|
||||
patch("src.heartbeat._check_git", return_value="Git: 2 uncommitted"), \
|
||||
patch("src.heartbeat._is_quiet_hour", return_value=True):
|
||||
result = run_heartbeat()
|
||||
assert result == "HEARTBEAT_OK"
|
||||
|
||||
def test_quiet_hours_allows_critical_calendar(self, tmp_env):
|
||||
with patch("src.heartbeat._check_email", return_value="Email: 5 necitite (A)"), \
|
||||
patch("src.heartbeat._check_calendar", return_value="Calendar: in 30 min — Meeting (15:00)"), \
|
||||
patch("src.heartbeat._check_kb_index", return_value=None), \
|
||||
patch("src.heartbeat._check_git", return_value="Git: 2 uncommitted"), \
|
||||
patch("src.heartbeat._is_quiet_hour", return_value=True):
|
||||
result = run_heartbeat()
|
||||
assert result == "Calendar: in 30 min — Meeting (15:00)"
|
||||
mock_cal.assert_not_called()
|
||||
|
||||
def test_config_disables_check(self, tmp_env):
|
||||
"""Checks can be disabled via config."""
|
||||
config = {"heartbeat": {"checks": {"git": False}}}
|
||||
with patch("src.heartbeat._check_email", return_value=None), \
|
||||
patch("src.heartbeat._check_calendar", return_value=None), \
|
||||
patch("src.heartbeat._check_calendar_smart", return_value=None), \
|
||||
patch("src.heartbeat._check_kb_index", return_value=None), \
|
||||
patch("src.heartbeat._check_git", return_value="Git: 5 uncommitted") as mock_git, \
|
||||
patch("src.heartbeat._run_claude_extra", return_value=None):
|
||||
@@ -452,7 +508,7 @@ class TestRunHeartbeat:
|
||||
"""Quiet hours can be overridden via config."""
|
||||
config = {"heartbeat": {"quiet_hours": [0, 1]}} # only 0-1 is quiet
|
||||
with patch("src.heartbeat._check_email", return_value=None), \
|
||||
patch("src.heartbeat._check_calendar", return_value=None), \
|
||||
patch("src.heartbeat._check_calendar_smart", return_value=None), \
|
||||
patch("src.heartbeat._check_kb_index", return_value=None), \
|
||||
patch("src.heartbeat._check_git", return_value="Git: 3 uncommitted"), \
|
||||
patch("src.heartbeat._is_quiet_hour", return_value=False), \
|
||||
@@ -462,7 +518,7 @@ class TestRunHeartbeat:
|
||||
|
||||
def test_saves_state_after_run(self, tmp_env):
|
||||
with patch("src.heartbeat._check_email", return_value=None), \
|
||||
patch("src.heartbeat._check_calendar", return_value=None), \
|
||||
patch("src.heartbeat._check_calendar_smart", return_value=None), \
|
||||
patch("src.heartbeat._check_kb_index", return_value=None), \
|
||||
patch("src.heartbeat._check_git", return_value=None), \
|
||||
patch("src.heartbeat._run_claude_extra", return_value=None):
|
||||
@@ -473,7 +529,7 @@ class TestRunHeartbeat:
|
||||
|
||||
def test_saves_check_timestamps(self, tmp_env):
|
||||
with patch("src.heartbeat._check_email", return_value=None), \
|
||||
patch("src.heartbeat._check_calendar", return_value=None), \
|
||||
patch("src.heartbeat._check_calendar_smart", return_value=None), \
|
||||
patch("src.heartbeat._check_kb_index", return_value=None), \
|
||||
patch("src.heartbeat._check_git", return_value=None), \
|
||||
patch("src.heartbeat._run_claude_extra", return_value=None):
|
||||
|
||||
Reference in New Issue
Block a user