feat(server): Routen, Seiten und Artefakte tragen das Repository — /repos/<name>/… (Sitzung D)

Zweite Hälfte von Sitzung D (docs/plan/22-multi-repo.md, PR #13): Der Server
bediente bisher genau ein Repository der Registry. Jede Route — API, Seiten,
Artefakt-Dateien — arbeitete auf `registry.current()`; ein zweites
registriertes Repository wurde gebaut und gepollt, war aber unsichtbar und
unerreichbar.

Jeder Controller löst sein Repository jetzt je Anfrage auf, statt das
bediente als Bohne zu halten; jede Route ist zweimal gemappt. Die unscoped
Form ist kein Übergangs-Alias, sondern dauerhaft die Art zu sagen „das
bediente Repository" — Lesezeichen und die nach Gitea geposteten Links
kennen kein Segment.

Entschieden gegen die Repo-Spalte: Die Seiten bleiben je Repository, die
Navigation bekommt einen Umschalter. Die Aktionen einer Zeile brauchen das
Repository ohnehin, Branches kommen von einem origin und Artefakte aus einem
Store — und bei dem einen Repository, das die meisten Installationen haben,
wäre eine Spalte nur Rauschen.

Das Link-Präfix folgt der ZAHL der bedienten Repositories, nicht dem Weg, über
den eine Seite erreicht wurde: mit einem behält die Installation ihre
bisherigen URLs (Abnahmekriterium der Sitzung), mit mehreren benennt jeder
Link sein Repository. werkator.js liest das Präfix einmal aus einem
`werkator-repo-base`-Meta. `BranchPermalinks.permanentUrl` bekommt es
ebenfalls — der permanente Schlüssel ist ein Hash des Build-Namens allein,
zwei Repositories mit `main` teilten sich sonst eine permanente URL.

Fünf neue Tests, Gegenprobe per Mutation gezogen (Präfix fest auf leer →
genau der Mehr-Repo-Test fällt). 498 Tests grün, ktlint sauber. PR-Dokument
docs/prs/2026-09-03-PR#13-…, Plan, Architektur-Skill und AGENTS.md
nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-03 13:39:09 +02:00
co-authored by Claude Opus 5
parent 4304dd7c4b
commit c82f2a965c
20 changed files with 417 additions and 96 deletions
@@ -0,0 +1,121 @@
> **WARNING:** This document describes only the change applied in this PR.
> It may already be outdated once the next PR is merged.
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
## The Problem
PR #12 gave the instance a registry of repositories, but the server still served exactly one of them.
Every route — API, pages, artifact files — worked on `registry.current()`, so a second registered repository was built and polled, yet invisible and unreachable.
Two consequences went beyond "not browsable": the current-builds views listed the running builds of *all* repositories while looking their status up in *one* repository's results, and `cancel` addressed a build by key across the whole instance.
Step 22 session D is the repository dimension in the server: routes, links, and the UI.
## Non-Goals
- The rollout on the instance and the deployment documentation of the registry (session E).
- Merging several repositories into one table: the pages stay per repository (see The Solution).
- A per-repository control token or per-repository metrics: one instance, one token, one metrics page (ADR 0009).
## The Scenarios
### Feature: every route carries the repository
#### Background
- The instance serves a registry of repositories (ADR 0009); the *served* repository is `RepoRegistry.current()` — the current working directory when it is served, else the first entry.
- The prefix is `/repos/<name>` for the pages and `/api/repos/<name>` for the API, where `<name>` is the registry entry's short name.
#### Scenario#13.01: The repository-scoped API answers for the named repository
So that a second registered repository is reachable at all.
- **Given** an instance serving a repository named `test`
- **When** `GET /api/repos/test/builds/latest` is requested
- **Then** the answer holds that repository's builds
- **and** `GET /api/builds/latest` still answers the same, because the unscoped form means the served repository
##### Verified by
- [BuildsApiControllerTest — "the repository-scoped routes answer for the named repository and 404 for an unknown name"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
#### Scenario#13.02: A name the instance does not serve is a miss, not an error
So that a typo in a URL reads like every other miss of this API.
- **Given** an instance that serves no repository named `no-such-repo`
- **When** `GET /api/repos/no-such-repo/builds/latest` is requested
- **Then** the answer is 404 with `{"error": "no repository named 'no-such-repo'"}`
- **and** the page `/repos/no-such-repo` answers 404 as well
##### Verified by
- [BuildsApiControllerTest — "the repository-scoped routes answer for the named repository and 404 for an unknown name"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
- [UiControllerTest — "a page of a repository this instance does not serve answers 404"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
#### Scenario#13.03: A single-repository installation keeps its existing URLs
So that no bookmark, no posted Gitea link, and no operator habit breaks on an installation that has exactly what it had before.
- **Given** an instance serving exactly one repository
- **When** any page is rendered
- **Then** every link it contains is unscoped (`/branches`, `/history`, `/api/builds/latest`)
- **and** no repository switcher is shown, because there is nothing to switch
##### Verified by
- [UiControllerTest — "with one served repository the pages keep their existing URLs and show no switcher"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
#### Scenario#13.04: With several repositories every link names its repository
So that a click inside a repository's page stays inside that repository.
- **Given** an instance serving the repositories `test` and `other`
- **When** the page `/repos/test` is rendered
- **Then** its navigation links, its `data-api`, and its `werkator-repo-base` meta carry `/repos/test`
- **and** the switcher offers `/repos/other`
##### Verified by
- [UiControllerTest — "with several served repositories every link names its repository and the switcher appears"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
#### Scenario#13.05: A repository-named route never reaches another repository
So that the repository in the path is a boundary, not a label.
- **Given** a build whose artifact key is not recorded in the repository named in the route
- **When** that build is cancelled through `/api/repos/test/builds/<key>/cancel`
- **Then** the answer is 404
- **and** the executor is not asked to cancel anything
##### Verified by
- [BuildsApiControllerTest — "cancel does not reach a build of another repository"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
- [BuildsApiControllerTest — "current answers only the served repository's builds"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
- [WatcherTest — "a running build of another repository does not keep this repository's worktree"](../../src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt)
## The Solution
Every controller resolves its `RepoContext` per request instead of holding the served one as a bean: `repoOf(name)` is `registry.current()` without a name and `registry.byName(name)` with one, and an unknown name throws `UnknownRepositoryException`, which each controller turns into its own 404 shape.
Each route is mapped twice — scoped and unscoped — so the unscoped form is not a transitional alias but the permanent way to say "the served repository".
`RunningBuild` carries its `RepoContext`, so the executor's instance-global `currentBuilds()` can be filtered: the current-builds view and API show their own repository's builds, and the watcher's worktree pruning is protected by its own repository's builds alone.
`cancel` additionally verifies that the artifact key is recorded in the named repository — a queued or running build always has its PENDING/RUNNING result there.
The pages stay **per repository** instead of merging every repository's rows into one table with a repository column: a row's actions need the repository anyway, branches come from one origin and artifacts from one store, and with the single repository most installations have, such a column is pure noise.
What makes the instance one UI is the switcher in the navigation.
The link prefix follows the *number of served repositories*, not the route a page was reached through — with one repository the installation keeps its existing URLs, with several every link names its repository.
`werkator.js` reads that prefix once from a `werkator-repo-base` meta and builds its action and artifact URLs from it; the paths rendered into the DOM already carry it.
`BranchPermalinks.permanentUrl` takes the prefix too: the permanent key is a hash of the build name alone, so two repositories both having `main` would otherwise share one permanent URL.
## Open Questions
- The metrics page and the control token stay instance-wide (ADR 0009); a per-repository token is not planned.
- `/api/watcher` stays unscoped — its state already carries the per-repository reports, and the UI banner is instance-wide.
## Prerequisite PRs
- PR #12 — the repository registry (step 22 session C).
## Follow-up PRs
- Step 22 session E — the rollout: the registry with Werkator and Werkbaum under one service, and `docs/deployment.md`.