[refactor](alarm): end alerts by disarm or expiry, not a ring timer

An alert now rings from trigger until the owner disarms it; a subscriber silences only its own device. The owner instead sets a persistence cap (default 24h, hard cap 14 days) and the sweeper destroys expired alerts. Disarm ends an armed alert too, and every ending (delivered, expired, disarmed, deleted) converges on the one destruction path so their event-log reasons cannot drift.
This commit is contained in:
2026-10-19 02:26:31 +01:00
parent f055700c2d
commit 3a2da52b7a
12 changed files with 658 additions and 246 deletions
+98 -42
View File
@@ -16,22 +16,23 @@
package actions
import (
"errors"
"net/http"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/deviceid"
"git.dcntrlized.org/Dcntrlized/Alarm/realtime"
)
// actionResponse is the trigger/disarm result.
type actionResponse struct {
EventID string `json:"eventId"`
Action string `json:"action"`
ServerTime string `json:"serverTime"`
AutoStopSeconds int `json:"autoStopSeconds"`
Delivered int `json:"delivered"`
EventID string `json:"eventId"`
Action string `json:"action"`
ServerTime string `json:"serverTime"`
Delivered int `json:"delivered"`
}
type triggerRequest struct {
@@ -76,23 +77,21 @@ func TriggerAlert(w http.ResponseWriter, r *http.Request, payload any) {
delivered := 0
if hub := realtime.Current(); hub != nil {
delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{
Type: "alarm",
AlertID: updated.ID.String(),
EventID: event.ID.String(),
Action: alertstate.KindTrigger,
Message: deref(event.Message),
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
Type: "alarm",
AlertID: updated.ID.String(),
EventID: event.ID.String(),
Action: alertstate.KindTrigger,
Message: deref(event.Message),
ServerTime: rfc3339(event.CreatedAt),
})
}
Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered)
writeJSON(w, http.StatusOK, actionResponse{
EventID: event.ID.String(),
Action: alertstate.KindTrigger,
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
Delivered: delivered,
EventID: event.ID.String(),
Action: alertstate.KindTrigger,
ServerTime: rfc3339(event.CreatedAt),
Delivered: delivered,
})
}
@@ -113,41 +112,79 @@ func DisarmAlert(w http.ResponseWriter, r *http.Request, payload any) {
delivered := 0
if hub := realtime.Current(); hub != nil {
delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{
Type: "alarm",
AlertID: updated.ID.String(),
EventID: event.ID.String(),
Action: alertstate.KindDisarm,
Message: deref(event.Message),
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
Type: "alarm",
AlertID: updated.ID.String(),
EventID: event.ID.String(),
Action: alertstate.KindDisarm,
Message: deref(event.Message),
ServerTime: rfc3339(event.CreatedAt),
})
}
Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered)
writeJSON(w, http.StatusOK, actionResponse{
EventID: event.ID.String(),
Action: alertstate.KindDisarm,
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
Delivered: delivered,
EventID: event.ID.String(),
Action: alertstate.KindDisarm,
ServerTime: rfc3339(event.CreatedAt),
Delivered: delivered,
})
}
// offlineAlarmResponse is the 409 check-in body: the standard error envelope plus
// the alarm frame the device missed while offline. 409 is a status, not a
// failure: it means "an alarm is pending and must ring now".
type offlineAlarmResponse struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Alarm realtime.AlarmFrame `json:"alarm"`
}
// writeOfflineAlarm answers alarmStatus with 409 ERR_ALARM_TRIGGERED_OFFLINE and
// the missed trigger frame. The frame is the same shape the socket would have
// delivered (same eventId, same serverTime), so the client renders an offline
// alarm with the code path it already has for a live one.
func writeOfflineAlarm(w http.ResponseWriter, alert *db.Alert, pending *db.PendingDelivery) {
message := ""
switch {
case pending.Message != nil:
message = *pending.Message
case alert.Message != nil:
message = *alert.Message
}
var body offlineAlarmResponse
body.Error.Code = CodeAlarmTriggeredOffline
body.Error.Message = "an alarm was triggered while this device was offline; ring now"
body.Alarm = realtime.AlarmFrame{
Type: "alarm",
AlertID: alert.ID.String(),
EventID: pending.EventID.String(),
Action: alertstate.KindTrigger,
Message: message,
ServerTime: rfc3339(pending.ServerTime),
}
writeJSON(w, http.StatusConflict, body)
}
// statusView is the alarmStatus polling fallback payload (frozen field set).
type statusView struct {
Active bool `json:"active"`
EventID string `json:"eventId"`
Action string `json:"action"`
Message string `json:"message"`
ServerTime string `json:"serverTime"`
AutoStopSeconds int `json:"autoStopSeconds"`
Active bool `json:"active"`
EventID string `json:"eventId"`
Action string `json:"action"`
Message string `json:"message"`
ServerTime string `json:"serverTime"`
}
// AlarmStatus implements GET/POST /action/alarmStatus: the cheap, indexed
// fallback a client polls when its socket is down or it was killed mid-alarm.
// fallback a client polls when its socket is down or it was killed mid-alarm. It
// also serves as the offline check-in (409 ERR_ALARM_TRIGGERED_OFFLINE) and is
// where a missed alarm's ledger row is retired.
//
// `active` is derived (status + auto-stop window), never stored, so no
// background job is needed to expire an alarm.
// `active` is derived from the commanded status, never stored and never
// clock-based: an alert is active from trigger until the owner disarms it, so no
// background job is needed to expire anything.
func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
const action = "alarmStatus"
p, ok := principal(w, r, action)
@@ -175,8 +212,28 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
return
}
// Offline check-in, READ-ONLY. If this device still owes an alarm, answer
// with the frame it missed instead of a quiet 200 — and clear NOTHING. The
// debt is settled only by an explicit ack (ackAlarm or the WS ack), because a
// 409 lost in transit must not forfeit the alarm: the next check-in has to
// find the same row. A trigger that has since been disarmed is not news, so
// that case falls through to the normal body (active:false).
deviceHash := ""
if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
deviceHash = deviceid.Hash(raw)
}
pending, err := db.PendingTrigger(r.Context(), alertID, p.ID, deviceHash)
if err != nil && !errors.Is(err, db.ErrNotFound) {
dbError(w, action, err)
return
}
if pending != nil && res.Event != nil && res.Event.Kind == alertstate.KindTrigger {
writeOfflineAlarm(w, res.Alert, pending)
return
}
now := time.Now().UTC()
active := alertstate.IsActive(res.Alert.Status, res.Alert.TriggeredAt, res.Alert.AutoStopSeconds, now)
active := alertstate.IsActive(res.Alert.Status)
if res.Event == nil || res.Event.Kind != alertstate.KindTrigger {
// A never-triggered alert, or one whose newest event is a disarm, is
// not live even if the stored status says "triggered".
@@ -184,9 +241,8 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
}
view := statusView{
Active: active,
ServerTime: rfc3339(now),
AutoStopSeconds: res.Alert.AutoStopSeconds,
Active: active,
ServerTime: rfc3339(now),
}
if res.Event != nil {
view.EventID = res.Event.ID.String()