feat(build): every running build knows its repository — die laufenden Builds und das Worktree-Aufräumen unterscheiden Repositories

Der letzte Übertrag aus Sitzung C: `RunningBuild` trug kein Repository, der
Executor ist aber instanzweit. Zwei Folgen, beide gemessen und jetzt behoben:

- Das Worktree-Aufräumen schützte die Worktrees ALLER Repositories. Ein
  laufender Build eines anderen Repositories auf einem gleichnamigen Branch
  hielt hier einen Worktree fest, dessen Branch längst von origin weg war.
- Die Current-Builds-Ansicht und `/api/builds/current` zeigten die Builds
  aller Repositories, schlugen ihren Status aber in den Ergebnissen NUR
  dieses Repositories nach — ein fremder Build fiel auf RUNNING zurück und
  zeigte einen Zustand, den niemand aufgezeichnet hat. Dasselbe beim
  Live-Log: der Schlüssel eines fremden Builds wurde beantwortet.

`RunningBuild.repo` ist der `RepoContext` selbst, verglichen wird per
Referenz — der Kontext ist die Identität (ADR 0009), und der Executor hat ihn
in `startBuild` ohnehin zur Hand. Watcher, API und UI filtern damit auf ihr
eigenes Repository.

Drei neue Tests, Gegenprobe per Mutation gezogen: Nimmt man die drei Filter
wieder heraus, fallen genau diese drei und sonst keiner. 493 Tests grün,
ktlint sauber.

Architektur-Skill und Plan (docs/plan/22-multi-repo.md) nachgezogen — die
Aussage „RunningBuild carries no repository" stimmte nicht mehr. Was von
Sitzung D bleibt: die repo-bezogenen Routen und die Oberfläche, die weiterhin
nur `registry.current()` bedienen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-03 12:36:07 +02:00
co-authored by Claude Opus 5
parent 6fba43c627
commit 6ca67a6a37
11 changed files with 84 additions and 9 deletions
@@ -97,6 +97,7 @@ class BuildExecutor(
val stagingDir = Files.createTempDirectory("werkator-build-")
val runningBuild =
RunningBuild(
repo = repo,
branch = branch,
build = build,
commit = commit,
@@ -1,11 +1,19 @@
package de.hoennig.werkator.build
import de.hoennig.werkator.config.BuildDefinition
import de.hoennig.werkator.repo.RepoContext
import java.nio.file.Path
import java.time.Instant
/** Handle to a build accepted by the [BuildExecutor]; log paths become valid once the build runs. */
data class RunningBuild(
/**
* The repository this build belongs to; the context object is the identity
* (ADR 0009), so it compares by reference. Without it neither the current-builds
* view nor the watcher's worktree pruning could tell two repositories apart —
* both would see every repository's running builds as their own.
*/
val repo: RepoContext,
/** The git branch being built. */
val branch: String,
/** The build definition (job) this build runs; its settings are resolved from config at run time. */
@@ -55,11 +55,17 @@ class BuildsApiController(
private fun BuildResult.isLatestGreen(): Boolean = repository.latestGreenFor(name)?.artifactKey == artifactKey
/** The currently executing builds — several are possible, up to `executor.maxConcurrent`. */
/**
* The currently executing builds of the served repository — several are possible,
* up to `executor.maxConcurrent`. The executor is instance-global and returns the
* builds of every registered repository, so this view filters: its [repository]
* holds only this repository's results, and a foreign build looked up in them
* would fall back to RUNNING and show a status nobody recorded.
*/
@GetMapping("/api/builds/current")
fun current(): List<CurrentBuildDto> {
val results = repository.history()
return buildExecutor.currentBuilds().map { build ->
return buildExecutor.currentBuilds().filter { it.repo === repo }.map { build ->
CurrentBuildDto(
branch = build.branch,
name = build.name,
@@ -82,7 +88,7 @@ class BuildsApiController(
@RequestParam(defaultValue = "0") offset: Long,
): ResponseEntity<Any> {
val build =
buildExecutor.currentBuilds().firstOrNull { it.artifactKey == artifactKey }
buildExecutor.currentBuilds().firstOrNull { it.repo === repo && it.artifactKey == artifactKey }
?: return notFound("no running build with artifact key '$artifactKey'")
return ResponseEntity.ok(readLogTail(artifactKey, build.liveLogFile, offset))
}
@@ -106,7 +106,7 @@ class UiController(
val links = baseModel(model, view = "current", pageTitle = "Current Builds")
val results = repository.history()
val currentBuilds =
buildExecutor.currentBuilds().map { build ->
buildExecutor.currentBuilds().filter { it.repo === repo }.map { build ->
CurrentBuildView(
branch = build.branch,
name = build.name,
@@ -506,7 +506,10 @@ class Watcher(
}
val keep = originBranches.map { ArtifactKeys.branchKey(it) }.toMutableSet()
// never delete under a build that is still queued or executing
buildExecutor.currentBuilds().forEach { keep += ArtifactKeys.branchKey(it.branch) }
buildExecutor
.currentBuilds()
.filter { it.repo === repo }
.forEach { keep += ArtifactKeys.branchKey(it.branch) }
repo.results
.latestPerName()
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }