feat: alert dedupe, proposal state API, HITL mirrors API approvals, Jev recovering reads the alert condition

第 4 回キャンペーン(2026-10-08)の 3 つの穴を塞ぐ。
- ハーネス: 同じアラート名が ALERT_DEDUPE_S(既定 300 秒)以内に再着したら
  /diagnose を送らず note(dedupe) に記録する。Grafana は Pod ごとに 1 件ずつ
  出し 30 秒後にグループを再送するため、1 障害が診断 4 件・提案 4 件になっていた。
  全件 resolved になれば窓をリセットする。
- ハーネス: GET /proposals/{token} で承認・実行状態を返す。
- hitl: 60 秒ごとに未決の提案をハーネスへ照会し、API で承認された件は
  メッセージを「ハーネス API で承認」に書き換えてタイマーを外す。期限切れ処理の
  直前にも同じ照会を行う。
- AI: Jev 選別の now に alert_condition_now / alert_condition_clear /
  recovered_now を追加し、recovering は recovered_now を読む。SLO に触れない
  障害(replay 停止、メモリ圧迫)で 0.98 を返していた。now の p95 と 5xx 比率に
  route フィルタを付け、判定と同じ式にそろえる。
This commit is contained in:
seon committed 2026-10-09 08:33:41 +09:00
1 parent 8836b37927
commit f8e0aa9122
9 files changed
+229 -9

No files matched your search

