treefmt-check
checks.x86_64-linux.treefmt
· build #252
· raw
1tribuchet: building on jamie2treefmt v2.5.0traversed 295 files3emitted 246 files for processing4formatted 246 files (1 changed) in 8.371s5 M PLAN-radicle.md6diff --git a/PLAN-radicle.md b/PLAN-radicle.md7index 7af7117..2b1ad28 1006448--- a/PLAN-radicle.md9+++ b/PLAN-radicle.md10@@ -2,200 +2,189 @@11 12 Add Radicle as a fourth forge next to GitHub, Gitea and GitLab.13 14-Radicle has no HTTP API, no webhooks and no commit-status endpoint.15-Everything runs against a local `radicle-node` instead:16+Radicle has no HTTP API, no webhooks and no commit-status endpoint. Everything17+runs against a local `radicle-node` instead:18 19 - **Change source**: subscribe to the node's event stream.20 - **Fetching**: repos already sit on disk in the node's storage.21-- **Reporting**: write "job" collaborative objects (COBs) that gossip22- through the network.23+- **Reporting**: write "job" collaborative objects (COBs) that gossip through24+ the network.25 26-radicle-desktop renders job COBs in its commit view, patch review and27-source header, so users see a ✓/✗ per commit linking to nixbot's web28-frontend.29+radicle-desktop renders job COBs in its commit view, patch review and source30+header, so users see a ✓/✗ per commit linking to nixbot's web frontend.31 32 ## Why not a ci-broker adapter?33 34-Radicle's CI broker (`radicle-ci-broker`) handles triggering, filtering35-and COB writing, and dispatches to adapters — nixbot could be one. But36-its one-shot "run adapter, get verdict" model cannot express37-per-attribute builds, rerun-from-UI or effects, and it would stack a38-second queue and state machine in front of nixbot's scheduler. nixbot39-talks to the node directly; operators who want the broker's report40-pages can run it separately.41+Radicle's CI broker (`radicle-ci-broker`) handles triggering, filtering and COB42+writing, and dispatches to adapters — nixbot could be one. But its one-shot "run43+adapter, get verdict" model cannot express per-attribute builds, rerun-from-UI44+or effects, and it would stack a second queue and state machine in front of45+nixbot's scheduler. nixbot talks to the node directly; operators who want the46+broker's report pages can run it separately.47 48 ## 1. Tests first (red before green)49 50 Write these tests first; §2–§6 make them pass.51 52 - **Harness**: a pytest fixture building a real radicle setup in a temp53- `RAD_HOME` (`rad auth --stdin`, `rad init`, `radicle-node`), like the54- nixpkgs radicle tests. All fixtures come from it, no hand-written55- JSON. Adds `radicle-node` and `radicle-job` to the dev shell and56- flake test inputs; tests skip when the binaries are missing.57-- **Unit, events** (§2): event JSON → change mapping; patch-COB58- revision extraction (head, base, author DID) from a real patch.59-- **Unit, poster** (§4): status transitions → expected `rad-job`60- invocations; `StatusState` mapping.61-- **Idempotency** (§4): re-posting a terminal state after a nixbot62- restart must not duplicate runs.63-- **Integration** (§2–§5): spawn `radicle-node` in a temp `RAD_HOME`,64- `rad init` a flake repo, open a patch; assert nixbot builds it,65- `rad-job list` shows the runs, the central clone borrows objects via66- alternates (near-empty objects dir), and clone corruption after a67- storage prune recovers via re-clone.68-- **NixOS VM test** (§6): three nodes (alice, a seed, nixbot), modeled69- on `nixos/tests/radicle-ci-broker.nix`.70+ `RAD_HOME` (`rad auth --stdin`, `rad init`, `radicle-node`), like the nixpkgs71+ radicle tests. All fixtures come from it, no hand-written JSON. Adds72+ `radicle-node` and `radicle-job` to the dev shell and flake test inputs; tests73+ skip when the binaries are missing.74+- **Unit, events** (§2): event JSON → change mapping; patch-COB revision75+ extraction (head, base, author DID) from a real patch.76+- **Unit, poster** (§4): status transitions → expected `rad-job` invocations;77+ `StatusState` mapping.78+- **Idempotency** (§4): re-posting a terminal state after a nixbot restart must79+ not duplicate runs.80+- **Integration** (§2–§5): spawn `radicle-node` in a temp `RAD_HOME`, `rad init`81+ a flake repo, open a patch; assert nixbot builds it, `rad-job list` shows the82+ runs, the central clone borrows objects via alternates (near-empty objects83+ dir), and clone corruption after a storage prune recovers via re-clone.84+- **NixOS VM test** (§6): three nodes (alice, a seed, nixbot), modeled on85+ `nixos/tests/radicle-ci-broker.nix`.86 87 ## 2. Change source: the node event stream88 89-New module `nixbot/nixbot/forge/radicle.py`, enabled by a `radicle`90-config section (`RadicleConfig`):91+New module `nixbot/nixbot/forge/radicle.py`, enabled by a `radicle` config92+section (`RadicleConfig`):93 94-| option | default | meaning |95-|-----------------|--------------|------------------------------------------|96-| `radicle_home` | `~/.radicle` | control socket, storage, keys |97-| `rid_allowlist` | unset | repos to build (default: all seeded) |98-| `follow_nodes` | unset | NIDs whose announced repos are auto-seeded and enabled |99+| option | default | meaning |100+| --------------- | ------------ | ---------------------------------------------------------- |101+| `radicle_home` | `~/.radicle` | control socket, storage, keys |102+| `rid_allowlist` | unset | repos to build (default: all seeded) |103+| `follow_nodes` | unset | NIDs whose announced repos are auto-seeded and enabled |104 | `patch_trust` | `delegates` | whose patches to build: `delegates` \| `followed` \| `all` |105 106 **Subscribing.** Connect to `node/control.sock` and send the JSON line107-`{"command": "subscribe"}`; the node streams events as JSON lines (the108-stream `rad node events` prints). Subscriptions have no server-side109-expiry — the stream ends only on socket EOF or error; then reconnect110-with backoff.111+`{"command": "subscribe"}`; the node streams events as JSON lines (the stream112+`rad node events` prints). Subscriptions have no server-side expiry — the stream113+ends only on socket EOF or error; then reconnect with backoff.114 115 **Catching up.** Events missed while disconnected are gone. On every116-(re)connect, compare sigrefs of all seeded repos against known heads117-and process whatever moved. For patches, build only the latest revision118-per patch; revisions superseded while offline are skipped, like119-force-pushed-away commits on other forges. Patch close/merge does not120-cancel in-flight builds.121+(re)connect, compare sigrefs of all seeded repos against known heads and process122+whatever moved. For patches, build only the latest revision per patch; revisions123+superseded while offline are skipped, like force-pushed-away commits on other124+forges. Patch close/merge does not cancel in-flight builds.125 126 **Events consumed:**127 128-- `RefsFetched` — refs arrived from a peer. Its `updated` list uses129- namespaced names (`refs/namespaces/<nid>/refs/...`):130- - delegate namespace, `refs/heads/<default>` moved → default-branch131- build (`head_changed`);132- - `refs/cobs/xyz.radicle.patch/*` moved (any namespace) → reload the133- patch COB and build its latest revision. The revision records head134- and base, so the change event carries `commit_sha=<head>`,135- `base_sha=<base>`, `branch=<default>`, `pr_author=<author DID>` and136- the existing merge-head-into-base pipeline works unchanged.137-- `LocalRefsAnnounced` — a local push; carries only a sigrefs pointer,138- so run the catch-up for that repo.139-- `SeedDiscovered` / `InventoryAnnounced` — repo lists from peers. When140- the announcing NID is in `follow_nodes`, seed the RID (`rad seed`,141- scope `followed`) and register the project. Radicle has no "seed142- repos of followed users" policy (following only scopes ref fetching;143- a node-wide allow policy would seed the whole network), so this144- subscription lives in nixbot.145-146-**Discovery.** `rad ls` has no JSON output, so enumerate the147-`storage/<rid>` directories and read each identity payload with148-`rad inspect --payload <rid>` (name, description, default branch,149-visibility) into `DiscoveredRepo`: `forge="radicle"`,150-`forge_repo_id=<RID>`, `clone_url=<storage path>`. A private RID151-becomes a private project under the existing `visibility.py` rules.152+- `RefsFetched` — refs arrived from a peer. Its `updated` list uses namespaced153+ names (`refs/namespaces/<nid>/refs/...`):154+ - delegate namespace, `refs/heads/<default>` moved → default-branch build155+ (`head_changed`);156+ - `refs/cobs/xyz.radicle.patch/*` moved (any namespace) → reload the patch COB157+ and build its latest revision. The revision records head and base, so the158+ change event carries `commit_sha=<head>`, `base_sha=<base>`,159+ `branch=<default>`, `pr_author=<author DID>` and the existing160+ merge-head-into-base pipeline works unchanged.161+- `LocalRefsAnnounced` — a local push; carries only a sigrefs pointer, so run162+ the catch-up for that repo.163+- `SeedDiscovered` / `InventoryAnnounced` — repo lists from peers. When the164+ announcing NID is in `follow_nodes`, seed the RID (`rad seed`, scope165+ `followed`) and register the project. Radicle has no "seed repos of followed166+ users" policy (following only scopes ref fetching; a node-wide allow policy167+ would seed the whole network), so this subscription lives in nixbot.168+169+**Discovery.** `rad ls` has no JSON output, so enumerate the `storage/<rid>`170+directories and read each identity payload with `rad inspect --payload <rid>`171+(name, description, default branch, visibility) into `DiscoveredRepo`:172+`forge="radicle"`, `forge_repo_id=<RID>`, `clone_url=<storage path>`. A private173+RID becomes a private project under the existing `visibility.py` rules.174 175 ## 3. Fetching176 177 Nothing to authenticate: the clone URL is the local bare repo under178 `storage/<rid>`; the credentials provider returns empty credentials and179-`git fetch` uses the path. The default branch comes from the identity180-payload, not HEAD guessing.181+`git fetch` uses the path. The default branch comes from the identity payload,182+not HEAD guessing.183 184 **Storage sharing via alternates.** Create the central clone with185-`git clone --bare --reference storage/<rid>`: it borrows objects from186-the node's storage, so fetches only update refs and the clone stays187-near-empty. Build merge commits land in nixbot's clone; the node's188-storage is never written to, and worktrees work unchanged. If the node189-prunes borrowed objects the clone corrupts — covered by gitrepo.py's190-existing delete-and-re-clone recovery. Do not point worktrees at191-`storage/<rid>` directly: merges and fetch locks would fight the node.192+`git clone --bare --reference storage/<rid>`: it borrows objects from the node's193+storage, so fetches only update refs and the clone stays near-empty. Build merge194+commits land in nixbot's clone; the node's storage is never written to, and195+worktrees work unchanged. If the node prunes borrowed objects the clone corrupts196+— covered by gitrepo.py's existing delete-and-re-clone recovery. Do not point197+worktrees at `storage/<rid>` directly: merges and fetch locks would fight the198+node.199 200 ## 4. Reporting: job COBs201 202-`RadicleStatusPoster` implements the existing `CommitStatusPoster`203-protocol:204+`RadicleStatusPoster` implements the existing `CommitStatusPoster` protocol:205 206-- **pending** → create (or find) the job COB for the commit, attach a207- `Started` run with the `log` URL = nixbot's build page;208+- **pending** → create (or find) the job COB for the commit, attach a `Started`209+ run with the `log` URL = nixbot's build page;210 - **success** → mark the run `Succeeded`;211-- **failure / error** → mark it `Failed` (`StatusState` has no212- cancelled member; cancellation arrives as error);213+- **failure / error** → mark it `Failed` (`StatusState` has no cancelled member;214+ cancellation arrives as error);215 - announce after each write so seeds pick it up.216 217-COB writes shell out to the `rad-job` CLI (`new`, `run`, `succeeded`,218-`failed`, `list`), which signs with the profile key in `radicle_home`.219-`rad-job run` generates the run UUID and prints it; `succeeded`/`failed`220-take commit oid + UUID, so run UUIDs are persisted in the DB the way221-`CheckRunStore` persists GitHub check-run ids.222+COB writes shell out to the `rad-job` CLI (`new`, `run`, `succeeded`, `failed`,223+`list`), which signs with the profile key in `radicle_home`. `rad-job run`224+generates the run UUID and prints it; `succeeded`/`failed` take commit oid +225+UUID, so run UUIDs are persisted in the DB the way `CheckRunStore` persists226+GitHub check-run ids.227 228-**Granularity.** One run per commit: an aggregate verdict plus a link,229-like the single "nixbot" status context on Gitea/GitLab. One run per230-check attribute is deferred: runs have no name field and desktop labels231-rows by the log URL, so dozens of near-identical labels may be232-unreadable. Validate in desktop before adopting.233+**Granularity.** One run per commit: an aggregate verdict plus a link, like the234+single "nixbot" status context on Gitea/GitLab. One run per check attribute is235+deferred: runs have no name field and desktop labels rows by the log URL, so236+dozens of near-identical labels may be unreadable. Validate in desktop before237+adopting.238 239 ## 5. Wiring into the service240 241-- `service.py` builds the event source, credentials provider and status242- poster when `config.radicle` is set, and registers `"radicle"` in the243- forge dispatch tables.244+- `service.py` builds the event source, credentials provider and status poster245+ when `config.radicle` is set, and registers `"radicle"` in the forge dispatch246+ tables.247 - Projects are stored with `forge="radicle"`, `forge_repo_id=<RID>`.248-- **Patch numbers.** The builds schema types `pr_number` as `int`, but249- radicle patch ids are git OIDs. A `radicle_patches(project_id,250- patch_oid, number)` table hands out a per-project sequential number251- on first sight; schema and queries stay untouched, users get short252- patch numbers, the full OID appears on the build page.253-- **Permissions.** No forge login exists; restart/cancel follows the254- existing non-forge login rules (`visibility.py`). Which patches get255- built is `patch_trust` (global and per repo): `delegates` (default),256- `followed` (authors the node follows), `all` (rely on the sandbox).257+- **Patch numbers.** The builds schema types `pr_number` as `int`, but radicle258+ patch ids are git OIDs. A `radicle_patches(project_id,259+ patch_oid, number)`260+ table hands out a per-project sequential number on first sight; schema and261+ queries stay untouched, users get short patch numbers, the full OID appears on262+ the build page.263+- **Permissions.** No forge login exists; restart/cancel follows the existing264+ non-forge login rules (`visibility.py`). Which patches get built is265+ `patch_trust` (global and per repo): `delegates` (default), `followed`266+ (authors the node follows), `all` (rely on the sandbox).267 268 ## 6. NixOS module269 270 `services.nixbot.radicle.enable` plus options mirroring `RadicleConfig`.271 272-nixbot runs its **own dedicated radicle-node** with its own identity273-(alias "nixbot") instead of sharing an operator's seed node: job-COB274-runs are attributed per node identity, so results show up under the275-nixbot alias in desktop, and a nixbot compromise never touches the seed276-node's identity. Costs: a second node process and storage copy (largely277-neutralized by §3 alternates), and keeping "enabled in nixbot" in sync278-with "seeded by the node" — the module seeds the allowlist; discovery279-seeds the rest.280-281-The node is **publicly reachable by default** (listen address, opened282-firewall port, configurable `externalAddresses`), so seeds and users283-fetch nixbot's job COBs directly. Users must allow/follow the nixbot284-node identity for its COBs to replicate to them; document this in the285-setup guide.286+nixbot runs its **own dedicated radicle-node** with its own identity (alias287+"nixbot") instead of sharing an operator's seed node: job-COB runs are288+attributed per node identity, so results show up under the nixbot alias in289+desktop, and a nixbot compromise never touches the seed node's identity. Costs:290+a second node process and storage copy (largely neutralized by §3 alternates),291+and keeping "enabled in nixbot" in sync with "seeded by the node" — the module292+seeds the allowlist; discovery seeds the rest.293+294+The node is **publicly reachable by default** (listen address, opened firewall295+port, configurable `externalAddresses`), so seeds and users fetch nixbot's job296+COBs directly. Users must allow/follow the nixbot node identity for its COBs to297+replicate to them; document this in the setup guide.298 299 ## Version floor300 301-radicle-job 0.6 pins the `radicle` crate at 0.24; current heartwood302-ships 0.25.1 (`rad` release 1.10.x). Require nixpkgs radicle-node303-≥ 1.10. The `Event` enum is `#[non_exhaustive]`: the JSON parser must304-ignore unknown event types and fields.305+radicle-job 0.6 pins the `radicle` crate at 0.24; current heartwood ships 0.25.1306+(`rad` release 1.10.x). Require nixpkgs radicle-node ≥ 1.10. The `Event` enum is307+`#[non_exhaustive]`: the JSON parser must ignore unknown event types and fields.308 309 ## Out of scope310 311 - Per-attribute runs in the job COB (§4).312-- Patch comments: radicle-explorer cannot render job COBs yet, so313- web-only users see no status. An edit-in-place summary comment per314- patch revision (`rad patch comment`) is a follow-up.315+- Patch comments: radicle-explorer cannot render job COBs yet, so web-only users316+ see no status. An edit-in-place summary comment per patch revision317+ (`rad patch comment`) is a follow-up.318 - Radicle-based login in the web UI.319 - Merge gating — radicle has no required-checks concept.320 321 ## Risks322 323-- The job-COB schema may still move (radicle-job 0.6.x); all COB I/O324- goes through the `rad-job` CLI, so only the CLI needs updating.325+- The job-COB schema may still move (radicle-job 0.6.x); all COB I/O goes326+ through the `rad-job` CLI, so only the CLI needs updating.327 - No event delivery guarantee across restarts; the catch-up in §2 is328 load-bearing, not an optimization.329-- Patch builds evaluate attacker-controlled Nix code — same threat330- model as PRs, mitigated by the `delegates` default (§5) and331- sandbox-only execution.332+- Patch builds evaluate attacker-controlled Nix code — same threat model as PRs,333+ mitigated by the `delegates` default (§5) and sandbox-only execution.