635c33d381
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
126 lines
4.7 KiB
Go
126 lines
4.7 KiB
Go
package nightchain
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// ── The missed-backup banner (R-871, `09` decision 110 — the operator's idea) ──────────────────────────────────
|
|
//
|
|
// The household's dashboard says it plainly when a daily backup is missing: when the last one was, that the box
|
|
// was off at the backup time (when the box's own record says so), and a suggested different time. It NEVER changes
|
|
// the time by itself. The household can close it; it stays closed until the NEXT missed backup time, and it goes
|
|
// away by itself after a successful night.
|
|
//
|
|
// "The box's own record of when it was on" is the controller's system-metrics table: one sample a minute, kept 30
|
|
// days (internal/metrics). An hour with at least 30 samples counts as "on" in that hour.
|
|
//
|
|
// Pinned by banner_test.go (TestBanner_*).
|
|
|
|
// StaleAfter: a daily tier with no success for this long shows the banner. 26 h = one night (24 h) plus the chain's
|
|
// own two hours, the same line as the hub's backupStaleAfter — so the household and the operator see the same
|
|
// fact at the same time. Decided by CC — operator may reverse.
|
|
const StaleAfter = 26 * time.Hour
|
|
|
|
// suggestMinDays: an hour is "usually on" when the box was on in it on at least this many of the last 7 days.
|
|
const suggestMinDays = 5
|
|
|
|
// HourDays counts, per Budapest hour of day, on how many of the last 7 days the box was on in that hour.
|
|
type HourDays [24]int
|
|
|
|
// BannerInput is everything the banner rule reads.
|
|
type BannerInput struct {
|
|
Now time.Time
|
|
Window string // W, Budapest HH:MM
|
|
Loc *time.Location
|
|
SeededAt time.Time // the ledger's seed: before it nothing is known
|
|
DBDumpOK time.Time // the last successful database dump
|
|
OffsiteConfigured bool
|
|
OffsiteOK time.Time // the off-site tier's LastSuccess
|
|
Dismissed time.Time // the missed W instant the household closed
|
|
OnAtW func(t time.Time) (on bool, known bool) // was the box on at t (from the metrics record)
|
|
Hours *HourDays // nil = no record
|
|
}
|
|
|
|
// Banner is what the dashboard shows.
|
|
type Banner struct {
|
|
Show bool
|
|
LastBackup time.Time // zero = never
|
|
DaysAgo int
|
|
OffAt string // "02:30" when the record says the box was off at the last backup time; "" = unknown/was on
|
|
Suggest string // "21:00", or "" (no clear pattern — say only "choose a time when the box is usually on")
|
|
MissedAt time.Time // the last backup time that was missed (what a dismissal records)
|
|
}
|
|
|
|
// ComputeBanner is the PURE rule.
|
|
func ComputeBanner(in BannerInput) Banner {
|
|
last := in.DBDumpOK
|
|
if in.OffsiteConfigured && (in.OffsiteOK.IsZero() || in.OffsiteOK.Before(last)) {
|
|
last = in.OffsiteOK
|
|
}
|
|
known := last
|
|
if known.Before(in.SeededAt) {
|
|
known = in.SeededAt // nothing before the seed is judged (an upgrade must not show a banner at once)
|
|
}
|
|
if in.Now.Sub(known) <= StaleAfter {
|
|
return Banner{}
|
|
}
|
|
w, ok := LastInstant(in.Now, in.Window, in.Loc)
|
|
if !ok {
|
|
return Banner{}
|
|
}
|
|
if !w.After(in.Dismissed) {
|
|
return Banner{} // closed by the household, and no backup time has been missed since
|
|
}
|
|
b := Banner{Show: true, LastBackup: last, MissedAt: w}
|
|
if !last.IsZero() {
|
|
b.DaysAgo = int(in.Now.Sub(last).Hours() / 24)
|
|
}
|
|
if in.OnAtW != nil {
|
|
if on, known := in.OnAtW(w); known && !on {
|
|
b.OffAt = w.In(in.Loc).Format("15:04")
|
|
}
|
|
}
|
|
if in.Hours != nil {
|
|
b.Suggest = Suggest(*in.Hours, w.In(in.Loc).Hour())
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Suggest proposes a window start: the LATEST hour H such that H, H+1 and H+2 were all "usually on" (the chain
|
|
// needs about two hours: the dump at W, the second copy at W+1h, the off-site copy at W+1h45m). Latest, because a
|
|
// later evening disturbs the household least. "" when there is no such hour, and "" when the CURRENT window's
|
|
// span is already usually on (a new time would not help; the miss has another cause). Decided by CC — operator
|
|
// may reverse.
|
|
func Suggest(h HourDays, currentHour int) string {
|
|
usual := func(start int) bool {
|
|
for k := 0; k < 3; k++ {
|
|
if h[(start+k)%24] < suggestMinDays {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
if usual(currentHour) {
|
|
return ""
|
|
}
|
|
for start := 23; start >= 0; start-- {
|
|
if usual(start) {
|
|
return fmt.Sprintf("%02d:00", start)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// HourDaysFrom folds per-UTC-hour sample counts (unix hour start → samples) into "on how many of the days was the
|
|
// box on in this Budapest hour": an hour with at least 30 one-minute samples counts as on.
|
|
func HourDaysFrom(counts map[int64]int, loc *time.Location) HourDays {
|
|
var h HourDays
|
|
for start, n := range counts {
|
|
if n >= 30 {
|
|
h[time.Unix(start, 0).In(loc).Hour()]++
|
|
}
|
|
}
|
|
return h
|
|
}
|