diff --git a/AGENTS.md b/AGENTS.md index 7487f9bf..80dc74c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,10 @@ Swarm-level: - **`swarm-claude-login/`** — runs `claude auth login` for one agent and stores the credentials at `swarm/agents//claude-credentials`. Design: [`docs/swarm/claude-login.md`](docs/swarm/claude-login.md). +- **`swarm-notify-router/`** — forge events from swarm-controller's + webhooks to todos on the agents they concern, through each agent's own + consumer on the `ROUTER_TODOS` stream. Design: + [`docs/swarm/notify-router.md`](docs/swarm/notify-router.md). - **`swarm-review-classifier/`** — one `claude --print` per PR classification, no tools, no forge credential. Design: [`docs/swarm/review-classifier.md`](docs/swarm/review-classifier.md). diff --git a/Cargo.lock b/Cargo.lock index b293e53f..2a6829f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5028,6 +5028,23 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "swarm-notify-router" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-nats", + "futures-util", + "hive-log", + "serde", + "serde_json", + "swarm-queue-client", + "swarm-secret-client", + "swarm-wire", + "tokio", + "tracing", +] + [[package]] name = "swarm-queue-client" version = "0.1.0" @@ -5035,12 +5052,14 @@ dependencies = [ "async-nats", "axum", "data-encoding", + "futures-util", "hive-types", "reqwest", "serde", "serde_json", "swarm-secret-client", "swarm-wire", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index a1f718b8..30ab2a15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ members = [ "swarm-matrix-client", "swarm-matrix-ctl", "swarm-nats-auth", + "swarm-notify-router", "swarm-queue-client", "swarm-review-classifier", "swarm-logs", diff --git a/docs/README.md b/docs/README.md index 6fc5894d..a5e118bc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -98,6 +98,8 @@ declarations. - **How do I log an agent in to claude from the swarm UI, and why doesn't a stale stored copy log it out again?** → [`swarm/claude-login.md`](swarm/claude-login.md). +- **How does an agent get a todo when someone assigns it an issue, and lose it + when the issue closes?** → [`swarm/notify-router.md`](swarm/notify-router.md). ## Scheduler, CI, observability diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 6f09cc4f..99ab8e5e 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -31,6 +31,9 @@ grafana, the controller, authelia, …) → - **review classifier** — planned, not implemented yet: checks a PR against its repo's own yes/no rules and posts the result as a review. → [`review-classifier.md`](review-classifier.md) +- **notification router** — forge events turned into todos on the agents + they concern: assigned, unassigned, cleared on close. → + [`notify-router.md`](notify-router.md) - **swarm UI** — the operator's day-to-day surface, on the swarm apex, `admins` only. → [`ui.md`](ui.md) - **shared services** — one forge, homeserver, SSO, queue and metrics/logs @@ -412,12 +415,18 @@ itself, all under `https:///webhook/forge/`: - an instance-wide `push` system hook, for commit metrics - an instance-wide `pull_request_review_request` system hook, which starts the [review classifier](review-classifier.md#trigger-and-lifecycle) +- instance-wide `issues` + `issue_assign` and `pull_request` + + `pull_request_assign` system hooks, whose deliveries it + keeps in the `SWARM_EVENTS` stream for the + [notification router](notify-router.md#the-path) The controller **interprets** a delivery and sends hives a specific message — _the knowledge repo changed_, _deploy agent `foo` at rev `abc123`_ — rather than forwarding forge payloads for each hive to re-derive. Approval happens once, at the swarm level: a hive receives a decision, not an event to adjudicate. +The `issues` and `pull_request` deliveries are the exception: no hive gets +them, and the notification router, not the controller, decides what they mean. A `config-pr` delivery reporting a PR merged into `main` queues a deploy of its `merge_commit_sha` on the one hive whose wanted state places the diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 419f61d5..4368ccdc 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -55,34 +55,34 @@ of the cell says how. -| store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull | -| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `swarm/agents//matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again | -| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | ✅ a secondary that didn't come up at start, or whose token the homeserver rejected, is read from the store again two minutes later, then at doubling waits up to sixteen minutes, without restarting the daemon or its other accounts; one that's up and then rejected ends its sync loop, the daemon exits and its timer restarts it | -| `swarm/agents//matrix-recovery/` | `swarm-controller`, a random passphrase for every account listed under the agent's `matrix/`, in a five-minute pass | the agent container itself, under the certificate its hive passed in, when `hive-matrix-daemon` brings the account up | ❌ never replaced: the account's server-side key backup opens only with the passphrase it was enabled with, so the pass writes only to an empty path | ❌ read when the account comes up; an account whose passphrase lands later turns backup on at the next daemon start | -| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass | -| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, at start and on refusal | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ✅ read at start and held in memory; read again when authelia refuses it, at most once per backoff of 5 s doubling to 5 min while the store holds the refused value | -| `swarm/controller/swarm-controller/forge/token` | the forge's `forgejo-swarm-controller-account`, in the `hive-forge` container, once, under the forge publisher's certificate with a write-only grant; it then revokes its earlier tokens | `swarm-controller`, under its own certificate, at start and on refusal | ❌ the forge mints it once and keeps only the token's name in the container, at `/var/lib/forgejo/swarm-controller-token-name`; deleting that file mints a new token on the unit's next start | ✅ the controller reads it at start, and in the background with backoff until it first finds it. When the forge answers 401 it reads it again and sends the request once more with the new token, at most once per backoff of 5 s doubling to 5 min while the store holds the refused value | -| `swarm/controller/swarm-controller/grafana/token` | `swarm-bao-grafana-controller-token` on Grafana's host, under Grafana's certificate: a token for the `swarm-controller` Viewer service account, minted with Grafana's admin password | `swarm-controller`, under its own certificate, on the first `/api/alerts` request and again when Grafana refuses it | ✅ a timer starts the unit again 10 minutes after each run; it keeps a stored token Grafana accepts, and mints a new one when Grafana answers 401 or the store holds none | ✅ the controller holds the token in memory; when Grafana answers 401 it reads the store once more and retries the request | -| `swarm/agents//bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config; then `hive-agent-bao-identity` in the agent container, under the leaf it holds | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ✅ `hive-agent-bao-identity` re-reads it every 10 minutes and swaps a new leaf into `/run/hive-agent-bao-identity/`, which every store login in the container presents. It reads under the leaf it holds, so it renews only while that leaf is valid | -| `swarm/agents//queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive; `swarm-logs` and `swarm-metrics` in the agent container, under the same certificate, on every query, as the bearer they present at the log and metrics stores; `swarm-nats-auth` and `swarm-logs-auth`, each under its own certificate, and `swarm-metrics-agent-auth` under `swarm-metrics-auth`'s, to check a presented secret | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with for at most an hour, until the server closes it at its user JWT's expiry; after a revocation the agent keeps retrying under the queue client's backoff. `swarm-logs` and `swarm-metrics` read it on every query and once more after a 401. `swarm-logs-auth` keeps granting a secret it verified for `deploy.victorialogs.authVerdictTtl` (60 s by default) without reading the path, so a replaced or deleted secret still works at the log store for up to that long; `swarm-metrics-agent-auth` likewise at the metrics store, for `deploy.victoriametrics.agentAuthVerdictTtl` | -| `swarm/agents//forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer | -| `swarm/agents//forge/