feat(ai): add AI VM services (diagnosis agent, pgvector RAG, Discord HITL bot) and harness approval endpoints

실험 설계서 6장(WP-12)의 AI VM 2층 구현.
- cmd/agent: 하네스 /diagnose 수신 → OpenAI Responses API 함수 호출 루프(읽기 전용 도구 7종:
  query_metrics, query_logs, get_probe_status, get_k8s, get_gcp_instances, get_aws_state,
  search_runbooks) → strict 스키마 submit_diagnosis(6.3) → 하네스 /proposals 등록 + HITL 게시,
  또는 신뢰도 0.5 미만·escalate 시 3층 큐 에스컬레이션. 세션·프롬프트 버전·러너북 벡터는
  pgvector(ai 스키마)에 저장. 라벨·조치 enum은 하네스 /scenarios·/actions/catalog에서 갱신.
- cmd/hitl: Discord Gateway 봇(intents GUILDS만). 승인/거부 버튼(custom_id approve:/reject:<token>),
  3초 내 DEFERRED_UPDATE_MESSAGE, 승인 시 하네스 승인 → /actions/execute → 원 메시지 편집,
  15분 무응답 시 에스컬레이션 채널로 이동. 재기동 시 미결 제안 타이머 복원.
- harness: POST /proposals/{token}/approve, /reject 추가. 제안은 진단 시점에 등록하고 승인은
  버튼 시점에 기록해 MTTA(승인-제안)가 실제 대기 시간이 되도록 함.
OpenAI·Discord 토큰은 Secret Manager 파일로만 읽고 코드·저장소에 두지 않는다.
This commit is contained in:
seon committed 2026-10-08 01:36:10 +09:00
1 parent de076a79e4
commit f30af2a4c3
21 files changed
+2875 -1

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
// hitl: Discord approval bot of the AI VM (experiment design 6.5). HTTP on :8081 for the agent; Gateway for buttons.
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/bwmarrin/discordgo"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/harness"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/hitl"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/rag"
)
func env(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func main() {
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
ctx := context.Background()
store, err := rag.New(ctx, env("RAG_DSN", "postgres://ai:ai@rag-db:5432/ai?sslmode=disable"))
if err != nil {
log.Fatalf("rag: %v", err)
}
cfg := hitl.Config{GuildID: env("DISCORD_GUILD_ID", ""), ApprovalsChannel: env("DISCORD_APPROVALS_CHANNEL_ID", ""), EscalationChannel: env("DISCORD_ESCALATION_CHANNEL_ID", ""), ApproverRoleID: env("DISCORD_APPROVER_ROLE_ID", "")}
tokB, err := os.ReadFile(env("DISCORD_TOKEN_FILE", "/run/secrets/discord-bot-token"))
token := strings.TrimSpace(string(tokB))
connected := false
var bot *hitl.Bot
hc := harness.New(env("HARNESS_URL", "http://10.20.0.11:8080"))
var dg *discordgo.Session
if err != nil || token == "" {
log.Printf("discord token missing (%v); running degraded: /post and /escalate return 503 until the secret exists", err)
} else {
dg, err = discordgo.New("Bot " + token)
if err != nil {
log.Fatalf("discord session: %v", err)
}
// Minimum intent set (design 11.7): GUILDS only. INTERACTION_CREATE carries no intent requirement; no privileged intents.
dg.Identify.Intents = discordgo.IntentsGuilds
bot = hitl.New(cfg, hitl.Session{S: dg}, hc, store)
dg.AddHandler(bot.HandleInteraction)
dg.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) {
log.Printf("discord ready: user=%s guilds=%d", r.User.Username, len(r.Guilds))
})
if err := dg.Open(); err != nil {
log.Fatalf("discord open: %v", err)
}
connected = true
bot.RearmPending(ctx)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) })
mux.HandleFunc("GET /selfcheck", func(w http.ResponseWriter, r *http.Request) {
out := map[string]any{"discord_connected": connected, "guild": cfg.GuildID, "approvals_channel": cfg.ApprovalsChannel, "escalation_channel": cfg.EscalationChannel, "approver_role": cfg.ApproverRoleID}
if dg != nil {
if ch, err := dg.Channel(cfg.ApprovalsChannel); err == nil {
out["approvals_channel_name"] = ch.Name
} else {
out["approvals_channel_error"] = err.Error()
}
}
pend, _ := store.Pending(r.Context())
out["pending"] = len(pend)
writeJSON(w, 200, out)
})
mux.HandleFunc("POST /post", func(w http.ResponseWriter, r *http.Request) {
if !connected {
writeJSON(w, 503, map[string]string{"error": "discord not connected"})
return
}
var req hitl.PostRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Token == "" {
writeJSON(w, 400, map[string]string{"error": "bad request"})
return
}
if err := bot.Post(r.Context(), req); err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 201, map[string]string{"status": "posted"})
})
mux.HandleFunc("POST /escalate", func(w http.ResponseWriter, r *http.Request) {
if !connected {
writeJSON(w, 503, map[string]string{"error": "discord not connected"})
return
}
var req hitl.EscalateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, 400, map[string]string{"error": "bad request"})
return
}
if err := bot.Escalate(r.Context(), req); err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 201, map[string]string{"status": "escalated"})
})
srv := &http.Server{Addr: env("HITL_ADDR", ":8081"), Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go func() { log.Fatal(srv.ListenAndServe()) }()
log.Printf("hitl up: connected=%v", connected)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
if dg != nil {
_ = dg.Close()
}
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}