+1
View File
@@ -59,6 +59,7 @@ func main() {
} }
connected = true connected = true
bot.RearmPending(ctx) bot.RearmPending(ctx)
go bot.SyncLoop(ctx, 60*time.Second)
} }
mux := http.NewServeMux() mux := http.NewServeMux()
+28 -6
View File
@@ -78,15 +78,15 @@ func (a *Agent) ShadowTriage(ctx context.Context, req Request, prefetched string
rec := &ShadowRecord{Gate: "triage"} rec := &ShadowRecord{Gate: "triage"}
rec.state = map[string]any{ rec.state = map[string]any{
"alert": compactAlert(req.Alert), "alert": compactAlert(req.Alert),
"now": a.nowSummary(ctx), "now": a.nowSummary(ctx, req.Alert),
"samples_last_10m": prefetched, "samples_last_10m": prefetched,
"recent_error_logs": a.recentErrorLogs(ctx), "recent_error_logs": a.recentErrorLogs(ctx),
"slo": "availability: probe_success = 1; latency: api p95 < 0.5 s; errors: 5xx ratio < 1%", "slo": "availability: probe_success = 1; latency: api p95 < 0.5 s; errors: 5xx ratio < 1%",
} }
rec.questions = map[string]jev.Question{ rec.questions = map[string]jev.Question{
"label": {Type: "choice", Instructions: "Which root cause label best explains `alert`, `now`, `samples_last_10m` and `recent_error_logs`?", Criteria: labels}, "label": {Type: "choice", Instructions: "Which root cause label best explains `alert`, `now`, `samples_last_10m` and `recent_error_logs`?", Criteria: labels},
"recovering": {Type: "noul", Instructions: "Look only at `now`: is `now.slo_now_ok` true, meaning the newest probe, the 1-minute p95 and the 1-minute error ratio are all inside `slo` right now?", "recovering": {Type: "noul", Instructions: "Look only at `now`: is `now.recovered_now` true, meaning the alert's own condition no longer holds (`now.alert_condition_clear`) and the newest probe, the 1-minute p95 and the 1-minute error ratio are all inside `slo` right now?",
Criteria: map[string]string{"true": "now.slo_now_ok is true: the symptom is gone in the newest samples", "false": "now.slo_now_ok is false, or the values are missing"}}, Criteria: map[string]string{"true": "now.recovered_now is true: the alert condition is clear and the SLO values are inside", "false": "now.recovered_now is false, or the values are missing"}},
"needs_investigation": {Type: "noul", Instructions: "To choose a safe remediation action, would an operator need evidence that is not in this state (more logs, Kubernetes objects, VM status, database replication status)?", "needs_investigation": {Type: "noul", Instructions: "To choose a safe remediation action, would an operator need evidence that is not in this state (more logs, Kubernetes objects, VM status, database replication status)?",
Criteria: map[string]string{"true": "more evidence is needed before any action", "false": "this state alone is enough to decide the action"}}, Criteria: map[string]string{"true": "more evidence is needed before any action", "false": "this state alone is enough to decide the action"}},
"irreversible_needed": {Type: "noul", Instructions: "Would fixing the root cause shown in this state require an irreversible or human-only operation: promoting the database replica, repairing a database schema or data, or starting or recreating a stopped VM?", "irreversible_needed": {Type: "noul", Instructions: "Would fixing the root cause shown in this state require an irreversible or human-only operation: promoting the database replica, repairing a database schema or data, or starting or recreating a stopped VM?",
@@ -98,7 +98,7 @@ func (a *Agent) ShadowTriage(ctx context.Context, req Request, prefetched string
// nowSummary computes the newest values and the SLO verdict in code (Jev reads text literally and should not compare // nowSummary computes the newest values and the SLO verdict in code (Jev reads text literally and should not compare
// numbers or read the tail of a series: jev-1.13 jaggedness notes). // numbers or read the tail of a series: jev-1.13 jaggedness notes).
func (a *Agent) nowSummary(ctx context.Context) map[string]any { func (a *Agent) nowSummary(ctx context.Context, alert map[string]any) map[string]any {
q := func(promql string) any { q := func(promql string) any {
v, err := a.tools.Instant(ctx, promql) v, err := a.tools.Instant(ctx, promql)
if err != nil || v != v { // NaN if err != nil || v != v { // NaN
@@ -110,8 +110,18 @@ func (a *Agent) nowSummary(ctx context.Context) map[string]any {
probeDB := q(`probe_success{target="db-primary"}`) probeDB := q(`probe_success{target="db-primary"}`)
probeMin1m := q(`min_over_time(probe_success{target="alb"}[1m])`) probeMin1m := q(`min_over_time(probe_success{target="alb"}[1m])`)
probeMin3m := q(`min_over_time(probe_success{target="alb"}[3m])`) probeMin3m := q(`min_over_time(probe_success{target="alb"}[3m])`)
p95 := q(`histogram_quantile(0.95, sum by (le) (rate(demo_http_request_duration_seconds_bucket[1m])))`) p95 := q(`histogram_quantile(0.95, sum by (le) (rate(demo_http_request_duration_seconds_bucket{route=~"orders|price"}[1m])))`)
errRatio := q(`sum(rate(demo_http_requests_total{status=~"5.."}[1m])) / sum(rate(demo_http_requests_total[1m]))`) errRatio := q(`sum(rate(demo_http_requests_total{route=~"orders|price",status=~"5.."}[1m])) / sum(rate(demo_http_requests_total{route=~"orders|price"}[1m]))`)
// the alert's own condition (Grafana annotation `condition`, the rule expression in bool form). A fault that does
// not touch the SLO (replay paused, memory pressure) is "recovering" only when this clears (campaign 3/4: 0.98).
var condNow any
condClear := true
if cond := alertCondition(alert); cond != "" {
condNow = q("max(" + cond + ")")
if v, ok := condNow.(float64); ok {
condClear = v == 0
}
}
restarts := q(`sum(increase(kube_pod_container_status_restarts_total{namespace="demo"}[10m]))`) restarts := q(`sum(increase(kube_pod_container_status_restarts_total{namespace="demo"}[10m]))`)
legacyUp := q(`count(up{job="legacy-api"} == 1)`) legacyUp := q(`count(up{job="legacy-api"} == 1)`)
f := func(v any) (float64, bool) { f := func(v any) (float64, bool) {
@@ -127,10 +137,22 @@ func (a *Agent) nowSummary(ctx context.Context) map[string]any {
return map[string]any{ return map[string]any{
"probe_alb_now": probeALB, "probe_db_primary_now": probeDB, "probe_alb_min_1m": probeMin1m, "probe_alb_min_3m": probeMin3m, "probe_alb_now": probeALB, "probe_db_primary_now": probeDB, "probe_alb_min_1m": probeMin1m, "probe_alb_min_3m": probeMin3m,
"api_p95_1m_s": p95, "api_error_ratio_1m": errRatio, "api_restarts_10m": restarts, "legacy_instances_up": legacyUp, "api_p95_1m_s": p95, "api_error_ratio_1m": errRatio, "api_restarts_10m": restarts, "legacy_instances_up": legacyUp,
"alert_condition_now": condNow, "alert_condition_clear": condClear,
"probe_ok_now": probeOK, "latency_ok_now": latOK, "errors_ok_now": errOK, "slo_now_ok": probeOK && latOK && errOK, "probe_ok_now": probeOK, "latency_ok_now": latOK, "errors_ok_now": errOK, "slo_now_ok": probeOK && latOK && errOK,
"recovered_now": probeOK && latOK && errOK && condClear,
} }
} }
// alertCondition returns the rule expression carried in the alert annotations (empty when absent).
func alertCondition(alert map[string]any) string {
ann, _ := alert["annotations"].(map[string]any)
if ann == nil {
return ""
}
c, _ := ann["condition"].(string)
return c
}
// recentErrorLogs returns a short, newest-first excerpt of api error lines (5 minutes, 8 lines) for the triage state. // recentErrorLogs returns a short, newest-first excerpt of api error lines (5 minutes, 8 lines) for the triage state.
func (a *Agent) recentErrorLogs(ctx context.Context) string { func (a *Agent) recentErrorLogs(ctx context.Context) string {
out := a.tools.Call(ctx, "query_logs", `{"logql":"{namespace=\"demo\", app=\"api\"} |~ \"(?i)error|timeout|refused|SQLSTATE\"","range_minutes":5,"limit":8}`) out := a.tools.Call(ctx, "query_logs", `{"logql":"{namespace=\"demo\", app=\"api\"} |~ \"(?i)error|timeout|refused|SQLSTATE\"","range_minutes":5,"limit":8}`)
+21
View File
@@ -57,6 +57,27 @@ type Proposal struct {
Approved bool `json:"approved"` Approved bool `json:"approved"`
} }
// ProposalState is GET /proposals/{token}: the decision as the harness knows it (approval may come through the API).
type ProposalState struct {
Token string `json:"token"`
ActionID string `json:"action_id"`
Approved bool `json:"approved"`
ApprovedAt *string `json:"approved_at"`
ApprovedBy *string `json:"approved_by"`
Executed bool `json:"executed"`
ExecutedAt *string `json:"executed_at"`
ExpiresAt string `json:"expires_at"`
}
// GetProposal reads the harness-side state of a proposal.
func (c *Client) GetProposal(ctx context.Context, token string) (*ProposalState, error) {
var out ProposalState
if _, err := c.call(ctx, "GET", "/proposals/"+token, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// Propose registers a layer-2 proposal (POST /proposals); the harness records the proposal event and returns the token. // Propose registers a layer-2 proposal (POST /proposals); the harness records the proposal event and returns the token.
func (c *Client) Propose(ctx context.Context, runID, scenarioID *string, actionID string, target, diagnosis map[string]any, ttl time.Duration) (*Proposal, error) { func (c *Client) Propose(ctx context.Context, runID, scenarioID *string, actionID string, target, diagnosis map[string]any, ttl time.Duration) (*Proposal, error) {
body := map[string]any{"run_id": runID, "scenario_id": scenarioID, "action_id": actionID, "target": target, "diagnosis": diagnosis} body := map[string]any{"run_id": runID, "scenario_id": scenarioID, "action_id": actionID, "target": target, "diagnosis": diagnosis}
+45
View File
@@ -230,12 +230,57 @@ func (b *Bot) edit(m *rag.HitlMessage, status string, color int, result map[stri
return b.dc.Edit(m.ChannelID, m.MessageID, em, b.buttons(m.Token, true)) return b.dc.Edit(m.ChannelID, m.MessageID, em, b.buttons(m.Token, true))
} }
// syncOne mirrors an approval recorded by the harness (API path, not the button) into the message. Returns true when
// the message was decided here.
func (b *Bot) syncOne(ctx context.Context, m *rag.HitlMessage) bool {
st, err := b.harness.GetProposal(ctx, m.Token)
if err != nil || !st.Approved {
return false
}
by := "unknown"
if st.ApprovedBy != nil && *st.ApprovedBy != "" {
by = *st.ApprovedBy
}
first, err := b.store.Decide(ctx, m.Token, "approved", by, map[string]any{"via": "harness-api", "executed": st.Executed})
if err != nil || !first {
return false
}
b.disarm(m.Token)
_ = b.edit(m, b.t.StatusApprovedAPI(by, st.Executed), colorApproved, nil)
log.Printf("proposal %s approved outside Discord by %s (executed=%v)", m.Token[len(m.Token)-6:], by, st.Executed)
return true
}
// SyncLoop polls the harness for pending proposals decided outside Discord (campaign 3/4 2026-10-08: approvals through
// POST /proposals/{token}/approve left the message to expire by timer).
func (b *Bot) SyncLoop(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
list, err := b.store.Pending(ctx)
if err != nil {
continue
}
for i := range list {
b.syncOne(ctx, &list[i])
}
}
}
}
func (b *Bot) expire(token string) { func (b *Bot) expire(token string) {
ctx := context.Background() ctx := context.Background()
m, err := b.store.GetHitl(ctx, token) m, err := b.store.GetHitl(ctx, token)
if err != nil { if err != nil {
return return
} }
if b.syncOne(ctx, m) {
return
}
first, err := b.store.Decide(ctx, token, "expired", "timer", nil) first, err := b.store.Decide(ctx, token, "expired", "timer", nil)
if err != nil || !first { if err != nil || !first {
return return
+15 -2
View File
@@ -25,6 +25,7 @@ type Strings struct {
StatusExecFailed func(by, err string) string StatusExecFailed func(by, err string) string
StatusExpiredBefore string StatusExpiredBefore string
StatusNoDecision string StatusNoDecision string
StatusApprovedAPI func(by string, executed bool) string
ExpiryMessage func(actionID, target, runID, scenarioID string) string ExpiryMessage func(actionID, target, runID, scenarioID string) string
EscalationTitle func(label string) string EscalationTitle func(label string) string
EscalationStatus string EscalationStatus string
@@ -48,7 +49,13 @@ var langs = map[string]Strings{
StatusApproveFailed: func(e string) string { return "approval failed: " + e }, StatusApproveFailed: func(e string) string { return "approval failed: " + e },
StatusExecFailed: func(by, e string) string { return "approved by " + by + "; execution failed: " + e }, StatusExecFailed: func(by, e string) string { return "approved by " + by + "; execution failed: " + e },
StatusExpiredBefore: "expired before approval", StatusExpiredBefore: "expired before approval",
StatusNoDecision: "no decision within the window; moved to layer 3", StatusApprovedAPI: func(by string, executed bool) string {
if executed {
return "approved through the harness API by " + by + "; executed"
}
return "approved through the harness API by " + by
},
StatusNoDecision: "no decision within the window; moved to layer 3",
ExpiryMessage: func(a, t, r, s string) string { ExpiryMessage: func(a, t, r, s string) string {
return fmt.Sprintf("Proposal `%s` for `%s` (run %s, scenario %s) received no decision within the approval window. Layer 3: follow the runbook or call the harness `/actions/execute` with the operator token.", a, t, r, s) return fmt.Sprintf("Proposal `%s` for `%s` (run %s, scenario %s) received no decision within the approval window. Layer 3: follow the runbook or call the harness `/actions/execute` with the operator token.", a, t, r, s)
}, },
@@ -72,7 +79,13 @@ var langs = map[string]Strings{
StatusApproveFailed: func(e string) string { return "承認の記録に失敗: " + e }, StatusApproveFailed: func(e string) string { return "承認の記録に失敗: " + e },
StatusExecFailed: func(by, e string) string { return by + " が承認したが実行に失敗: " + e }, StatusExecFailed: func(by, e string) string { return by + " が承認したが実行に失敗: " + e },
StatusExpiredBefore: "承認前に期限切れ", StatusExpiredBefore: "承認前に期限切れ",
StatusNoDecision: "期限内に判断がなかったため第3層へ引き継ぎ", StatusApprovedAPI: func(by string, executed bool) string {
if executed {
return by + " がハーネス API で承認、実行済み"
}
return by + " がハーネス API で承認"
},
StatusNoDecision: "期限内に判断がなかったため第3層へ引き継ぎ",
ExpiryMessage: func(a, t, r, s string) string { ExpiryMessage: func(a, t, r, s string) string {
return fmt.Sprintf("提案 `%s`(対象 `%s`、run %s、シナリオ %s)は承認期限内に判断されませんでした。第3層の対応です。ランブックに従って手動で対処するか、オペレーター用トークンでハーネスの `/actions/execute` を呼び出してください。", a, t, r, s) return fmt.Sprintf("提案 `%s`(対象 `%s`、run %s、シナリオ %s)は承認期限内に判断されませんでした。第3層の対応です。ランブックに従って手動で対処するか、オペレーター用トークンでハーネスの `/actions/execute` を呼び出してください。", a, t, r, s)
}, },
+48 -1
View File
@@ -24,10 +24,14 @@ type AlertRouter struct {
mu sync.Mutex mu sync.Mutex
timers map[string]*time.Timer // fingerprint -> escalation timer timers map[string]*time.Timer // fingerprint -> escalation timer
firing map[string]string // fingerprint -> alertname while Grafana reports it firing (judge: no recovery with an open alert) firing map[string]string // fingerprint -> alertname while Grafana reports it firing (judge: no recovery with an open alert)
// alertname+scenario -> when the last layer-2 diagnosis was dispatched. Grafana emits one alert per label set
// (ApiMemoryPressure: one per pod) and resends the group 30 s later; campaign 4 (2026-10-08) turned one fault into
// four diagnoses and four proposals. Inside cfg.AlertDedupeS the later alerts are recorded and skipped.
lastDiag map[string]time.Time
} }
func NewAlertRouter(a *App) *AlertRouter { func NewAlertRouter(a *App) *AlertRouter {
return &AlertRouter{app: a, timers: map[string]*time.Timer{}, firing: map[string]string{}} return &AlertRouter{app: a, timers: map[string]*time.Timer{}, firing: map[string]string{}, lastDiag: map[string]time.Time{}}
} }
// Firing lists the alert names currently firing (deduplicated, sorted). // Firing lists the alert names currently firing (deduplicated, sorted).
@@ -113,6 +117,7 @@ func (r *AlertRouter) handleWebhook(w http.ResponseWriter, req *http.Request) {
r.setFiring(al.Fingerprint, name, al.Status == "firing" && sig == "") r.setFiring(al.Fingerprint, name, al.Status == "firing" && sig == "")
if al.Status != "firing" { if al.Status != "firing" {
r.cancelTimer(al.Fingerprint) r.cancelTimer(al.Fingerprint)
r.clearDedupe(name, sid)
continue continue
} }
if sig != "" { if sig != "" {
@@ -129,11 +134,53 @@ func (r *AlertRouter) handleWebhook(w http.ResponseWriter, req *http.Request) {
r.startEscalation(al.Fingerprint, payload, runID, sid) r.startEscalation(al.Fingerprint, payload, runID, sid)
continue continue
} }
if since, dup := r.dedupe(name, sid); dup {
r.app.store.Event(bg, runID, sid, "note", "runner", map[string]any{"phase": "dedupe", "alertname": name, "fingerprint": al.Fingerprint,
"since_last_diagnosis_s": since.Seconds(), "note": "same alert name already under diagnosis; no second /diagnose"})
continue
}
go r.diagnose(payload, runID, sid) go r.diagnose(payload, runID, sid)
} }
writeJSON(w, 200, map[string]any{"received": handled, "run_id": runID, "scenario_id": sid}) writeJSON(w, 200, map[string]any{"received": handled, "run_id": runID, "scenario_id": sid})
} }
// dedupe reports whether a layer-2 diagnosis for this alert name (within the scenario) was dispatched less than
// cfg.AlertDedupeS ago; when not, it records now as the dispatch time.
func (r *AlertRouter) dedupe(name string, sid *string) (time.Duration, bool) {
if r.app.cfg.AlertDedupeS <= 0 {
return 0, false
}
key := name
if sid != nil {
key += "|" + *sid
}
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
if t, ok := r.lastDiag[key]; ok && now.Sub(t) < time.Duration(r.app.cfg.AlertDedupeS)*time.Second {
return now.Sub(t), true
}
r.lastDiag[key] = now
return 0, false
}
// clearDedupe forgets the dispatch time once no alert of that name is firing any more, so the next firing starts a
// new diagnosis even inside the window.
func (r *AlertRouter) clearDedupe(name string, sid *string) {
key := name
if sid != nil {
key += "|" + *sid
}
r.mu.Lock()
defer r.mu.Unlock()
for _, n := range r.firing {
if n == name {
return
}
}
delete(r.lastDiag, key)
}
func targetFromLabels(l map[string]string) map[string]any { func targetFromLabels(l map[string]string) map[string]any {
t := map[string]any{} t := map[string]any{}
for _, k := range []string{"namespace", "pod", "deployment", "node", "instance"} { for _, k := range []string{"namespace", "pod", "deployment", "node", "instance"} {
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"testing"
"time"
)
// one fault, several alerts with the same name (per-pod label sets, group resend): one diagnosis inside the window
func TestDedupeSameAlertName(t *testing.T) {
r := NewAlertRouter(&App{cfg: Config{AlertDedupeS: 300}})
sid := "C-G2"
if _, dup := r.dedupe("ApiMemoryPressure", &sid); dup {
t.Fatal("first alert must be diagnosed")
}
if _, dup := r.dedupe("ApiMemoryPressure", &sid); !dup {
t.Fatal("second alert inside the window must be skipped")
}
other := "V-G1"
if _, dup := r.dedupe("ApiMemoryPressure", &other); dup {
t.Fatal("another scenario is a new fault")
}
// all alerts of that name resolved -> the next firing starts a new diagnosis
r.setFiring("fp1", "ApiMemoryPressure", true)
r.clearDedupe("ApiMemoryPressure", &sid) // still firing: keep
if _, dup := r.dedupe("ApiMemoryPressure", &sid); !dup {
t.Fatal("still firing: keep the window")
}
r.setFiring("fp1", "ApiMemoryPressure", false)
r.clearDedupe("ApiMemoryPressure", &sid)
if _, dup := r.dedupe("ApiMemoryPressure", &sid); dup {
t.Fatal("after resolve the next firing is a new fault")
}
// window expiry
r.lastDiag["ApiMemoryPressure|"+sid] = time.Now().Add(-301 * time.Second)
if _, dup := r.dedupe("ApiMemoryPressure", &sid); dup {
t.Fatal("outside the window a new diagnosis is allowed")
}
// off
r2 := NewAlertRouter(&App{cfg: Config{AlertDedupeS: 0}})
if _, dup := r2.dedupe("X", nil); dup {
t.Fatal("dedupe off")
}
if _, dup := r2.dedupe("X", nil); dup {
t.Fatal("dedupe off")
}
}
+13
View File
@@ -9,6 +9,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -33,6 +34,7 @@ type Config struct {
DBPassFile string // harness role password on db-primary / db-replica DBPassFile string // harness role password on db-primary / db-replica
HumanTokFile string HumanTokFile string
AIDiagnoseURL string AIDiagnoseURL string
AlertDedupeS int // layer-2 alerts with the same name inside this window share one diagnosis (0 = off)
ScenarioDir string ScenarioDir string
JudgeEvery time.Duration JudgeEvery time.Duration
ObserveMax time.Duration ObserveMax time.Duration
@@ -62,6 +64,7 @@ func loadConfig() Config {
DBPassFile: env("DB_PASSWORD_FILE", "/run/secrets/db-password"), DBPassFile: env("DB_PASSWORD_FILE", "/run/secrets/db-password"),
HumanTokFile: env("HUMAN_TOKEN_FILE", "/run/secrets/harness-token"), HumanTokFile: env("HUMAN_TOKEN_FILE", "/run/secrets/harness-token"),
AIDiagnoseURL: env("AI_DIAGNOSE_URL", "http://10.20.0.12:8080/diagnose"), AIDiagnoseURL: env("AI_DIAGNOSE_URL", "http://10.20.0.12:8080/diagnose"),
AlertDedupeS: envInt("ALERT_DEDUPE_S", 300),
ScenarioDir: env("SCENARIO_DIR", "/etc/harness/scenarios"), ScenarioDir: env("SCENARIO_DIR", "/etc/harness/scenarios"),
JudgeEvery: 30 * time.Second, JudgeEvery: 30 * time.Second,
ObserveMax: 30 * time.Minute, ObserveMax: 30 * time.Minute,
@@ -115,6 +118,7 @@ func main() {
mux.HandleFunc("POST /runs/{id}/abort", app.handleRunAbort) mux.HandleFunc("POST /runs/{id}/abort", app.handleRunAbort)
mux.HandleFunc("POST /hooks/alert", app.hooks.handleWebhook) mux.HandleFunc("POST /hooks/alert", app.hooks.handleWebhook)
mux.HandleFunc("POST /proposals", app.handleProposal) mux.HandleFunc("POST /proposals", app.handleProposal)
mux.HandleFunc("GET /proposals/{token}", app.handleProposalGet)
mux.HandleFunc("POST /proposals/{token}/approve", app.handleProposalApprove) mux.HandleFunc("POST /proposals/{token}/approve", app.handleProposalApprove)
mux.HandleFunc("POST /proposals/{token}/reject", app.handleProposalReject) mux.HandleFunc("POST /proposals/{token}/reject", app.handleProposalReject)
mux.HandleFunc("POST /actions/execute", app.handleActionExecute) mux.HandleFunc("POST /actions/execute", app.handleActionExecute)
@@ -208,3 +212,12 @@ func (a *App) handleScenarios(w http.ResponseWriter, r *http.Request) {
sortScenarios(list) sortScenarios(list)
writeJSON(w, 200, list) writeJSON(w, 200, list)
} }
func envInt(k string, d int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return d
}
+12
View File
@@ -62,6 +62,18 @@ func (a *App) handleProposal(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 201, map[string]any{"token": token, "expires_in_s": ttl.Seconds(), "approved": req.Approved}) writeJSON(w, 201, map[string]any{"token": token, "expires_in_s": ttl.Seconds(), "approved": req.Approved})
} }
// GET /proposals/{token}: the HITL bot reads the decision state (approval may arrive through this API instead of the
// Discord button; the bot mirrors it into its message instead of letting the message expire, campaign 3/4 2026-10-08).
func (a *App) handleProposalGet(w http.ResponseWriter, r *http.Request) {
p, err := a.store.GetProposal(r.Context(), r.PathValue("token"))
if err != nil {
writeErr(w, 404, errors.New("unknown proposal"))
return
}
writeJSON(w, 200, map[string]any{"token": p.Token, "run_id": p.RunID, "scenario_id": p.ScenarioID, "action_id": p.ActionID,
"approved": p.ApprovedAt != nil, "approved_at": p.ApprovedAt, "approved_by": p.ApprovedBy, "executed": p.ExecutedAt != nil, "executed_at": p.ExecutedAt, "expires_at": p.ExpiresAt})
}
// POST /proposals/{token}/approve: the HITL bot records the human decision (design 6.5 step 4). The proposal was // POST /proposals/{token}/approve: the HITL bot records the human decision (design 6.5 step 4). The proposal was
// registered at diagnosis time, so MTTA = approval - proposal (design 5.4) measures the real waiting time. // registered at diagnosis time, so MTTA = approval - proposal (design 5.4) measures the real waiting time.
func (a *App) handleProposalApprove(w http.ResponseWriter, r *http.Request) { func (a *App) handleProposalApprove(w http.ResponseWriter, r *http.Request) {