diff --git a/CONTEXT.md b/CONTEXT.md index e864dbc..b996362 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,23 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-16 (v0.139.0 — immediate out-of-cycle hub report, Direction 1) +Last updated: 2026-07-16 (v0.140.0 — Direction-2 immediate-sync wait channel; the immediate-sync arc is COMPLETE for the controller plane) + +> **2026-07-16 — v0.140.0: Direction-2 immediate-sync (hub→box) SHIPPED.** The reverse of v0.139.0: +> an OPERATOR action on the hub now reaches the box in seconds. `report.Waiter` +> (`internal/report/waiter.go`) holds a hanging `GET {hub}/api/v1/wait?gen=N` (same hub URL+key as +> the pusher — no new config keys) against hub ≥ v0.58.0's in-memory operator-intent generation +> counter; on a generation CHANGE it fires the v0.139.0 `report.Trigger` and nothing else, so the +> report ACK delivers everything through the UNCHANGED machinery (the box pulls even the wake-up). +> No overall client timeout (held GET); first-observation records-not-fires (no restart echo); +> same-gen timeout fires nothing; errors incl. a 404 from a pre-v0.58.0 hub back off 5s→5min while +> the 15-min cycle reconciles. Gated on the SAME `hubPusher!=nil && Hub.Enabled` as the trigger. +> Copy soften: backups_remote/escrow "néhány **másodperc**, legfeljebb 15 perc" (15-min bound stays +> as the honest worst case). Red-proof: baseline-branch-off → first-obs fires (reverted). Agent-plane +> poke is PARKED in the OOB arc (spike P4). Deployed to 9201; live-validated (hold + immediacy). +> Detail: `CHANGELOG.md` v0.140.0, `controller/README.md` §9, `REPORT.md`. + +Last updated (prior): 2026-07-16 (v0.139.0 — immediate out-of-cycle hub report, Direction 1) > **2026-07-16 — v0.139.0: immediate out-of-cycle hub report on user actions (Direction 1 > SHIPPED; Direction 2 pending SPIKE-immediate-sync-transport).** Viktor's ruling: user actions diff --git a/REPORT.md b/REPORT.md index 8570487..961adf2 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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.