Repository navigation
[feature] Add Mast as an alert channel - #784
Conversation
Mast (https://tissue.systems/mast) is a push app that pages a phone, on iPhone and Android. A channel key is the whole credential, so the channel needs one setting, the channel URL: [[alerts.channels]] type = "mast" channel_url = "https://mast.tissue.dev/mk_..." Severity maps onto three of Mast's four priorities: info sends quiet, warning normal, critical loud. Nothing maps to "page", which repeats until acked and ignores mute and quiet hours; you ask for it with priority= on the channel. Each Mast channel also has a ceiling, set when it's created, and a send above it is delivered at the ceiling. docs/alerts.md covers how that interacts with the mapping, since a channel created on Android with the default of Normal delivers critical alerts as normal. Each send carries a dedupe key of "<type>:<agent>", the same pair CooldownTracker keys on, so an alert that re-fires after the cooldown lapses updates the card already on the phone instead of adding another. Captured content is stripped per Critical Rule 5 unless alerts.include_captured_content is set, like the other external channels. Unlike them, a non-2xx response is logged, along with the error message Mast puts in the body, so a rotated key shows up as "No such channel." rather than a bare 404. A 5xx gets different wording because the message may have been stored before it failed, and retrying could page twice.
[Medium impact] The PR appears safe to merge; no actionable issues remain.
|
|
Thanks @ki7dk — first PR here, and the craft is well above average: the channel slots into the existing I'm not merging yet. One disclosure question, one code finding, and a couple of accuracy nits. 1. Affiliation. The commits are authored from 2. The dedupe key folds alerts that the cooldown treats as separate. The PR body says the key is "the same pair 3. The wire contract isn't publicly documented. The only posting interface I can find documented is 4. "Free, no account required." The Mast pricing section I read says the free tier is 10 channels and 10,000 messages a month and that sign-in (email, Google or GitHub) is required. 5. Small correction. On CI: nothing has run on this PR — the fork-approval gate has the workflows at One thing I want to be straight with you about: the open question I can't settle by reading code is whether we take a channel for a service this small. Discord, Telegram and ntfy carry no judgment on my part because they're ubiquitous or open; Mast would be the first integration here pointing at a service most of our users have never heard of, and an alert channel is an egress path out of a local-first tool. I'm not saying no. I'm saying that's the decision, and I'd rather make it in the open than bury it in a code review. Get me items 1 through 4 and I'll make the call. |
|
Settled the open question from my last comment: yes, let's take Mast. No change needed on your side for that, and no need to litigate the disclosure — I know Denis. Mentioning the affiliation in the PR body is still worth doing for everyone else reading the thread later, but it is a courtesy, not a condition. Two things left, and only the first is a real bug. 1. The dedupe key drops the session scope"key": f"{alert.type.value}:{alert.agent_id or 'unknown'}"[:120]Our cooldown tracker keys on three things, not two ( scope = session_id if alert_type in _SESSION_SCOPED_ALERT_TYPES else ""
key = (agent_id or "", alert_type.value, scope or "")
That matters more for us than it would for most tools: fan-out, where one agent runs many sessions at once, is the workload this product exists for. It is also the case where you most want the alert. Including 2. "Free, no account required"The PR body and the inline config comment both say this. Your published free tier asks for sign-in and caps at 10 channels and 10,000 messages a month. The prose in Everything else checks outContent stripping uses the same The 5-second timeout and no-retry design are right for this path. Alert dispatch sits behind the post-ingest hooks, where a channel that blocks or retries would be holding up ingest. CI still has not run — the fork-approval gate has the workflows at Nice first contribution. The channel-interface conformance in particular is better than most. |
|
CI has now run and is green across the matrix: lint, 3.10, 3.11, 3.12, test-ts, version-lockstep, plus the contributor guard. That retires the "unproven" caveat in my review. The dedupe-key finding stands — it is a behaviour no current test covers, which is itself worth a test when you fix it. |
The dedupe key was type:agent, cut at 120 characters. CooldownTracker also keys on the session for cost_budget_session and session_duration, so two sessions of one agent going over their own budget were two alerts here and one card on the phone. Mast folds a repeat into the existing card and keeps its text, so the second session never showed. Cutting at 120 characters also merged long agent ids with a shared prefix. The key is now the alert type plus a hash of (type, agent, scope), built by one helper that CooldownTracker uses too, so the two can't drift apart again. Docs: give both free Mast tiers and their message quotas, link Mast's page for the JSON fields, and explain when repeats fold.
|
Thanks Anil, both fixed. The key is now the alert type plus a hash of agent, type and scope, built by the same helper CooldownTracker uses now, so they can't drift apart. Two sessions of one agent get separate cards for the session-scoped types, and everything else still folds per agent and type. The key is 57 chars at most, so Greptile's truncation case goes away too. It was a bit worse than you described. Mast doesn't replace the card on a repeat, it bumps the repeat count on the existing card and keeps its text. So the second session only ever showed up as a count on the first one. Added a test that runs pairs of alerts through both the tracker and the key and checks they agree, plus tests for the session and long-id cases. docs/alerts.md now also says repeats only fold when they land inside the channel's dedupe window. The config comment and the description list both free tiers now: 2 channels and 1,000 messages a month without an account, 10 and 10,000 with a free one. Mast doesn't stop delivering at the limit, it keeps accepting sends, and the docs say that too. And yes, I run Tissue Systems. Added it to the description. I also took out the write_config note, you were right about that. The JSON fields are documented at https://tissue.systems/mast/docs/send/ and docs/alerts.md links there now. Fair point that it was hard to find from the landing page, which only showed the plain-text curl. That page has the JSON example now. |
|
Reviewed The dedupe fixI asked you to include the session scope. You extracted Verified rather than read:
I also mutation-tested the new guard: dropping The free-tier item: I was wrongI said your "free, no account required" line conflicted with a published tier requiring sign-in. Two errors in that. The line I was looking at is the ntfy example, which predates your PR and is accurate — ntfy genuinely needs no account. And your actual description is more precise than mine was: 2 channels and 1,000 messages with no account, 10 and 10,000 with one. I asserted sign-in was required. It is not. The new prose in Where it stands
Two mechanical things before merge, neither yours to fix:
Everything above is from my own machine, which is not the 3.10/3.11/3.12 matrix. Good work on this one. The shared-identity helper is the kind of change that makes the next channel easier to add correctly. |
anilmurty
left a comment
There was a problem hiding this comment.
Approving. Thanks @ki7dk — and thanks for taking the dedupe note further than I asked.
CI is green across the matrix on 523f362, the branch is current with main, and Greptile is 5/5 with its finding closed and nothing new.
Re-verified on the merged head locally rather than relying on the earlier run: 125 tests pass across the Mast, alerts, config, policy and tracked-secrets suites; ruff and mypy clean.
What I checked by hand over the two rounds, so it is on the record:
- Content stripping is load-bearing, not just present. Flipping
strip=not self._include_captured_contenttoFalseturns the test red. Nothing beyond title, detail, priority and key reaches the wire, through the same path ntfy, Discord and Telegram already use. - The identity refactor changes no behaviour. The old inline cooldown key and the new
_alert_identity()agree across all 117 combinations of agent id, alert type and session id, sois_suppressed,recordandhydrateare untouched. - The session-scope guard is load-bearing too. Dropping
alert.session_idfrom the key turns two tests red, including the parametrised one that pins Mast's key to the cooldown's own notion of identity. That test is the thing that stops the two drifting apart later. - The credential never leaks.
tj policy listprintschannel_url=<set>, error paths log only the alert id, status code and Mast's message, and nothing tracked carries a key. - The channel cannot hurt ingest. One line in the factory, no dispatcher special-casing, a 5-second timeout, no retries, and the dispatcher already wraps every send.
Over to @anilmurty to merge.
One note for whoever adds the eighth channel: _alert_identity() is now the single definition of "the same alert", and the parity test is what keeps a channel's dedupe key honest against it. Worth copying that pattern rather than hand-rolling a key.
Adds a seventh alert channel: Mast (https://tissue.systems/mast), a pager app for iPhone and Android. A channel key is the whole credential, so the config is one line. It's free: 2 channels and 1,000 messages a month with no account, 10 channels and 10,000 messages with a free Tissue account.
Disclosure: I run Tissue Systems, which operates Mast.
Summary
MastChannelincore/alerts.py, registered in_build_channelundertype = "mast". One required setting,channel_url.page(repeats until acked, ignores mute and quiet hours); an optionalpriority =on the channel pins every alert to one level, and that's the only way to getpage. A misspelt priority fails at startup, the same way a badmin_severitydoes.CooldownTrackerkeys on, where scope is the session forcost_budget_sessionandsession_duration. Both build it with one helper, so they can't drift apart. A repeat of the same alert inside the channel's dedupe window adds to the count on the card already on the phone; two sessions over their own budget get two cards.alerts.include_captured_contentis set, same as the other external channels.No such channel.rather than a bare 404. A 5xx gets different wording since the message may have been stored before the failure.tj policy listshowschannel_url=<set>and the priority, never the URL, since the URL carries the key.docs/alerts.md(setup, both free tiers and their message quotas, the JSON fields with a link to Mast's sending docs, the priority mapping,page, the per-channel ceiling and how it interacts with the mapping on Android, and when repeats fold), the config example indocs/configuration.md, channel counts indocs/architecture.mdanddocs/nemoclaw-integration.md.Tests / Verification
tests/unit/test_mast_channel.py(30 tests, httpx faked): factory registration, severity mapping, thepageoverride and the startup check, payload fields, title/body caps, content stripping, and the logging for 4xx, 5xx, non-JSON and 202. For the dedupe key: repeats share a key, different agents and different sessions don't, long agent ids with a shared prefix don't collide, the key fits Mast's 120-character limit, and a table of alert pairs checks that the key agrees withCooldownTrackeron what counts as the same alert.tests/unit/test_cmd_policy.py: the channel summary redacts the key and shows the priority.tests/unit/test_config.py: the channel parses.test_xdist_isolationones that fail the same way on main. ruff and mypy clean.AlertEngineand dispatcher: critical, warning and info alerts arrived on the phone at loud, normal and quiet; a repeat folded into the existing card ("duplicate": true); a URL with a wrong key loggedMast rejected alert … (HTTP 404: No such channel.).What's NOT in this PR