Files
werkator/docs/prs/2026-09-03-PR#13-repository-scoped-routes.md
mhoennigandClaude Opus 5 c82f2a965c 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>
2026-09-03 13:39:09 +02:00

7.4 KiB

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

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

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

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

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

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.