Files
Assistant/cmd/commit-runtime/main.go
T
m 5d4e064a94
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 15s
DEV-000065 assistant presence attention surface
2026-08-17 16:23:16 +03:00

194 lines
7.2 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/continuityexecutor"
"git.cin.su/m/Assistant/adapters/postgres"
"git.cin.su/m/Assistant/contracts/authority"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
"git.cin.su/m/Assistant/contracts/identity"
authoritydomain "git.cin.su/m/Assistant/modules/authority/domain"
commitdomain "git.cin.su/m/Assistant/modules/commit/domain"
"git.cin.su/m/Assistant/platform/ids"
"git.cin.su/m/Assistant/runtime/health"
)
type authorizationRequest struct {
Proposal commitcontract.ActionProposal
AuthorizedID commitcontract.AuthorizedActionID
GrantID authority.GrantID
PrincipalID identity.PrincipalID
}
type authorityValidator struct{}
func (authorityValidator) Validate(context authority.AuthorizationContext) error {
return authoritydomain.ValidateGrant(context.Binding, context.Scope, context.Grant)
}
func main() {
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8082"
}
config := postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE")}
config.User, config.Password = os.Getenv("POSTGRES_USER"), os.Getenv("POSTGRES_PASSWORD")
repository, err := postgres.OpenCommit(context.Background(), config)
if err != nil {
log.Fatal(err)
}
defer repository.Close()
config.User, config.Password = os.Getenv("AUTHORITY_POSTGRES_USER"), os.Getenv("AUTHORITY_POSTGRES_PASSWORD")
authorityRepository, err := postgres.OpenAuthority(context.Background(), config)
if err != nil {
log.Fatal(err)
}
defer authorityRepository.Close()
coreURL := os.Getenv("CORE_RUNTIME_URL")
if coreURL == "" {
log.Fatal("CORE_RUNTIME_URL is required")
}
executorToken := os.Getenv("COMMIT_EXECUTOR_TOKEN")
if executorToken == "" {
log.Fatal("COMMIT_EXECUTOR_TOKEN is required")
}
continuityExecutor, err := continuityexecutor.New(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), executorToken, &http.Client{Timeout: 5 * time.Second})
if err != nil {
log.Fatal(err)
}
creatorHTTPClient := &http.Client{Timeout: 5 * time.Second}
creatorCore, err := newCreatorCoreClient(coreURL, creatorHTTPClient)
if err != nil {
log.Fatal(err)
}
creatorContinuity, err := newCreatorContinuityClient(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), creatorHTTPClient)
if err != nil {
log.Fatal(err)
}
notificationSecret, err := readCommitSecretFile("NOTIFICATION_WRITE_TOKEN_FILE")
if err != nil {
log.Fatal(err)
}
notificationSecretRef := os.Getenv("NOTIFICATION_WRITE_SECRET_REF")
if notificationSecretRef == "" {
log.Fatal("NOTIFICATION_WRITE_SECRET_REF is required")
}
dispatcher := operationDispatcher{continuityExecutor: continuityExecutor, continuityReconciler: continuityExecutor, bindings: creatorCore, secretRef: notificationSecretRef, secret: notificationSecret, client: creatorHTTPClient}
mux := http.NewServeMux()
mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
registerExecutionRoutes(mux, repository, dispatcher, dispatcher, executorToken)
registerCreatorCommandRoutes(mux, creatorCommandHandler{
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
executor: dispatcher, reconciler: dispatcher, newID: ids.New, now: time.Now,
})
registerInternalNotificationRoutes(mux, internalNotificationHandler{
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
executor: dispatcher, newID: ids.New, now: time.Now,
}, executorToken)
mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) {
var request authorizationRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "invalid authorization request", 400)
return
}
grant, scope, err := authorityRepository.LoadGrant(r.Context(), request.GrantID)
if err != nil {
http.Error(w, "authority grant not found", http.StatusConflict)
return
}
binding, err := loadJSON[identity.PrincipalBinding](r.Context(), coreURL+"/v1/principal-bindings/"+url.PathEscape(string(request.Proposal.ActorID))+"/"+url.PathEscape(string(request.PrincipalID)))
if err != nil {
http.Error(w, "identity binding unavailable", http.StatusConflict)
return
}
target, err := loadJSON[struct {
TaskID string
GoalID string
ScopeID string
Revision uint64
}](r.Context(), coreURL+"/v1/tasks/"+url.PathEscape(string(request.Proposal.Target.TaskID)))
if err != nil || target.TaskID != string(request.Proposal.Target.TaskID) || target.GoalID != string(request.Proposal.Target.GoalID) || target.ScopeID != string(request.Proposal.Target.ScopeID) || target.Revision != uint64(request.Proposal.Target.Revision) {
http.Error(w, "work target is stale or unavailable", http.StatusConflict)
return
}
action, err := commitdomain.Authorize(request.Proposal, commitdomain.AuthorizationContext{Binding: binding, Scope: scope, Grant: grant}, authorityValidator{}, request.AuthorizedID, time.Now().UnixMilli())
if err != nil {
http.Error(w, "authorization denied", http.StatusConflict)
return
}
if err := repository.SaveAuthorized(r.Context(), action); err != nil {
http.Error(w, "save authorized action", 500)
return
}
w.WriteHeader(http.StatusCreated)
})
mux.HandleFunc("POST /v1/action-attempts", func(w http.ResponseWriter, r *http.Request) {
var attempt commitcontract.ActionAttempt
if err := json.NewDecoder(r.Body).Decode(&attempt); err != nil {
http.Error(w, "invalid action attempt", 400)
return
}
if attempt.State != commitcontract.ActionUnknownOutcome && attempt.State != commitcontract.ActionSucceeded && attempt.State != commitcontract.ActionFailed {
http.Error(w, "attempt must have terminal outcome", http.StatusConflict)
return
}
if err := repository.SaveAttempt(r.Context(), attempt); err != nil {
http.Error(w, "save action attempt", 500)
return
}
w.WriteHeader(http.StatusCreated)
})
log.Printf("commit-runtime listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal(err)
}
}
func readCommitSecretFile(name string) (string, error) {
path := os.Getenv(name)
if path == "" {
return "", fmt.Errorf("%s is required", name)
}
value, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", name, err)
}
secret := strings.TrimSpace(string(value))
if secret == "" {
return "", fmt.Errorf("%s is empty", name)
}
return secret, nil
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func loadJSON[T any](ctx context.Context, endpoint string) (T, error) {
var value T
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return value, err
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return value, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return value, fmt.Errorf("remote query status %d", response.StatusCode)
}
return value, json.NewDecoder(response.Body).Decode(&value)
}