feat(notify): desktop notifications — the final Phase-2 item - #37
Merged
Conversation
Adds the desktop-notifier dependency (PyPI, MIT) — one cross-platform API over the native notification backends (D-Bus on Linux, Notification Center on macOS, WinRT on Windows) — for the daemon's desktop-notification feature (PROJECT.md §5.16). The package ships a py.typed marker, so no mypy ignore-missing-imports override is needed.
Add the desktop-notification boundary as the next piece of the platform abstraction (PROJECT.md §12.1), mirroring the file-open and URL-open seams: a secret-free `NotifyError`, a `@runtime_checkable` `Notifier` protocol (a `(title, message)` callable), and `default_notifier` — a pragma'd production impl over desktop-notifier's `DesktopNotifierSync`, which dispatches to the native backend (D-Bus / Notification Center / WinRT) and degrades gracefully. The heavy import is lazy so the hermetic suite never loads it, and any backend failure (no D-Bus, an unsigned macOS interpreter, …) maps to the one domain error for the best-effort caller to swallow. Re-export the three names from the package and add a `FakeNotifier` (the opener-fake mold: records `(title, message)`, optionally raises) plus the three canonical seam tests. The daemon will fire these for new high-fit matches and upcoming deadlines (PROJECT.md §4.1, §5.16).
Model the [notifications] section (PROJECT.md §5.16, §10): a
NotificationsConfig with all fields defaulted — enabled (ships disabled,
like [aggregators]), min_match_score (80), deadline_lead_hours (24),
quiet_hours ("22:00-08:00"), daily_cap (20) — nested on Config and no
longer ignored by the schema.
Add the atlas.notify package scaffolding it needs:
- emit.notify_best_effort: fire-and-forget dispatch mirroring the daemon's
emit_progress seam — no-ops on a None notifier and logs-and-swallows any
failure, so a dead backend (no D-Bus, an unsigned macOS interpreter, …)
never breaks a poll.
- window: pure quiet-hours parsing (a "HH:MM-HH:MM" window that wraps past
midnight; a malformed/empty spec disables quiet hours rather than
crashing) and a per-calendar-day key for the daily cap.
The service and state that consume these land next.
Add the pieces the daemon needs to decide *what* to notify about and *once*: - state: a NotifyState persisted as JSON under the state dir (a new notify_state_file path helper), mirroring the probe-cache idiom — a missing/corrupt file yields fresh default state (never a crash). It holds the high-water mark of the last match score notified, the per-day count for the cap, and the deadline keys already alerted. - matching.repository.list_new_high_fit: the profile's postings scored at or above min_score whose (append-only, monotonic) score id is past the high-water mark — exactly the matches new since the last notification. - tracking.repository.upcoming_deadlines: deadlines live only inside the status_history JSON, so a pure scanner over non-terminal applications collects each StatusTransition.due within [now, now + lead_hours), skipping a malformed entry rather than crashing, with a stable per-deadline key so each fires once. - service.notify_after_poll: the after-poll orchestrator — short-circuits when disabled or inside quiet hours, notifies new high-fit matches across every profile (one shared baseline so interleaved score ids are not skipped) then upcoming deadlines, posting best-effort and advancing the run-state, all bounded by the daily cap. Every boundary injected (notifier, clock, state) so the suite drives it with a FakeNotifier and a fixed clock.
Wire the notification pass into the daemon after scoring, at both entry points that run the three-poll pass: - Add run_after_poll_notifications — the daemon-facing wrapper that loads the run-state, opens its own session (so the just-committed scores are visible), runs notify_after_poll, and saves the advanced state. It short-circuits before any disk I/O when notifications are disabled (the common case) and is wholly best-effort — any internal failure is logged and swallowed, returning a no-op outcome, so a notification problem never fails the poll. - The scheduled tick (atlas daemon start's bound run()) calls it after the scoring poll. - The on-demand IPC poll (daemon.ipc._run_poll) calls it after scoring too; handle_request/_run_poll gain an injected notifier param (default the real one) so the suite drives it with a FakeNotifier. A raising notifier still yields the terminal ResultEvent, never an ErrorEvent.
Desktop notifications are the last Phase-2 item, so Phase 2 (Discovery & background) is now complete: - STATUS.md: new 'What has landed' entry, Phase-2 row → complete in the at-a-glance table, and the 'Next up' pointer moved to Phase 3 (CalDAV, IMAP email scan, Q&A drafting). - PROJECT.md §15: a checked Desktop-notifications item under Phase 2 and the IPC item's 'Remaining Phase-2 work' note dropped. - CHANGELOG.md: an Unreleased/Added entry (+ the desktop-notifier dep). - README.md: a 'Desktop notifications' section (the [notifications] config, the ships-disabled default, graceful degradation, and the macOS signed-interpreter caveat), plus the daemon section refreshed to mention aggregators, per-profile scoring, notifications, and 'atlas daemon poll'.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Adds desktop notifications (PROJECT.md §5.16) — the last unchecked Phase-2 item. The
daemon now posts native OS notifications for new high-fit matches and upcoming
deadlines even when the TUI is closed, so the user is alerted out-of-app. With this, Phase
2 (Discovery & background) is complete.
It follows the established platform-seam pattern (
atlas.platform): aNotifierProtocol, asys.platform-independent pragma'd backend overdesktop-notifier(D-Bus / Notification Center / WinRT), and a best-effort / no-op contract so a dead backend
never breaks a poll. Ships disabled by default.
Key commits
build(deps)— adddesktop-notifier(shipspy.typed, no mypy override).feat(notify)— theNotifierplatform seam (atlas.platform.notifier) +FakeNotifier.feat(notify)—[notifications]config + best-effortnotify_best_effort+ purequiet-hours/daily-cap gating.
feat(notify)— the service + JSON run-state (mirroring the probe cache) + two new queries(
list_new_high_fit,upcoming_deadlines).feat(notify)— fire notifications after scoring from both daemon poll paths (thescheduled tick and the on-demand IPC poll).
docs— record the feature; mark Phase 2 complete (STATUS.md, PROJECT.md §15, CHANGELOG,README).
Design notes
uses a persisted JSON run-state (a monotonic score-id high-water mark for matches; stable
per-deadline keys for deadlines). No Alembic migration.
application.status_historyJSON (StatusTransition.due),so
upcoming_deadlinesscans it for non-terminal applications rather than querying a column.fallback covers the unsigned case (documented in the README).
nudge, no-response follow-up, and email-scan status changes; plus actionable click callbacks.
Testing
default_notifierbody isexcluded (posts a real OS notification).
ruff format --check+ruff check+mypy --strict(incl.--platform win32) all clean;uv buildconfirms the wheel still builds.FakeNotifier,db_engine, and a fixed clock drive the flow — afresh high-fit score fires, a re-poll suppresses (high-water mark), quiet hours + daily cap
are respected, a
status_historyduebecomes a deadline notification, and a raisingnotifier is swallowed (poll/IPC still succeed).
Refs PROJECT.md §5.16, §15 (Phase 2).