Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
9.4 KiB
REPORT — controller v0.147.0 → v0.147.3: feedback slice 1 (2026-07-19)
Baseline v0.146.0 → LIVE v0.147.3 on demo guest 9201. Green at every version:
go build ./... && go vet ./... && go test ./... (23 packages) plus template_id_gate,
emoji_gate, native_confirm_gate, offbox_rename_gate, app_row_dedup_gate, mojibake_gate.
docker_run_volume_path_gatefails oninternal/appexport/estimate.go:179. Pre-existing on HEAD and untouched by this release — verified by stashing this work and re-running the gate. Not fixed here (out of scope, and it is someone's call whether that-v :rodu probe gets an allowlist entry or a rewrite).
The problem
The systemic complaint, twice in one evening: you press a button and nothing happens. No progress, no ETA, no named result. Three worst offenders fixed on the two patterns already in the codebase — the deploy 3-step panel and the storage-init status poll. No new framework; that is now ROADMAP R-45, because three targeted cards were worth shipping tonight and a framework was not.
Three of the four versions below exist because the cards were watched against real runs on the demo box. Each follow-up is a thing no unit test would have surfaced.
4a — a verification restore names its result
The completion flash said the app had been restored „ellenőrző mappába a meghajtón". Which folder, on which drive, was invisible — so the customer could not go and look at the thing they had just asked for. And nothing anywhere listed what these restores had accumulated, so copies piled up and the only way to find them was SSH.
- The flash now carries the full path.
- The restore page gained „Meglévő ellenőrző másolatok" — app · size · date · path — each with a
double-confirmed „Másolat törlése" (the house inline
felhomConfirmidiom, never nativeconfirm()). backups/offsite-restorewas open-coded in three places; it now has one home (offsiteRestoreRootFor). A test pins the path in the flash to the path in the listing, so the customer can never be told about a directory the page cannot show or remove.
The delete is the only one this release adds, so it names a stack, never a path: the Manager
resolves that name inside a root it computed itself and refuses anything landing outside.
Red-proofed — neutralise the name guard and stack: "" resolves to the offsite-restore root and
os.RemoveAll takes every copy with it. Every refusal is asserted as a non-effect (the
neighbouring copy and the live data are still on disk afterwards).
Honest note: the HasPrefix containment check behind that name guard could not be red-proofed
independently — with isSafeStackName in front of it, no input the API accepts can reach it with an
escaping path. It is kept as defence-in-depth and labelled in the test file as unproven-by-test
rather than dressed up as a red-proof it does not have.
Live (endpoint-exact, through the real handlers)
Real verification restore of calibre-web, then the guard matrix:
message: "A(z) calibre-web visszaállítva ellenőrző mappába:
/mnt/felhom-drives/hdd_1/backups/offsite-restore/calibre-web
(a meglévő adatok változatlanok)."
listing: calibre-web · 430.4 KB · …/offsite-restore/calibre-web · [Másolat törlése]
stack=../primary -> refused; copy AND backups/primary both still present
missing confirm -> refused; nothing changed
real delete -> copy gone; backups/primary SURVIVES; page -> „Nincs ellenőrző másolat"
4b — Megosztás enable shows what it is waiting for
Enabling sharing ran ReconcileSamba() synchronously inside the POST handler. On a box whose
golden had not baked felhom-samba that is compose up -d pulling ~100MB from a private registry:
minutes of an apparently-hung form post, then „Beállítás mentve." whether or not anything came up.
Detached + polled now, with a card that distinguishes „képfájl letöltése" (image genuinely absent — the multi-minute case) from „indítás" (already baked — seconds). The distinction is decided before the work starts, because afterwards the image is always present and the card could never truthfully say „letöltés".
- Success is probed, not inferred —
compose up -dexits 0 on a crash-loop, so the terminal state is container liveness. nilfrom reconcile also covers "deliberately deployed nothing because there is no household password yet"; that now gets its own message instead of a card spinning forever.- The password form starts the same job — with
UserSetfalse reconcile deploys nothing, so on a fresh box that, not the enable toggle, is where the pull actually happens.
Test scope, stated honestly: the unit tests cover the single-flight slot, snapshot isolation and
the phase vocabulary. They do not drive runSambaEnsureJob end-to-end — the Server field is the
concrete *stacks.Manager, not an interface, and introducing one purely for this card was more churn
than the slice warranted. Pulling-vs-starting and the probed terminal state are covered by live
validation instead. That seam belongs to R-45.
Live: /sharing/status returns {"ok":true,"data":{"phase":"running","running":true,"error":""}};
the page carries the card and the poll.
4c — „Távoli mentés most" streams real progress
restic was already reporting bytes and percentages; the runner seam used CombinedOutput() and threw
them away. The manual run now passes --json, scans stdout line-by-line, and the page shows total
bytes, percent and the app being pushed. Manual only — the nightly stays silent, pinned by a test
that fails if the scheduled path ever passes --json or publishes progress.
The poll also now arms unconditionally. It used to start only if the page already rendered
„Fut…", which loses a race the manual trigger always runs: the POST redirects and the page renders
before the detached goroutine writes LastStatus=running, so the poll never armed and the customer
watched a static page during the very run they had just started.
Red-proofed twice: break the parser -> the percent assertion fails; drop the wiring -> the --json
assertion fails.
What three live runs actually taught (v0.147.1 -> .3)
Run 1 — immich reported 0% for 40+ seconds. The parser was not broken. On an incremental run
where nothing changed restic transfers nothing: bytes_done is omitempty, so it is not even in the
JSON, and percent_done stays 0. Confirmed by capturing the real schema from
backup --dry-run --json on restic 0.14.0 in the controller image rather than guessing; those lines
are now quoted verbatim in the type's doc comment. A byte-only bar is indistinguishable from a hang
in the common case — shipping it would have traded "no feedback" for "feedback that says 0% and
looks stuck". -> v0.147.1 parses and publishes files_done/total_files.
Run 2 — bookstack showed clean byte progress (100%, 154.0 MB, 7/7), immich sat at 1 of 46 files, 0 bytes for 42 seconds. restic 0.14 only counts a file when it completes, so an app dominated
by one large archive (immich's ~430MB volume tar) freezes both counters. No percentage can move.
-> v0.147.2 shows what restic does keep reporting: the file being processed and elapsed
seconds. „1 / 46 fájl (430.2 MB) · feldolgozás alatt: immich_upload.tar · 42 mp" is a different
message from „0%".
Run 3 — the per-app legs finished in ~15s; the remaining 40 of a 57-second run was the shares leg
and forget --prune, during which the card sat frozen on „calibre-web — 8 / 8 fájl". The same
silence, relocated to the end of the run. -> v0.147.3 adds a run phase; the post-app stages
announce themselves and app-scoped counters clear when a phase starts.
Final live trace — the whole 57-second run is narrated, no frozen state:
t+6s app=immich
t+9s app=bookstack
t+12s app=calibre-web
t+15s phase=shares
t+18s ... t+54s phase=retention
t+57s done
Also: golden/controller infra-image drift closed at the source
infra.Images() derives the list from the existing pins; --print-infra-images prints it; the golden
bake now asks the controller binary it is about to bake instead of carrying its own copy. That copy
had already drifted — felhom-samba was never added to it, so the golden baked 3 of 4, which is
why enabling Megosztás pulled at runtime in the first place. A test parses the const block out of
the source with go/ast and fails if a pin is added without reaching Images(); red-proofed.
Verified against the real published image on the live box — all four returned.
Validation method
Live validation was endpoint-exact (the exact URLs the UI posts to, through the real handlers and the real restic/samba paths), not browser click-through: the controller UI is password-gated and I do not enter credentials into web forms. No server logic was skipped — only rendering. The rendered markup was checked by fetching the pages themselves and asserting on the emitted HTML.
Not done
- Floor not raised — Viktor decides floor timing.
- No golden rebuild, no manifest saves, nothing on Peti's arc.
- No SMB server-side changes — the
\\FELHOMaccess issue stays client-side diagnosis first. - ROADMAP: R-45 (unified async-job feedback) and R-46 (verification-copy browse + expiry)
registered in
felhom.eu/documentation/backlog/ROADMAP.md.