Repository navigation
feat(kanban): board-wide auto-subscribe via kanban.default_notify #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,6 +55,51 @@ def _resolve_auto_decompose_settings( | |
| return enabled, per_tick | ||
|
|
||
|
|
||
| def _resolve_default_notify_targets( | ||
| load_config: Callable[[], Any], | ||
| ) -> "list[dict]": | ||
| """Resolve the live board-wide ``kanban.default_notify`` target list. | ||
|
|
||
| Each entry fans every ticket's terminal events out to a chat with zero | ||
| manual ``notify-subscribe``. Shape in ``config.yaml``:: | ||
|
|
||
| kanban: | ||
| default_notify: | ||
| - platform: signal | ||
| chat_id: "group:..." | ||
| thread_id: "" # optional | ||
|
|
||
| Read fresh on every notifier tick (like ``_resolve_auto_decompose_settings``) | ||
| so adding/removing a target takes effect on the next tick instead of | ||
| requiring a gateway restart. Fails **safe**: any read/parse error returns | ||
| ``[]`` (no auto-subscribe) rather than raising out of the notifier loop. | ||
| Entries missing ``platform`` or ``chat_id`` are dropped. ``thread_id`` | ||
| defaults to ``""`` to match the subscription primary key. | ||
| """ | ||
| try: | ||
| cfg = load_config() | ||
| except Exception: | ||
| return [] | ||
| kcfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} | ||
| raw = kcfg.get("default_notify") or [] | ||
| if not isinstance(raw, (list, tuple)): | ||
| return [] | ||
| targets: list[dict] = [] | ||
| for entry in raw: | ||
| if not isinstance(entry, dict): | ||
| continue | ||
| platform = str(entry.get("platform") or "").strip().lower() | ||
| chat_id = str(entry.get("chat_id") or "").strip() | ||
| if not platform or not chat_id: | ||
| continue | ||
| targets.append({ | ||
| "platform": platform, | ||
| "chat_id": chat_id, | ||
| "thread_id": str(entry.get("thread_id") or "").strip(), | ||
| }) | ||
| return targets | ||
|
|
||
|
|
||
| def _acquire_singleton_lock(lock_path) -> "tuple[Optional[object], str]": | ||
| """Take an exclusive, non-blocking advisory lock for the sole dispatcher. | ||
|
|
||
|
|
@@ -192,6 +237,11 @@ async def _kanban_notifier_watcher(self, interval: float = 5.0) -> None: | |
|
|
||
| while self._running: | ||
| try: | ||
| # Resolve board-wide auto-subscribe targets fresh each tick so | ||
| # adding/removing a `kanban.default_notify` entry takes effect | ||
| # on the next tick (no gateway restart). Fails safe to []. | ||
| default_notify_targets = _resolve_default_notify_targets(_load_config) | ||
|
|
||
| def _collect(): | ||
| deliveries: list[dict] = [] | ||
| active_platforms = { | ||
|
|
@@ -244,6 +294,52 @@ def _collect(): | |
| # a legacy DB. `_add_column_if_missing` now | ||
| # tolerates that race, but we still skip the | ||
| # redundant call to avoid the wasted work. | ||
| # | ||
| # Board-wide auto-subscribe: before reading the | ||
| # subscription table, ensure every config-listed | ||
| # `kanban.default_notify` target is subscribed to | ||
| # every active (non-final) task on this board. This | ||
| # is what makes terminal events fan out to e.g. the | ||
| # "Kanban Workers" Signal group with zero manual | ||
| # `notify-subscribe`. `add_notify_sub` is | ||
| # INSERT-OR-IGNORE on the (task, platform, chat, | ||
| # thread) PK, so this is idempotent and never | ||
| # disturbs an existing per-task subscription (the | ||
| # cursor / fail-count state is keyed the same way). | ||
| # We only auto-subscribe targets whose platform | ||
| # adapter is currently connected, mirroring the | ||
| # delivery gate below. | ||
| if default_notify_targets: | ||
| try: | ||
| active_tasks = _kb.list_tasks(conn) | ||
| except Exception as exc: | ||
| active_tasks = [] | ||
| logger.debug( | ||
| "kanban notifier: cannot list tasks on board %s for default-notify: %s", | ||
| slug, exc, | ||
| ) | ||
| FINAL_STATUSES = {"done", "archived"} | ||
| for t in active_tasks: | ||
| if getattr(t, "status", None) in FINAL_STATUSES: | ||
| continue | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a task is created and completes before the notifier's next poll (the watcher has an initial 5s delay and then polls on the interval), this final-status guard prevents Useful? React with 👍 / 👎. |
||
| for tgt in default_notify_targets: | ||
| if tgt["platform"] not in active_platforms: | ||
| continue | ||
| try: | ||
| _kb.add_notify_sub( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This only ever Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a default target whose chat is invalid or whose adapter keeps failing while the task stays non-final (for example Useful? React with 👍 / 👎. |
||
| conn, | ||
| task_id=t.id, | ||
| platform=tgt["platform"], | ||
| chat_id=tgt["chat_id"], | ||
| thread_id=tgt["thread_id"], | ||
| notifier_profile=notifier_profile, | ||
| ) | ||
| except Exception as exc: | ||
| logger.debug( | ||
| "kanban notifier: default-notify subscribe failed for %s on %s: %s", | ||
| t.id, tgt["platform"], exc, | ||
| ) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Executing Instead, we can perform a single bulk if default_notify_targets:
now = int(time.time())
for tgt in default_notify_targets:
if tgt["platform"] not in active_platforms:
continue
try:
with _kb.write_txn(conn):
conn.execute(
"""
INSERT OR IGNORE INTO kanban_notify_subs (
task_id, platform, chat_id, thread_id, notifier_profile, created_at
)
SELECT id, ?, ?, ?, ?, ?
FROM tasks
WHERE status NOT IN ('done', 'archived')
""",
(
tgt["platform"],
tgt["chat_id"],
tgt["thread_id"],
notifier_profile,
now,
),
)
except Exception as exc:
logger.debug(
"kanban notifier: default-notify bulk subscribe failed for %s on %s: %s",
tgt["chat_id"], tgt["platform"], exc,
) |
||
|
|
||
| subs = _kb.list_notify_subs(conn) | ||
| if not subs: | ||
| logger.debug("kanban notifier: board %s has no subscriptions", slug) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a live config edit leaves
kanbanas a non-dict value (for examplekanban: falseor a YAML list), this.get()raises before_collect()runs, and the outer tick handler skips all existing per-task notification delivery every interval. Since this resolver is meant to fail safe to[], guard thatkcfgis a dict so malformeddefault_notifyconfig cannot break unrelated kanban subscriptions.Useful? React with 👍 / 👎.