All checks were successful
release-tag / release-image (push) Successful in 2m32s
570 lines
22 KiB
Go
570 lines
22 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/local/glpi-neural-brain/internal/activity"
|
|
"github.com/local/glpi-neural-brain/internal/engine"
|
|
"github.com/local/glpi-neural-brain/internal/graph"
|
|
"github.com/local/glpi-neural-brain/internal/model"
|
|
"github.com/local/glpi-neural-brain/internal/sourceagent"
|
|
)
|
|
|
|
//go:embed static/*
|
|
var assets embed.FS
|
|
|
|
type Server struct {
|
|
Engine *engine.Engine
|
|
Graph *graph.Store
|
|
Broker *activity.Broker
|
|
APIKey string
|
|
PublicURL string
|
|
SourceAgents *sourceagent.Store
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /api/status", s.handleStatus)
|
|
mux.HandleFunc("GET /api/graph", s.handleGraph)
|
|
mux.HandleFunc("GET /api/analysis", s.handleAnalysis)
|
|
mux.HandleFunc("GET /api/analysis/dashboard", s.handleAnalysisDashboard)
|
|
mux.HandleFunc("GET /api/analysis/export", s.handleAnalysisExport)
|
|
mux.HandleFunc("GET /api/runtime-settings", s.handleGetRuntimeSettings)
|
|
mux.HandleFunc("PUT /api/runtime-settings", s.handleSetRuntimeSettings)
|
|
mux.HandleFunc("GET /api/sources", s.handleSources)
|
|
mux.HandleFunc("GET /api/research/status", s.handleResearchStatus)
|
|
mux.HandleFunc("POST /api/research/test", s.handleResearchTest)
|
|
mux.HandleFunc("GET /api/research/tasks", s.handleResearchTasks)
|
|
mux.HandleFunc("POST /api/research/tasks", s.handleCreateResearchTask)
|
|
mux.HandleFunc("POST /api/research/tasks/{id}/cancel", s.handleCancelResearchTask)
|
|
mux.HandleFunc("POST /api/research/autonomous/scan", s.handleAutonomousResearchScan)
|
|
mux.HandleFunc("POST /api/research/autonomous/run", s.handleAutonomousResearchRun)
|
|
mux.HandleFunc("GET /api/stream", s.Broker.ServeSSE)
|
|
mux.HandleFunc("POST /api/query", s.handleQuery)
|
|
mux.HandleFunc("POST /api/events", s.handleEvent)
|
|
mux.HandleFunc("POST /api/reindex", s.handleReindex)
|
|
mux.HandleFunc("POST /api/enrich", s.handleEnrich)
|
|
mux.HandleFunc("POST /api/glpi-kb/sync", s.handleGLPIKBSync)
|
|
mux.HandleFunc("POST /api/flush", s.handleFlush)
|
|
mux.HandleFunc("GET /api/state/export", s.handleStateExport)
|
|
mux.HandleFunc("GET /api/source-agents", s.handleListSourceAgents)
|
|
mux.HandleFunc("POST /api/source-agents", s.handleCreateSourceAgent)
|
|
mux.HandleFunc("PATCH /api/source-agents/{id}", s.handlePatchSourceAgent)
|
|
mux.HandleFunc("DELETE /api/source-agents/{id}", s.handleDeleteSourceAgent)
|
|
mux.HandleFunc("POST /api/source-agents/{id}/rotate-token", s.handleRotateSourceAgentToken)
|
|
mux.HandleFunc("GET /api/source-agents/{id}/tasks", s.handleListSourceTasks)
|
|
mux.HandleFunc("POST /api/source-agents/{id}/tasks", s.handleCreateSourceTask)
|
|
mux.HandleFunc("PUT /api/source-tasks/{id}", s.handleUpdateSourceTask)
|
|
mux.HandleFunc("DELETE /api/source-tasks/{id}", s.handleDeleteSourceTask)
|
|
mux.HandleFunc("GET /api/source-inbox", s.handleSourceInbox)
|
|
mux.HandleFunc("GET /api/source-inbox/status", s.handleSourceInboxStatus)
|
|
mux.HandleFunc("GET /api/controller/policy", s.handleGetControllerPolicy)
|
|
mux.HandleFunc("PUT /api/controller/policy", s.handleSetControllerPolicy)
|
|
mux.HandleFunc("GET /api/controller/profiles", s.handleListControllerProfiles)
|
|
mux.HandleFunc("POST /api/controller/profiles", s.handleCreateControllerProfile)
|
|
mux.HandleFunc("PUT /api/controller/profiles/{id}", s.handleUpdateControllerProfile)
|
|
mux.HandleFunc("DELETE /api/controller/profiles/{id}", s.handleDeleteControllerProfile)
|
|
mux.HandleFunc("POST /api/controller/profiles/{id}/run", s.handleRunControllerProfile)
|
|
mux.HandleFunc("GET /api/controller/jobs", s.handleListControllerJobs)
|
|
mux.HandleFunc("POST /api/controller/jobs", s.handleCreateControllerJob)
|
|
mux.HandleFunc("POST /api/controller/jobs/{id}/cancel", s.handleCancelControllerJob)
|
|
mux.HandleFunc("GET /api/v1/agent/config", s.handleAgentConfig)
|
|
mux.HandleFunc("GET /api/v1/agent/performance", s.handleAgentPerformance)
|
|
mux.HandleFunc("POST /api/v1/agent/heartbeat", s.handleAgentHeartbeat)
|
|
mux.HandleFunc("POST /api/v1/agent/ingest", s.handleAgentIngest)
|
|
mux.HandleFunc("GET /api/v1/agent/compute/claim", s.handleAgentComputeClaim)
|
|
mux.HandleFunc("POST /api/v1/agent/compute/{id}/result", s.handleAgentComputeResult)
|
|
mux.HandleFunc("GET /api/v1/agent/compute/article-quality/claim", s.handleAgentArticleQualityComputeClaim)
|
|
mux.HandleFunc("POST /api/v1/agent/compute/article-quality/{id}/result", s.handleAgentArticleQualityComputeResult)
|
|
mux.HandleFunc("GET /api/v1/agent/controller/claim", s.handleAgentControllerClaim)
|
|
mux.HandleFunc("GET /api/v1/agent/controller/{id}/authorized", s.handleAgentControllerAuthorized)
|
|
mux.HandleFunc("POST /api/v1/agent/controller/{id}/result", s.handleAgentControllerResult)
|
|
sub, _ := fs.Sub(assets, "static")
|
|
mux.HandleFunc("GET /analysis", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/analysis.html", http.StatusTemporaryRedirect)
|
|
})
|
|
mux.Handle("GET /", http.FileServer(http.FS(sub)))
|
|
return s.headers(mux)
|
|
}
|
|
func (s *Server) headers(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.ToLower(r.URL.Path)
|
|
if path == "/" || strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
|
|
// Embedded UI assets keep stable filenames across upgrades. Do not let
|
|
// browsers combine a new HTML document with stale JS/CSS from an older build.
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
}
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
out := s.Engine.Status()
|
|
if s.SourceAgents != nil {
|
|
ctx, cancel := contextTimeout(r, 5*time.Second)
|
|
defer cancel()
|
|
if agents, err := s.SourceAgents.ListAgents(ctx); err == nil {
|
|
online := 0
|
|
for _, a := range agents {
|
|
if !a.LastSeen.IsZero() && time.Since(a.LastSeen) < 15*time.Minute {
|
|
online++
|
|
}
|
|
}
|
|
tasks, _ := s.SourceAgents.ListTasks(ctx, "")
|
|
stats, _ := s.SourceAgents.Stats(ctx)
|
|
out["source_agents"] = map[string]any{"registered": len(agents), "online": online, "tasks": len(tasks), "inbox": stats, "compute": s.SourceAgents.ComputeStats(), "controller": s.SourceAgents.ControllerStats(ctx), "brain_url": s.PublicURL}
|
|
}
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, s.Graph.Snapshot())
|
|
}
|
|
func (s *Server) handleAnalysis(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, s.Graph.Analyze())
|
|
}
|
|
|
|
func (s *Server) analysisDashboardPayload(r *http.Request) (map[string]any, error) {
|
|
hours := 24
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("hours")); raw != "" {
|
|
if value, err := strconv.Atoi(raw); err == nil && value >= 1 && value <= 24*90 {
|
|
hours = value
|
|
}
|
|
}
|
|
limit := 250
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
|
if value, err := strconv.Atoi(raw); err == nil && value >= 20 && value <= 1000 {
|
|
limit = value
|
|
}
|
|
}
|
|
ctx, cancel := contextTimeout(r, 60*time.Second)
|
|
defer cancel()
|
|
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
|
history, err := s.Graph.AnalysisHistory(ctx, since, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s.SourceAgents != nil {
|
|
if lifecycles, lifecycleErr := s.SourceAgents.SecurityLifecycles(ctx, since); lifecycleErr == nil {
|
|
records := make([]graph.AnalysisSecurityLifecycle, 0, len(lifecycles))
|
|
for _, item := range lifecycles {
|
|
records = append(records, graph.AnalysisSecurityLifecycle{InboxID: item.InboxID, RunID: item.RunID, Title: item.Title, Status: item.Status, ProactiveState: item.ProactiveState, Outcome: item.Outcome, LastError: item.LastError, MaterializedNodeID: item.MaterializedNodeID, StartedAt: item.StartedAt, CompletedAt: item.CompletedAt, DurationMS: item.DurationMS, Confidence: item.Confidence, Severity: item.Severity, EventType: item.EventType, Mutations: graph.MutationStats{NodesCreated: item.NodesCreated, NodesUpdated: item.NodesUpdated, NodesDeleted: item.NodesDeleted, EdgesCreated: item.EdgesCreated, EdgesUpdated: item.EdgesUpdated, EdgesDeleted: item.EdgesDeleted, VectorsCreated: item.VectorsCreated, VectorsUpdated: item.VectorsUpdated, VectorsDeleted: item.VectorsDeleted}})
|
|
}
|
|
graph.ReconcileSecurityLifecycles(&history, records)
|
|
}
|
|
}
|
|
detail := s.Graph.DetailedAnalysis()
|
|
system := s.Engine.Status()
|
|
if s.SourceAgents != nil {
|
|
agentSummary := map[string]any{"brain_url": s.PublicURL}
|
|
agents, agentsErr := s.SourceAgents.ListAgents(ctx)
|
|
if agentsErr != nil {
|
|
agentSummary["error"] = agentsErr.Error()
|
|
} else {
|
|
online := 0
|
|
for _, agent := range agents {
|
|
if agent.Enabled && !agent.LastSeen.IsZero() && time.Since(agent.LastSeen) < 15*time.Minute {
|
|
online++
|
|
}
|
|
}
|
|
agentSummary["registered"] = len(agents)
|
|
agentSummary["online"] = online
|
|
}
|
|
if tasks, tasksErr := s.SourceAgents.ListTasks(ctx, ""); tasksErr != nil {
|
|
agentSummary["tasks_error"] = tasksErr.Error()
|
|
} else {
|
|
agentSummary["tasks"] = len(tasks)
|
|
}
|
|
if stats, statsErr := s.SourceAgents.Stats(ctx); statsErr != nil {
|
|
agentSummary["inbox_error"] = statsErr.Error()
|
|
} else {
|
|
agentSummary["inbox"] = stats
|
|
}
|
|
agentSummary["controller"] = s.SourceAgents.ControllerStats(ctx)
|
|
system["source_agents"] = agentSummary
|
|
}
|
|
readiness := s.productionReadiness(ctx, detail, history, system)
|
|
return map[string]any{
|
|
"generated_at": history.GeneratedAt,
|
|
"range_hours": hours,
|
|
"graph": detail,
|
|
"history": history,
|
|
"system": system,
|
|
"readiness": readiness,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) handleAnalysisDashboard(w http.ResponseWriter, r *http.Request) {
|
|
payload, err := s.analysisDashboardPayload(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, payload)
|
|
}
|
|
|
|
func (s *Server) handleAnalysisExport(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
payload, err := s.analysisDashboardPayload(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="brain-analysis.json"`)
|
|
w.WriteHeader(http.StatusOK)
|
|
encoder := json.NewEncoder(w)
|
|
encoder.SetIndent("", " ")
|
|
_ = encoder.Encode(payload)
|
|
}
|
|
|
|
func (s *Server) handleGetRuntimeSettings(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, s.Engine.RuntimeSettingsView())
|
|
}
|
|
|
|
func (s *Server) handleSetRuntimeSettings(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
_, err = s.Engine.ApplyRuntimeSettingsJSON(data)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.Engine.RuntimeSettingsView())
|
|
}
|
|
|
|
func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"sources": s.Engine.Sources()})
|
|
}
|
|
|
|
func (s *Server) handleResearchStatus(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, s.Engine.ResearchStatus())
|
|
}
|
|
|
|
func (s *Server) handleResearchTest(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
var request struct {
|
|
Query string `json:"query"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
if r.Body != nil {
|
|
err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&request)
|
|
if err != nil && err != io.EOF {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
ctx, cancel := contextTimeout(r, 60*time.Second)
|
|
defer cancel()
|
|
result, err := s.Engine.TestResearch(ctx, request.Query, request.Limit)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{
|
|
"error": err.Error(),
|
|
"query": result.Query,
|
|
"diagnostic": result.Diagnostic,
|
|
})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func (s *Server) handleResearchTasks(w http.ResponseWriter, r *http.Request) {
|
|
limit := 50
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
|
if value, err := strconv.Atoi(raw); err == nil && value > 0 && value <= 500 {
|
|
limit = value
|
|
}
|
|
}
|
|
ctx, cancel := contextTimeout(r, 15*time.Second)
|
|
defer cancel()
|
|
tasks, err := s.Engine.ResearchTasks(ctx, limit)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "status": s.Engine.AutonomousResearchStatus(ctx)})
|
|
}
|
|
|
|
func (s *Server) handleCreateResearchTask(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
var request model.ResearchTaskRequest
|
|
if err := decode(r, &request); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
ctx, cancel := contextTimeout(r, 15*time.Second)
|
|
defer cancel()
|
|
task, created, err := s.Engine.QueueResearchTask(ctx, request)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
status := http.StatusAccepted
|
|
if !created {
|
|
status = http.StatusOK
|
|
}
|
|
writeJSON(w, status, map[string]any{"task": task, "created": created})
|
|
}
|
|
|
|
func (s *Server) handleCancelResearchTask(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
ctx, cancel := contextTimeout(r, 15*time.Second)
|
|
defer cancel()
|
|
cancelled, err := s.Engine.CancelResearchTask(ctx, r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if !cancelled {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "task is already running or completed"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "cancelled": true})
|
|
}
|
|
|
|
func (s *Server) handleAutonomousResearchScan(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
if !s.Engine.AutonomousResearchEnabled() {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "autonomous research is disabled"})
|
|
return
|
|
}
|
|
if !s.Engine.ResearchEnabledForRuntime() {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "SearXNG must be available"})
|
|
return
|
|
}
|
|
if !s.Engine.RequestAutonomousResearchScan("manual") {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "autonomous research scan is already queued"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true, "queued": true})
|
|
}
|
|
|
|
func (s *Server) handleAutonomousResearchRun(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
if !s.Engine.AutonomousResearchEnabled() {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "autonomous research is disabled"})
|
|
return
|
|
}
|
|
if !s.Engine.ResearchEnabledForRuntime() {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "SearXNG must be available"})
|
|
return
|
|
}
|
|
s.Engine.WakeAutonomousResearch()
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true, "queued": true})
|
|
}
|
|
|
|
func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
var req model.QueryRequest
|
|
if err := decode(r, &req); err != nil {
|
|
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
ctx, cancel := contextTimeout(r, 8*time.Minute)
|
|
defer cancel()
|
|
out, err := s.Engine.Query(ctx, req.Query)
|
|
if err != nil {
|
|
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
func (s *Server) handleEvent(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
var in model.ExternalEvent
|
|
if err := decode(r, &in); err != nil {
|
|
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
var ids []string
|
|
for _, h := range in.Hits {
|
|
if n, ok := s.Graph.LookupExternal(h.ID); ok {
|
|
ids = append(ids, n.ID)
|
|
}
|
|
}
|
|
s.Broker.Publish(model.Activity{Type: nonempty(in.Type, "external.event"), Source: nonempty(in.Source, "external"), Phase: "external", Query: in.Query, Message: in.Message, NodeIDs: ids, EdgeIDs: s.Graph.ConnectingEdges(ids), Strength: .9, Metadata: in.Metadata})
|
|
queued := false
|
|
if s.Engine.Cfg.AutonomousResearchQueryTriggers && s.Engine.AutonomousResearchEnabled() && (in.Type == "knowledge.answer_insufficient" || in.Type == "knowledge.search.empty" || in.Type == "agent.answer.uncertain") {
|
|
priority := .9
|
|
if value, ok := in.Metadata["priority"].(float64); ok && value > 0 {
|
|
priority = value
|
|
}
|
|
request := model.ResearchTaskRequest{Topic: nonempty(in.Query, in.Message), Question: in.Query, SeedNodeIDs: ids, Priority: priority, RequestedBy: nonempty(in.Source, "external"), Reason: in.Type, Metadata: in.Metadata}
|
|
ctx, cancel := contextTimeout(r, 15*time.Second)
|
|
defer cancel()
|
|
_, queued, _ = s.Engine.QueueResearchTask(ctx, request)
|
|
}
|
|
writeJSON(w, 202, map[string]any{"ok": true, "resolved_nodes": len(ids), "research_task_queued": queued})
|
|
}
|
|
func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
ctx, cancel := contextTimeout(r, 10*time.Minute)
|
|
defer cancel()
|
|
if err := s.Engine.Scan(ctx); err != nil {
|
|
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 200, s.Engine.Status())
|
|
}
|
|
func (s *Server) handleGLPIKBSync(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
ctx, cancel := contextTimeout(r, 10*time.Minute)
|
|
defer cancel()
|
|
if err := s.Engine.SyncGLPIKB(ctx); err != nil {
|
|
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 200, s.Engine.Status())
|
|
}
|
|
|
|
func (s *Server) handleStateExport(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
dir, err := os.MkdirTemp("", "brain-export-*")
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
path := filepath.Join(dir, "graph.db")
|
|
ctx, cancel := contextTimeout(r, 10*time.Minute)
|
|
defer cancel()
|
|
if err := s.Engine.ExportGraph(ctx, path); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/vnd.sqlite3")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="graph.db"`)
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
|
|
http.ServeContent(w, r, "graph.db", info.ModTime(), file)
|
|
}
|
|
|
|
func (s *Server) handleFlush(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
ctx, cancel := contextTimeout(r, 2*time.Minute)
|
|
defer cancel()
|
|
if err := s.Engine.Flush(ctx); err != nil {
|
|
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 200, s.Engine.Status())
|
|
}
|
|
|
|
func (s *Server) handleEnrich(w http.ResponseWriter, r *http.Request) {
|
|
if !s.authorized(r) {
|
|
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
if r.URL.Query().Get("async") == "1" {
|
|
if !s.Engine.ThinkingEnabled() {
|
|
writeJSON(w, http.StatusConflict, map[string]any{"error": "AI-THINK is disabled by runtime settings", "status": s.Engine.Status()})
|
|
return
|
|
}
|
|
if !s.Engine.RequestEnrich("manual") {
|
|
writeJSON(w, http.StatusConflict, map[string]any{"error": "AI-THINK is already running or queued", "status": s.Engine.Status()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true, "queued": true, "status": s.Engine.Status()})
|
|
return
|
|
}
|
|
ctx, cancel := contextTimeout(r, 10*time.Minute)
|
|
defer cancel()
|
|
if err := s.Engine.EnrichOne(ctx); err != nil {
|
|
writeJSON(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 200, s.Engine.Status())
|
|
}
|
|
func (s *Server) authorized(r *http.Request) bool {
|
|
if s.APIKey == "" {
|
|
return true
|
|
}
|
|
v := strings.TrimSpace(r.Header.Get("Authorization"))
|
|
return v == "Bearer "+s.APIKey || r.Header.Get("X-Brain-Key") == s.APIKey
|
|
}
|
|
func decode(r *http.Request, v any) error {
|
|
return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(v)
|
|
}
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
func nonempty(a, b string) string {
|
|
if strings.TrimSpace(a) != "" {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func contextTimeout(r *http.Request, d time.Duration) (context.Context, context.CancelFunc) {
|
|
return context.WithTimeout(r.Context(), d)
|
|
}
|