Files
Assistant/cmd/core-runtime/main.go
m 6e0040fe89
CI / test (pull_request) Successful in 12s
CI / test (push) Successful in 10s
feat: add notification delivery foundation
2026-08-16 19:08:07 +03:00

378 lines
14 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"git.cin.su/m/Assistant/adapters/postgres"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/contracts/situation"
"git.cin.su/m/Assistant/contracts/work"
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
identitydomain "git.cin.su/m/Assistant/modules/identity/domain"
situationdomain "git.cin.su/m/Assistant/modules/situation/domain"
"git.cin.su/m/Assistant/runtime/health"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const maxCreatorCredentialsBytes = 4 << 10
func main() {
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
bootstrapToken, err := readRequiredSecret("CREATOR_BOOTSTRAP_TOKEN")
if err != nil {
log.Fatal(err)
}
sessionKeyValue, err := readRequiredSecret("CREATOR_SESSION_SIGNING_KEY")
if err != nil {
log.Fatal(err)
}
creatorPassword, err := readRequiredSecret("CREATOR_PASSWORD")
if err != nil {
log.Fatal(err)
}
sessionKey := []byte(sessionKeyValue)
creatorUsername := os.Getenv("CREATOR_USERNAME")
repository, err := postgres.OpenSituation(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("POSTGRES_USER"), Password: os.Getenv("POSTGRES_PASSWORD")})
if err != nil {
log.Fatal(err)
}
defer repository.Close()
workRepository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: os.Getenv("WORK_POSTGRES_PASSWORD")})
if err != nil {
log.Fatal(err)
}
defer workRepository.Close()
capabilityRepository, err := postgres.OpenCapability(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("CAPABILITY_POSTGRES_USER"), Password: os.Getenv("CAPABILITY_POSTGRES_PASSWORD")})
if err != nil {
log.Fatal(err)
}
defer capabilityRepository.Close()
identityRepository, err := postgres.OpenIdentity(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("IDENTITY_POSTGRES_USER"), Password: os.Getenv("IDENTITY_POSTGRES_PASSWORD")})
if err != nil {
log.Fatal(err)
}
defer identityRepository.Close()
mux := http.NewServeMux()
authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) {
session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now())
if err != nil {
http.Error(w, "creator session denied", http.StatusUnauthorized)
return identity.CreatorSession{}, false
}
return session, true
}
resolveFocus := func(r *http.Request) (situation.FocusBinding, error) {
runtime := situation.RuntimeBinding{SessionID: situation.SessionID(r.URL.Query().Get("session_id")), Workspace: situation.WorkspaceID(r.URL.Query().Get("workspace_id"))}
bindings, err := repository.ByRuntime(r.Context(), runtime)
if err != nil {
return situation.FocusBinding{}, err
}
return situationdomain.NewResolver(bindings).Resolve(runtime)
}
mux.Handle("/healthz", health.Handler{Runtime: "core-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now))
mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(session)
})
mux.HandleFunc("GET /v1/creator/focus", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
return
}
focus, err := resolveFocus(r)
if err != nil {
http.Error(w, "creator focus unavailable", http.StatusConflict)
return
}
if focus.ActorID != session.ActorID {
http.Error(w, "creator focus denied", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(focus)
})
mux.HandleFunc("GET /v1/creator/tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
return
}
focus, err := resolveFocus(r)
if err != nil || focus.ActorID != session.ActorID {
http.Error(w, "creator focus denied", http.StatusForbidden)
return
}
target, err := workRepository.Load(r.Context(), work.TaskID(r.PathValue("id")))
if err != nil {
http.Error(w, "task not found", http.StatusNotFound)
return
}
if err := situationdomain.ValidateTaskTarget(focus, target); err != nil {
http.Error(w, "task outside creator focus", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(target)
})
mux.HandleFunc("GET /v1/creator/capabilities/{id}/health", func(w http.ResponseWriter, r *http.Request) {
if _, ok := authenticateCreator(w, r); !ok {
return
}
status, err := capabilityRepository.LoadHealth(r.Context(), capability.CapabilityID(r.PathValue("id")))
if err != nil {
http.Error(w, "capability health not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(status)
})
mux.HandleFunc("GET /v1/principal-bindings/{actor}/{principal}", func(w http.ResponseWriter, r *http.Request) {
binding, err := identityRepository.LoadBinding(r.Context(), identity.ActorID(r.PathValue("actor")), identity.PrincipalID(r.PathValue("principal")))
if err != nil {
http.Error(w, "principal binding not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(binding)
})
mux.HandleFunc("GET /v1/tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
target, err := workRepository.Load(r.Context(), work.TaskID(r.PathValue("id")))
if err != nil {
http.Error(w, "task not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(target)
})
mux.HandleFunc("POST /v1/capabilities/health", func(w http.ResponseWriter, r *http.Request) {
var status capability.Health
if err := json.NewDecoder(r.Body).Decode(&status); err != nil {
http.Error(w, "invalid capability health", http.StatusBadRequest)
return
}
if err := capabilitydomain.ValidateHealth(status); err != nil {
http.Error(w, "invalid capability health", http.StatusBadRequest)
return
}
if err := capabilityRepository.SaveHealth(r.Context(), status); err != nil {
http.Error(w, "save capability health", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
})
mux.HandleFunc("GET /v1/capabilities/{id}/health", func(w http.ResponseWriter, r *http.Request) {
status, err := capabilityRepository.LoadHealth(r.Context(), capability.CapabilityID(r.PathValue("id")))
if err != nil {
http.Error(w, "capability health not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(status)
})
mux.HandleFunc("GET /v1/capability-bindings/{id}/revisions/{revision}", func(w http.ResponseWriter, r *http.Request) {
revision, err := strconv.ParseInt(r.PathValue("revision"), 10, 64)
if err != nil || revision < 1 {
http.Error(w, "invalid binding revision", http.StatusBadRequest)
return
}
binding, err := capabilityRepository.LoadBinding(r.Context(), capability.CapabilityBindingID(r.PathValue("id")), revision)
if err != nil {
http.Error(w, "capability binding not found", http.StatusNotFound)
return
}
if err = capabilitydomain.ValidateBinding(binding); err != nil {
http.Error(w, "capability binding invalid", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(binding)
})
mux.HandleFunc("GET /v1/capability-bindings/active", func(w http.ResponseWriter, r *http.Request) {
capabilityID := capability.CapabilityID(r.URL.Query().Get("capability_id"))
ownerID := identity.PrincipalID(r.URL.Query().Get("owner_principal_id"))
if capabilityID == "" || ownerID == "" {
http.Error(w, "invalid binding query", http.StatusBadRequest)
return
}
binding, err := capabilityRepository.LoadActiveBinding(r.Context(), capabilityID, ownerID)
if err != nil {
http.Error(w, "active capability binding not found", http.StatusNotFound)
return
}
if err = capabilitydomain.UsableBinding(binding); err != nil {
http.Error(w, "active capability binding unavailable", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(binding)
})
mux.HandleFunc("POST /v1/capabilities/health-projection/rebuild", func(w http.ResponseWriter, r *http.Request) {
rebuilt, err := capabilityRepository.RebuildHealthProjection(r.Context())
if err != nil {
http.Error(w, "rebuild capability health projection", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(struct{ Rebuilt int64 }{rebuilt})
})
mux.HandleFunc("POST /v1/focus-bindings", func(w http.ResponseWriter, r *http.Request) {
var binding situation.FocusBinding
if err := json.NewDecoder(r.Body).Decode(&binding); err != nil {
http.Error(w, "invalid focus binding", http.StatusBadRequest)
return
}
if err := repository.Save(r.Context(), binding); err != nil {
http.Error(w, "save focus binding", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
})
mux.HandleFunc("GET /v1/focus", func(w http.ResponseWriter, r *http.Request) {
focus, err := resolveFocus(r)
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(focus)
})
mux.HandleFunc("POST /v1/tasks", func(w http.ResponseWriter, r *http.Request) {
var target work.TaskTarget
if err := json.NewDecoder(r.Body).Decode(&target); err != nil {
http.Error(w, "invalid task", 400)
return
}
if err := workRepository.Create(r.Context(), target); err != nil {
http.Error(w, "create task", 500)
return
}
w.WriteHeader(http.StatusCreated)
})
mux.HandleFunc("POST /v1/tasks/{id}/commit", func(w http.ResponseWriter, r *http.Request) {
var target work.TaskTarget
if err := json.NewDecoder(r.Body).Decode(&target); err != nil {
http.Error(w, "invalid task commit", 400)
return
}
target.TaskID = work.TaskID(r.PathValue("id"))
revision, err := workRepository.Commit(r.Context(), target)
if err != nil {
http.Error(w, "task revision conflict", http.StatusConflict)
return
}
_ = json.NewEncoder(w).Encode(struct{ Revision work.TaskRevision }{revision})
})
mux.HandleFunc("POST /v1/focus-task-commits", func(w http.ResponseWriter, r *http.Request) {
var request struct {
SessionID situation.SessionID
Workspace situation.WorkspaceID
Target work.TaskTarget
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "invalid guarded commit", http.StatusBadRequest)
return
}
bindings, err := repository.ByRuntime(r.Context(), situation.RuntimeBinding{SessionID: request.SessionID, Workspace: request.Workspace})
if err != nil {
http.Error(w, "load focus", http.StatusInternalServerError)
return
}
focus, err := situationdomain.NewResolver(bindings).Resolve(situation.RuntimeBinding{SessionID: request.SessionID, Workspace: request.Workspace})
if err != nil {
http.Error(w, "focus unavailable", http.StatusConflict)
return
}
if err := situationdomain.ValidateTaskTarget(focus, request.Target); err != nil {
http.Error(w, "target outside resolved focus", http.StatusConflict)
return
}
revision, err := workRepository.Commit(r.Context(), request.Target)
if err != nil {
http.Error(w, "task revision conflict", http.StatusConflict)
return
}
_ = json.NewEncoder(w).Encode(struct{ Revision work.TaskRevision }{revision})
})
log.Printf("core-runtime listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal(err)
}
}
func creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword string, sessionKey []byte, now func() time.Time) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authenticated := identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r))
if !authenticated {
var credentials identity.CreatorCredentials
decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
return
}
authenticated = identitydomain.CreatorCredentialsMatch(creatorUsername, creatorPassword, credentials)
}
if !authenticated {
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
return
}
token, session, err := identitydomain.IssueCreatorSession(sessionKey, now(), 8*time.Hour)
if err != nil {
http.Error(w, "creator session unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Token string
Session identity.CreatorSession
}{token, session})
}
}
func bearerToken(r *http.Request) string {
value := r.Header.Get("Authorization")
if !strings.HasPrefix(value, "Bearer ") {
return ""
}
return strings.TrimPrefix(value, "Bearer ")
}
func readRequiredSecret(name string) (string, error) {
direct, directSet := os.LookupEnv(name)
fileName, fileSet := os.LookupEnv(name + "_FILE")
if directSet && fileSet {
return "", fmt.Errorf("%s and %s_FILE cannot both be configured", name, name)
}
if fileSet {
if fileName == "" {
return "", fmt.Errorf("%s_FILE is empty", name)
}
contents, err := os.ReadFile(fileName)
if err != nil {
return "", fmt.Errorf("read %s_FILE: %w", name, err)
}
direct = strings.TrimRight(string(contents), "\r\n")
if strings.ContainsAny(direct, "\r\n") {
return "", fmt.Errorf("%s_FILE must contain one line", name)
}
}
if direct == "" {
return "", fmt.Errorf("%s is required", name)
}
return direct, nil
}