package latest import ( "context" "fmt" "time" "bookmarkmanager/backend/internal/store" ) // ConditionStall is the owner-notice machine word for a Lane that owed Polls, // made none, and has nothing to say for it. The word is the message's footer // and its suppression key; it is wire-stable. #172 declares the other three // words (no-browser-route, sidecar-down, adapter-broken); this ticket // declares only the stall. const ConditionStall = "stall" // OwnerWindow is the class-level staleness boundary every owner-notice // condition measures against — the same twelve hours the Lanes page's // "not checked in 12h" filter uses (internal/web/admin.go). Declared here // once so #172's three conditions and the admin filters share one figure. const OwnerWindow = 12 * time.Hour // Fault is one condition the owner is told about, judged from durable rows // alone. Site is "" for a fault that is not one Site's. type Fault struct { Condition string // one of the Condition* words Site string Since int64 // unix ms the episode began; the message's age } // FaultInput is everything the judgement reads. A struct so #172's three // conditions can add inputs without changing either caller. type FaultInput struct { Passes []store.LanePass } // FaultsFrom judges the owner-notice conditions from durable rows alone, so // the poller and the landing page cannot disagree about what a fault is. // // A Lane stalls when its latest pass shows due > 0, none checked, no skip // value and no refusal — exactly the row the mid-loop browser loss writes // (see the comment at the outcomeUnreachable return in runLanePass), so a // Lane that owed Polls, made none, and has nothing to say for it is a fault. // The no-refusal clause is AC6's amendment: the twice-refused break happens // inside the pass loop, not on an early return, so a newly-gated Site would // otherwise send two messages. A pause is excluded by Skip == "" (SkipPaused): // the owner's own act is not reported back (AC10). The episode began at the // pass that produced the row; the stall test itself has no age clause — the // class-level twelve hours belongs to #172's conditions. func FaultsFrom(in FaultInput, now time.Time) []Fault { var faults []Fault for _, p := range in.Passes { if p.Due > 0 && p.Checked == 0 && p.Skip == "" && p.Refused == 0 { faults = append(faults, Fault{Condition: ConditionStall, Site: p.Site, Since: p.RanAt}) } } return faults } // Notifier delivers one owner notice. The poller neither retries nor queues: // an error is logged and the suppression row left unwritten, so the next pass // tries again while the condition holds. type Notifier interface { Notify(ctx context.Context, f Fault, sentence, href string) error } // ownerNoticeConditions is every condition this package judges, for the // clear loop in recordPass: a condition absent from a pass's fault list // forgets its episode, so the next occurrence sends again. #172 extends the // list when it adds its conditions. var ownerNoticeConditions = []string{ConditionStall} // noticeFor renders one fault's message: the description sentence — condition, // age, repair — and the deep link the embed's title points at. Each condition // provides its own wording; the stall is the only one today (issue #171). func noticeFor(f Fault, row store.LanePass, now time.Time) (sentence, href string) { switch f.Condition { case ConditionStall: return fmt.Sprintf( "%s owed %d Polls and made none — %s; check the Lane's browser sidecar and the Site's challenge state", f.Site, row.Due, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes" } return "", "" } // humanAge renders a duration the way an owner reads it in a message: minutes // under an hour, then hours, then days; a stall that was just born reads // "just now". func humanAge(d time.Duration) string { switch { case d < time.Minute: return "just now" case d < time.Hour: return fmt.Sprintf("%dm", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%dh", int(d.Hours())) default: return fmt.Sprintf("%dd", int(d.Hours()/24)) } }