docs: v0.140.0 Direction-2 waiter — REPORT + CONTEXT (live: 240s no-annotation hold, 0.047s wake, restart no-storm)

This commit is contained in:
2026-07-16 21:10:42 +02:00
parent 2dd05670ae
commit 5be2449267
2 changed files with 67 additions and 140 deletions
+50 -139
View File
@@ -1,145 +1,56 @@
# REPORT — v0.139.0: immediate out-of-cycle hub report on user actions (Direction 1)
# REPORT — most recent implementation
**Date:** 2026-07-16 · **Session:** Claude Code (spec: "Immediate out-of-cycle hub report on user
actions (Direction 1)") · **Repo:** felhom-controller only (hub/agent/catalog untouched, as specced)
## v0.140.0 — Direction-2 immediate-sync: hub→box wait channel client (2026-07-16)
## Baselines used
The reverse half of the immediacy arc (Direction 1 = v0.139.0 box→hub trigger). An operator action
on the hub now reaches the box in **seconds** instead of on the next ~15-min cycle. Pairs with hub
**v0.58.0** (`GET /api/v1/wait` + the in-memory operator-intent notifier). Grounding:
`felhom.eu/documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md` (option b).
| Repo | Base commit | Version before → after |
|---|---|---|
| felhom-controller | `8f3564c` (matched the spec's `8f3564c1`) | v0.138.0 → **v0.139.0** |
### What shipped
- **`internal/report/waiter.go` (new) `report.Waiter`.** Holds a hanging authenticated
`GET {hub}/api/v1/wait?gen=N` (same hub URL + key as the pusher — **no new config keys**). Its own
`http.Client` has **no overall Timeout** (a held GET must stay open for the hub's ~240 s hold),
with connect/TLS/`ResponseHeaderTimeout` deadlines; a per-request context bounds a black-holed
connection. On a completion whose generation **differs** from the last seen, it fires the v0.139.0
`report.Trigger` — and nothing else; the report ACK delivers config/escrow/claim/floor through the
UNCHANGED machinery (zero new delivery logic; the box pulls even the wake-up).
- First observation **records, never fires** (no restart-echo report).
- Same-generation timeout **fires nothing** (not interval-shortening).
- Heartbeat newlines tolerated; only the `{"gen":N}` line is read (contentless wake).
- Any error — transport, **404 from a pre-v0.58.0 hub**, malformed body — backs off 5 s→5 min
(reset on success), ONE WARN per state change, and the 15-min cycle keeps reconciling. Exits
promptly on ctx cancel (even mid-hold).
- **`cmd/controller/main.go`.** Waiter constructed + started beside the Direction-1 trigger, gated on
the SAME `hubPusher != nil && cfg.Hub.Enabled` (strict no-op when hub reporting is off).
- **Copy soften.** `backups_remote.html` + `backups_escrow.html`: "ez általában néhány **másodperc**,
legfeljebb 15 perc" (the 15-min bound stays as the honest worst case; escrow grace window
unchanged).
## What shipped
### Tests + red-proof
Non-hollow `waiter_test.go` (httptest fake hub): fires-once-on-change, same-gen-timeout-no-fire,
**first-observation-records-no-fire** (RED-PROOF: disable the baseline branch → first poll fires;
run-fail-reverted), heartbeats-tolerated, malformed-completion-no-fire-no-panic, 404-as-error,
Bearer+gen wire check, ctx-cancel-mid-hold-returns-promptly. Full controller suite green
(`go build/vet/test ./...`); design gates pass (template_id / emoji / native_confirm / offbox_rename
/ mojibake).
One generic, debounced out-of-cycle report trigger (`report.Trigger`) on the proven outbound push
channel; the 15-min `hub-report` cycle untouched as the reconciliation backbone.
### Deploy + live validation (guest 9201, customer demo-felhom)
Image `felhom-controller:0.140.0` built + pushed on 180; deployed via the bootstrap flow; container
`Up (healthy)`; log `[report] hub wait channel active (hold ≤240s)`; startup report sent.
- Completion-on-difference path: box-side curl with a mismatched `?gen=999999``{"gen":0}` in
**0.047 s** through the real ingress.
- No-annotation hold proof: box-side curl at `?gen=0` held cleanly for **240.04 s** (HTTP 200 →
`{"gen":0}`, **10 heartbeat newlines**); the controller's own Waiter logged **no wait-channel
errors** over the window (holds + reconnects cleanly past nginx's 60 s default). No ingress
annotation applied.
- Scenario C (hub `rollout restart` mid-hold): the Waiter logged **exactly one** WARN (`unexpected
EOF — backing off`), reconnected cleanly, and fired **0 out-of-cycle reports** (gen 0→0, no storm).
The "recovered" INFO is deferred to the next hold completion (`pollOnce` blocks) — cosmetic.
- Save→applied via the operator UI needs an operator login (CC lacks the password by standing
constraint); the mechanism is unit-proven and the delivery chain is the already-live v0.139.0 /
config-refresh path.
- **`controller/internal/report/trigger.go` (new):** buffered-1 signal chan + single worker
(shape: hub `wgsync/reconciler.go`). `Fire()` non-blocking; worker = quiet 2 s → drain →
min-interval 15 s → ONE fire of an opaque `func() error`. Coalesce-and-eventually-fire
(trailing edge) — deliberately NOT the `internal/sync` refuse-debounce. No own retries
(Pusher owns 3×5 s); fire error → one WARN, loop continues; exits on ctx cancel.
- **`controller/cmd/controller/main.go`:** ONE canonical fire closure (BuildReport + Claimed +
Push), built only when `hubPusher != nil && cfg.Hub.Enabled`; `trigger.Run` under the process
ctx; replaced the raw per-fire goroutine behind `apiRouter.SetReportPushTrigger` (v0.70.0 geo
seam kept, now debounced); new `webServer.SetReportTrigger(trigger.Fire)`.
- **`controller/internal/api/router.go`:** `reportPushNow()` added after success in deploy,
remove, delete (geo save/sync already had it).
- **`controller/internal/web`:** `SetReportTrigger`/`reportTriggerNow` seam (server.go, nil-safe,
mirrors `api.Router.reportPushNow`); fired AFTER the successful local commit in:
escrow recovery-code claim (escrow_handlers.go — the headline), notification-prefs save +
app-email toggle (handlers.go; password handler untouched — it is a separate handler),
offsite target config (offbox_handlers.go `offboxConfigHandler`) **and** per-app offsite
toggle (`offboxToggleHandler`), customer claim completion (claim.go).
- Docs: CHANGELOG (v0.139.0 top entry), CONTEXT.md decision ("Direction 1 shipped; Direction 2
pending SPIKE-immediate-sync-transport"), README report section rewritten, REUSE.md
(§1 helper row + §2 coalescing-vs-refuse pattern pair).
### Handler-name mapping vs the spec (per §Part 3 "wire the real one")
| Spec name | Real handler wired |
|---|---|
| "settings POST handler(s) in web/handlers.go" | `settingsNotificationsHandler` + `settingsAppEmailHandler` (both preference/notification-type; `settingsPasswordHandler` is a separate handler → cleanly excluded) |
| "offbox_handlers.go toggle handler" | BOTH `offboxConfigHandler` (target enable/disable) and `offboxToggleHandler` (per-app Távoli-mentés flag) — the spec's "enable/disable" is the config handler; the literal "toggle handler" is per-app; both are hub-visible state |
| "api-router deploy/remove endpoints" | `deployStack`, `removeStack`, `deleteStack` (both removal variants) |
## Commits
| Hash | Content |
|---|---|
| `fe9266f` | feat(report): v0.139.0 — trigger engine + all wiring + tests + docs (15 files, +501/14) |
| (this commit) | docs(report): REPORT.md live-validation evidence |
## Tests & red-proofs
Green gate `go build ./... && go vet ./... && go test ./...` — all packages pass **except 6
pre-existing Windows-environment failures** (appexport disk-free df=0 ×4, stacks
`TestMigrate_PaperlessShape_ScopeApp`, web `TestFab_SelectionsRideBothStartPipelines`) —
**verified failing identically on base `8f3564c`** via `git stash` + rerun. Changed packages:
`internal/report` ok, `internal/api` ok, `internal/web` ok (except the listed pre-existing fab
test). Design gates (template_id, emoji, native_confirm, offbox_rename): all OK. Test functions:
629 total after (+8 new).
New tests (`internal/report/trigger_test.go`, `web/report_trigger_seam_test.go`,
`api/report_trigger_nilsafe_test.go`):
| Group | Test | Result |
|---|---|---|
| A | `TestTrigger_SingleFireExactlyOnePush` (1 fire → exactly 1 push) | PASS |
| A | `TestTrigger_FireNonBlocking` (100× Fire < 1 ms each, buffer full, no worker) | PASS |
| B | `TestTrigger_BurstCoalescesTrailingEdge` (10 fires → 1..2 pushes, last push after last fire) | PASS |
| C | `TestTrigger_FireErrorWorkerContinues` (error isolated, next fire pushes) | PASS |
| §8 | `TestTrigger_CancelDuringWaitExitsPromptly` (<1 s exit mid-wait, 0 fires) | PASS |
| D | `TestReportTriggerNow_NilSeamIsNoOp` (web) + `TestReportPushNow_NilSeamIsNoOp` (api) | PASS |
| — | `TestReportTriggerNow_FiresAfterSuccessfulCommitOnly` (real offbox-toggle handler: 0 on refused save, 1 on committed save) | PASS |
**Red-proof B (run→fail→revert):** replaced `Run` with the naive `for { <-signal; fire() }` loop
(every signal straight to fire) → `TestTrigger_BurstCoalescesTrailingEdge` FAILED with
**"10-fire burst produced 10 pushes, want 1..2"** → reverted → green.
**Red-proof C (run→fail→revert):** added `return` after the fire-error WARN (worker dies on
first error) → `TestTrigger_FireErrorWorkerContinues` FAILED with **"fire count = 1, want >= 2"**
(the subsequent fire never pushed) → reverted → green.
## Deploy (guest 9201, bootstrap flow)
- Built + pushed `gitea.dooplex.hu/admin/felhom-controller:0.139.0` on 180
(digest `73fb3f57…`, 145 MB).
- Deployed: pull + `/etc/felhom-controller-image` + `systemctl restart
felhom-controller-bootstrap.service`.
- Verified: `docker ps` → `…felhom-controller:0.139.0 Up … (healthy)`; startup clean; startup
report `17:34:04Z` "Hub report pushed successfully (2953 bytes)" ↔ hub pod `19:34:04 Received
report from demo-felhom (2953 bytes)` (hub logs are CEST).
## Live validation (real-UI-flow; method stated per doctrine)
**Method:** browser login was not possible (dashboard password is customer-owned; the deploy also
invalidated sessions, and entering credentials via browser automation is out of bounds for CC), so
per the repo doctrine the **exact endpoints the UI invokes** were driven server-side from 180 with
curl — real login POST → session cookie → CSRF token from the real page → real form POSTs. The
password was read into a shell var from Viktor's out-of-band store (never echoed/logged; length 13
only). Residual gap vs a browser: client-side rendering only — no server logic skipped.
**Action:** `bookstack` per-app Távoli-mentés toggle OFF → (6 s later) → ON — value-identical end
state (`offbox: true` confirmed in settings.json afterwards); both POSTs 302 (success flash).
| Event | Controller (UTC) | Hub pod (CEST) | Delta |
|---|---|---|---|
| Toggle OFF POST | 17:42:17 | — | — |
| Out-of-cycle push #1 | **17:42:19** "Hub report pushed successfully (2968 bytes)" | **19:42:19** "Received report from demo-felhom (2968 bytes)" | **2 s after the save** (= quiet window) |
| Toggle ON POST | 17:42:23 | — | — |
| Out-of-cycle push #2 (trailing) | **17:42:34** (2961 bytes) | **19:42:34** (2961 bytes) | 15 s after push #1 (= min-interval pacing; last state carried) |
Round-trip user action → hub: **2 seconds** (previously up to ~15 min). The second push also
live-demonstrates Scenario B's coalescing/pacing (two fires 6 s apart → exactly 2 pushes,
15 s spaced, trailing edge).
**Scheduled cycle unaffected:** the next 15-min `hub-report` tick fired on schedule at
**17:49:00Z** (≈15 min after the 17:34:04Z startup report; the two out-of-cycle pushes at
17:42 did NOT perturb it) — controller "Hub report pushed successfully (2982 bytes)" ↔ hub
`19:49:00 Received report from demo-felhom (2982 bytes)`.
## NOT yet live-validated (unit-covered; shared seam proven live)
- **Escrow recovery-code claim call site** — deliberately NOT exercised (the demo box is
escrowed; un/re-escrowing is Viktor's supervised flow). Covered by unit tests + the identical
`reportTriggerNow` seam proven live via the offbox toggle. Expected effect at Viktor's next
wizard pass: the "megerősítésre vár" card flips in seconds.
- **App deploy/remove call sites** — not exercised live (a deploy/remove on the demo box is not
harmless); same `reportPushNow` seam as geo, now trigger-backed.
- **Customer claim + notification-prefs + app-email call sites** — not exercised live (claim
would reset Viktor's password; the notifications form on this box has an empty stored email —
an "identical re-save" is impossible without picking an email, and the v0.137.0 wipe guard
refuses empty-email-with-events anyway).
## Observations (out of scope — documented, not acted on)
- **BuildReport call-site duplication:** the `BuildReport(...) + r.Claimed = sett.GetClaimed()`
pair still exists at the scheduled job, the startup report, and the debug callback (per spec
§Part 2.1 only the trigger closure was centralized).
- **Copy soften candidate (post-live-proof):** `backups_remote.html` "legfeljebb 15 perc" →
"általában néhány másodperc" is now a truthful later one-liner (spec §Part 3 note).
- **`offboxConfirmEscrowHandler` (legacy manual escrow confirm)** also flips hub-relevant state
but was not in the Viktor-approved wiring list — a candidate for a later one-liner.
- **Windows green gate:** 6 pre-existing environment failures (df=0/rsync-shape); the Linux
build on 180 is the authoritative gate for those packages.
### Coupling
Immediacy needs hub ≥ v0.58.0; against an older hub the wait 404s and the box degrades to the 15-min
cycle. No agent coupling, no `MinAgent`. The agent-plane ep0-poke stays parked in the OOB arc.