A connector watches an outside service and puts its news on the island: a failed check, an approved pull request, a review waiting for you. GitHub is the first one; adding another takes one Rust file, one line in the registry and one entry in the settings UI.
What you write#
pub trait Connector: Send + Sync {
fn id(&self) -> &'static str; // "github"
fn interval(&self, last: &Snapshot) -> Duration; // wait after a good poll, from what it saw
fn poll(&self) -> Poll<'_>; // the service's current state, as a Snapshot
fn diff(&self, before: &Snapshot, after: &Snapshot) -> Vec<Event>; // news between two states
fn board(&self, snapshot: &Snapshot) -> Option<Vec<Row>> { None } // optional: its card
}A Snapshot is a map from a stable key (pr:owner/repo#12) to whatever JSON you need to tell what
changed. You never decide when something is news against the wall clock: you compare two snapshots.
That is what makes the rest automatic.
What the runtime does for you#
- Polls only while the user has the connector switched on, and stops at once when it is switched off.
- Saves the last snapshot to disk, so a restart does not replay old news.
- On the very first poll it only records a baseline: no flood of alerts about things that were already true.
- Backs off on errors (from 4 minutes, doubling, up to 15, whatever your
interval), waits outError::RateLimited { retry_after }, and checks only every 10 minutes when the user must act (Error::Unavailable,Error::Auth). - Waits
interval(&last_snapshot)after a good poll, so a connector can poll sooner while something is running (GitHub: 60 s while checks run, else 300 s). - Polls early when the island opens and the last poll, good or failed, is more than a minute old
(
Runtime::refresh_if_stale): fresh news, or a retry after an error the user may have fixed. Never while off, already polling, or rate limited. - Sends no events from a poll that ends after the user switched the connector off; its snapshot is still saved as the baseline.
- Sends the card (
board, when the connector has one) after each good poll, after its events, and takes it away when switched off. A row’sitemis its snapshot key: when an item leaves the card, the core drops the alerts whose keys start with<item>:. - Asks the app’s network gate (ADR 0019) before each poll: only while switched on, at most 3,000 in
any 24 hours, a
requestspan per poll invults.sqlite. A refusal waits 10 minutes and shows Budget spent.Runtime::starttakes the gate; there is no way to poll around it. - Reports status (enabled, last successful poll, error, items watched, over budget) to Settings.
Steps#
crates/connectors/src/<id>.rs: implementConnector. Keeppollto one request when the service allows it, and turn its answer into aSnapshotin a pure function you can test.crates/connectors/src/lib.rs: addpub mod <id>;and the connector toall().ui/src/connectors.ts: add{ id, name, about }.aboutsays, in one sentence, what it watches and how it signs in.- Links: an event’s
urlis shown only ifcore::SafeUrlaccepts it (https, allowed host). Add the service’s host toHOSTSincrates/core/src/safe_url.rs. - Tests: feed recorded answers to your snapshot function and check
difffor each kind of news, and for “nothing changed means no news”.
Credentials#
Prefer the service’s own CLI that the user is already logged into, as GitHub does with gh: the app
never handles a token at all. When there is no such CLI, the token goes in the OS keyring (Secret
Service, Credential Manager), never in a file, and never in a log.
Event keys#
Event::key identifies the news, not just the thing: pr:owner/repo#12:ci-failed. The island
replaces an alert with the same key instead of stacking duplicates, and a new failure on another
commit (branch:owner/repo:ci-failed:<sha>) is a new alert.
Event::topic names the story a news belongs to (pr:owner/repo#12:ci): a newer event of the
same topic retires the older alerts, so checks passed does not sit next to the failure it fixed.
Leave it None for news that stands alone. Every event is news to the island, even under a key it
has shown before: a review requested again sounds again.