build duration tracking Feature notiert (#2)

* renaming from gitTally to Werkator

* Rename GitTally to Werkator

`gitTally` is the name of another product in the git space, so the
rename is a precaution; nothing about what the build system does changes.

The name follows one rule: `Werkator` where it is prose, capitalized
where it is a Kotlin type and its file, lowercase everywhere a machine
reads it — the command, packages, paths, configuration keys and values,
the Gitea check context. Environment variables keep their convention and
are uppercase throughout.

Every configuration file is still found under its pre-rename name
(`ConfigFiles`): `.gittally.yml` at the repository root, in a build
worktree and as committed on a branch, `.git/gittally/.gittally.yml` for
the machine layer. The current name wins where both exist, and the old
file is then ignored rather than merged — two files side by side are a
half-done rename, not a layering. Without the fallback an installation
that updated without renaming would not fail: a configuration that is
not found leaves every setting at its default, so it would come up
looking healthy while having forgotten its credentials and its builds.

`docs/werkator-migrationsplan.md` lists what the fallback does not
cover and has to be moved by hand — above all the state directory
`.git/werkator/`, which holds the build history, the control token and
the worktrees, and has no fallback of its own.

`docs/migration-from-legacy.md` is deleted with this: it mapped the
legacy script's environment variables, and every host it addressed has
long since moved to the YAML configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Move the pre-rename state directory at the first start

The configuration is found under either name, the state is not: build
history, control token, auto-build slots and worktrees live at one fixed
path. An installation that updates without moving `.git/gittally` would
not fail — it would come up with an empty history and a fresh control
token, quietly. So the first start moves it instead of the release notes
asking for it.

Only when the old directory exists and the new one does not. Where both
exist nothing is touched and a warning names the leftover: which of the
two is the live state is not something to guess. A failed move is an
error in the log, never an abort — a CI must not hang on it.

The worktrees are dropped rather than moved, since they point at their
old path in both directions; `GitWorktreeWorkspaces` prunes the stale
admin entry and recreates each on its branch's next build. A generated
systemd unit moves with the directory and leaves its symlink dangling,
which is warned about — the running service is unaffected, the next
start is not.

Runs from `CliRunner`, before any command resolves a path under the
directory, and so before the second context of `server` exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Document PR#1: the rename to Werkator

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Drop the legacy env-to-YAML conversion from the setup tool

The old bash script configured itself through `GITTALLY_*` environment
variables. The blanket rename rewrote those literals, so the converter
was looking for `WERKATOR_*` — a spelling no host has ever written. Fed
a real legacy file it would have found nothing and written an almost
empty configuration, without an error, which is the same silent failure
this rename is otherwise careful to avoid.

The conversion has served its purpose with the vm2176 to vm4006
migration, so it goes instead of being repaired. What remains is the
setup of a new instance: the preconditions, the credential prompt, and
the machine configuration written mode 600 — now carrying the host's
public URL as well, since that is host-specific too. Everything the
repository builds comes from `init` and its templates.

It also stops emitting a legacy `branches:` section, which step 18 is
about to reject outright.

`docs/plan/00-legacy-analysis.md` and `13-nginx-tls.md` get the real
`GITTALLY_*` spelling back: they record what the old script read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Describe this repository's build with a build definition

Its own `.werkator.yml` still used the deprecated `branches` section
with an `autoBuild` schedule that was switched off. That section is read
only while nothing defines a build at all, and step 18 rejects it by
name — so this repository would have blocked the precondition of that
step, which asks that no configuration still in play carries it.

Nothing about the build changes: `builds.default` with `trigger.onPush`
is a build of every new commit on every branch, which is what the branch
section said. `config:print --full` resolves the definition completely
and logs no deprecation warning any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop documenting the pre-rename fallback for users

Exactly one repository is configured with the old names, and it is
migrated by hand in the same move as this release. The fallback is
therefore a transition of days, not a feature anyone reading the release
notes or the configuration reference has to plan around.

Removed from `releases.html` and `docs/configuration.md`. The mechanism
itself is unchanged and stays described where it is worked on: in
`ConfigFiles`, in `StateDirMigration`, and in the migration plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Bring the PR-doc to its final state

The `statusContext` question is answered and marked as decided rather
than left standing: it is the one value a human reads as a label, and it
stays lowercase because Gitea matches it and the client reads it back,
which makes it a value.

Also records that the pre-rename fallback is deliberately absent from
the release notes and the configuration reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Release v1.0.0: Werkator

The release after 0.9.21 is 1.0.0, because a product that changes its
name is better off counting from one under it. The release note says as
much, so the jump is not read as a claim about maturity — plan steps 14,
17 and 18 are still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Correct the PR-doc about the version

It claimed the PR carries no version bump, which the release commit made
untrue, and records why the number is 1.0.0 instead of 0.9.22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Point the legacy references at the history

`legacy/gitTally` was removed from the tree with the rename, but the
README still described it as a reference kept in the repository, and the
plan told an executing session to read parts of it — including step 14,
which is open.

The README section is gone; `docs/plan/README.md`, step 14 and the
legacy analysis now say where the script actually is
(`git show 7f55068^:legacy/gitTally`). Executed steps and ADR 0004 keep
their wording: they record what was true when they ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Retarget the links in the historic PR-docs

The package rename moved every file the older PR-docs link to, leaving
60 dead links. Only the link targets are rewritten, never the visible
text and never a statement: those documents record what was true when
they were written, GitTally in the prose included. A snapshot may be
outdated; it should still be navigable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add the v1.0.0 deployment procedure for vm4006

Measured, not estimated: the state directory is 878 MB, of which 878 MB
are the nine build worktrees. What cannot be recreated is 84 KB, so the
snapshot before an in-place switch is instant and the rollback is one
sequence of moves.

Records the three expected non-failures — a cold Gradle volume, one
image rebuild, containers left under the old label — and that
`gitea.statusContext` needs no attention because it comes from the
watched repository's committed configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Rename the machine configuration along with its directory

Found by the deployment to vm4006: the move renames the directory and
leaves the file inside it alone, so the machine configuration ended up at
`.git/werkator/.gittally.yml` — a pair of names the lookup did not
expect, because it pairs directory and file name. The instance resolved
empty credentials, no public URL and none of the host's build
definitions, and said nothing about it. That is the exact failure this
change exists to prevent, produced by the change itself.

`StateDirMigration` now renames the configuration with the directory,
unless one under the current name is already there. `ConfigFiles` carries
`.git/werkator/.gittally.yml` as a third candidate as well, for a
directory somebody moved by hand, where the migration never runs and so
can rename nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Let init see a configuration under its previous name

`init --systemd` runs `init`, and its "already exists" check knew only
the current name. On the one repository still carrying `.gittally.yml`
it therefore wrote a fresh template `.werkator.yml` beside it — and
since the current name wins, that repository would have built the
template's `./gradlew test` instead of what its own configuration says.
Found on vm4006, where the file was created in the watched working tree
and removed again by hand.

Both checks now ask `ConfigFiles`, so init decides existence by the same
rule the loader uses to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Record the v1.0.0 deployment to vm4006

Deployed from the branch as the final test of PR#1, and it did what a
final test is for: it found two silent-failure defects before the
service was started, both fixed and redeployed in the same window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Drop the control token left under the old localStorage key

The key is named after the product, so the rename left every browser
with a token under `gittally.controlToken`, which nothing reads any more
and which "forget token" can no longer reach. It is a write-scope token
in a browser store, not a password, but a secret nobody owns is worth
one line to remove.

Removed on load. The token on the server is unchanged, so re-entering it
once per browser is all the rename costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Plan tracking the build duration over time (step 20)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michael Hönnig
2026-08-31 13:58:34 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 06fc3e4b39
commit 516765a717
2 changed files with 103 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
# Step 20: Track build duration over time
Prerequisites: the single-build duration already exists — `BuildResult.duration` (pure execution time from `runningSince`, without the queue wait) is shown in the History view, the JSON API (`durationSeconds`) and the CLI status.
Read `README.md` first.
## Why
Step 14's `Out of Scope` deliberately left out *historical* phase series and alerting; it only gave a per-build breakdown.
A per-build number answers "how long was the last run", not "is the build getting slower".
On a CI serving one repository the first sign of trouble is usually a build that used to take 6 minutes now taking 9 — before a failure, at a point where the per-build figure still looks fine.
This step turns the already-recorded durations into a trend, so a regression in build time is visible as it happens instead of after the fact.
## What Already Exists
- `BuildResult.duration` — set once on the final transition in `BuildExecutor` (`Duration.between(runningSince, now)`).
- `repository.history()` returns every recorded build, newest first, each carrying `duration`.
- The History view (`/history`, `/api/builds/history`) already renders `durationSeconds` per row via `BuildResultDto`.
- No aggregation, no trend, no comparison against history exists anywhere.
So the raw data is complete; nothing about build recording needs to change.
## Goal
A per-job (build-definition name) duration trend derived from the existing history, shown where the History view already lives.
The trend answers, for a chosen job and a bounded time window (default: last 30 days):
- how long each build took, chronologically;
- the running/window average, the min and the max;
- the slowest recent build and how far it sits above the window average — a visible outlier instead of a silent one;
- how the latest finished build compares to the window average (slower/faster by how much).
Derived on read from `repository.history()`, not stored: it is the same data, and deriving keeps the repository schema untouched and the numbers always fresh as the retention window prunes old runs.
## Scope Decision 1: whole build, not phases
This step tracks the *whole execution* duration — the number that tells a user "the build is getting slower".
Step 14 tracks the *phase* breakdown (where orchestration time goes) and already owns the per-build budget warning.
Keep the two apart: phase timing is Step 14's, whole-duration trends are this step's.
Only compute a trend where `duration` is not null (queued/cancelled builds have none and carry no signal for this purpose).
## Scope Decision 2: which builds go into the trend — jobs, not branches
Branch builds matter here as much as the primary branch, often more: on our projects the feature-branch build is the performance gate, not `main`.
But the history is already grouped by `name` (`branch`, or `<branch>@<build>`), and a branch may sit behind the machine config by up to a week, yet still build several times a day.
So the trend must **not** merge every branch into one per-job line — that would average a stale branch together with the current one and hide exactly the regression the feature exists to show.
Rule: group the trend by the history's own grouping — `name` — which is already the natural comparison unit (every "latest" and every retention pool is keyed by it).
A job defined on the host (`main@nightly`) gets its own line; each branch build (`mihoe/feature-x`) gets its own line.
Where `build == DEFAULT` the line is just the branch, and still distinct from every other branch and from every named job.
The series stays comparable because each line is a single, self-consistent grouping; the window average of `main@nightly` is never polluted by a branch that lags the config.
What this deliberately does *not* do is aggregate branches together for a project-level view.
"A branch build got slower" is a per-name question and is answered per line; conflating branches has no one right answer while they lag the config by different amounts.
The default UI shows the primary branch or the default job, and the other lines are a lookup, not a summary.
This assumes the per-branch duration itself is a fair comparison: every build of one `name` runs the same build steps with the same config (pinning strips only a fixed set).
A branch lagging the machine config by under a week still runs its committed `.werkator.yml` — see the branch-layer invariant — so its durations come from the same definition it has always run, and the trend is honest within that line.
## Design
1. `build/BuildDurationTrend.kt` — pure function over `List<BuildResult>`, grouped by `name` (the history's grouping — one line per name, see Scope Decision 2), optionally filtered by a `since: Instant` window:
per name return the chronological `(startedAt, duration)` series plus window average, min, max, and the latest finished duration's deviation from the window average.
Pure and dependency-free so it is unit-testable without Spring.
Durations with a `null` `duration` are excluded up front.
2. `server/BuildsApiController.kt` — a `GET /api/builds/duration` returning the trend for all names over the default window. The History view polls it on the same cycle as the rest.
3. Web UI — a compact trend block on the History view (`/history`): for the primary branch (the default job), the series of recent durations with the window average and the latest build's delta, with an explicit marker when the latest build is slower than the window average by a margin (the exact ink and wording are free; the marker must call out *slowness* in the user's words, not just plot a line). The other names are a lookup, not a summary — see Scope Decision 2. Follow the existing discipline: a fetch timeout, and a failure of this request must not break the view's own refresh.
`UiFormats` and `werkator.js` keep producing identical formats (invariant in `AGENTS.md`).
4. CLI — `status --history` already prints per-build duration; add nothing to the CLI. The trend is a server-side view; the CLI has no need to chart it.
## Out of Scope
- Phase timing and the overhead budget warning — Step 14.
- Storing a time series separate from the build results; the retention window already prunes old runs, and the trend lives where the history lives.
- Alerting or notifications (Webhook, Gitea status) on a slow build — this step surfaces the trend in the UI; wiring it to push is a later concern and belongs nowhere here.
## Tests
- `build/BuildDurationTrendTest.kt` — grouping by name (a branch build and a named job stay separate, never merged); the window filter; a `duration == null` build is excluded; average/min/max correct for an asymmetric series; the latest build's deviation (slower and faster) against the window average.
- `server/BuildsApiControllerTest.kt` — a history with several durations across two names resolves through `/api/builds/duration` to the expected per-name trends.
- The JavaScript has no test harness; the trend block is verified manually below.
## Documentation
- `docs/configuration.md` — nothing configurable is added (the window is a constant for now); no config key changes.
- `docs/adrs/` — none: this repeats no architectural decision, it derives read-only from existing data.
- `AGENTS.md` web-UI invariant is already covered; no new invariant.
## Verification
- In a scratch install, run several builds of different durations (e.g. by building on commits with different workloads), then open `/history` and confirm the trend shows the series, the window average, and a slower-latest marker on the last run.
- Delete a run via the API and confirm the trend reflects the pruned history, since it is derived on read.
- On a narrow viewport the trend block must not push the table off screen.
## Production
Nothing to deploy beyond the next release: the feature is read-only over existing history and needs no config or migration on vm4006.
Deploy as usual.
+5
View File
@@ -91,6 +91,10 @@ Added for running Werkator on Hostsharing Managed Webspaces (2026-08-10):
- [ ] `17-bwrap-build-runtime.md` — Werkator on a Managed Webspace: bubblewrap user-namespace build sandbox with a prepared rootfs (precondition check first — see the step file), plus web access under a domain via the platform's Apache proxy and Let's Encrypt - [ ] `17-bwrap-build-runtime.md` — Werkator on a Managed Webspace: bubblewrap user-namespace build sandbox with a prepared rootfs (precondition check first — see the step file), plus web access under a domain via the platform's Apache proxy and Let's Encrypt
Added for surfacing build time as a trend (2026-08-31):
- [ ] `20-build-duration-tracking.md` — a per-name duration trend over the existing history, derived on read in the History view: series, window average/min/max, and a visible marker when the latest build is slower than its window average (grouped by the history's own `name`, so branch builds and named jobs stay separate — complements Step 14, which owns phase timing)
Steps 0103 are independent of each other. Steps 0103 are independent of each other.
Steps 0406 depend on 0103. Steps 0406 depend on 0103.
Steps 0709 depend on 0406. Steps 0709 depend on 0406.
@@ -100,3 +104,4 @@ Step 15 depends on 12 and 13 and revises the containerized-runtime sketch in `do
Step 17 depends on 11, 15, and 16, and starts with a hard precondition check on the target webspace (ADR 0007 is written as part of the step). Step 17 depends on 11, 15, and 16, and starts with a hard precondition check on the target webspace (ADR 0007 is written as part of the step).
Step 18 depends on nothing in code but on the watched repository having migrated — its precondition check is a hard gate, not a formality. Step 18 depends on nothing in code but on the watched repository having migrated — its precondition check is a hard gate, not a formality.
Step 19 depends on nothing; `WatcherState` and `/api/watcher` already carry everything it needs to render. Step 19 depends on nothing; `WatcherState` and `/api/watcher` already carry everything it needs to render.
Step 20 depends on nothing; the duration is already recorded, and the trend is derived read-only from `repository.history()`.