Files
Assistant/cmd/commit-runtime/creator_command.go
2026-08-16 16:24:02 +03:00

228 lines
8.6 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"git.cin.su/m/Assistant/contracts/authority"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
"git.cin.su/m/Assistant/contracts/continuity"
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/contracts/work"
commitdomain "git.cin.su/m/Assistant/modules/commit/domain"
"git.cin.su/m/Assistant/modules/commit/ports"
"git.cin.su/m/Assistant/platform/ids"
)
const creatorGrantID authority.GrantID = "domovoy-creator-grant"
type creatorSessionSource interface {
Session(context.Context, string) (identity.CreatorSession, error)
Task(context.Context, work.TaskID) (work.TaskTarget, error)
Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error)
}
type creatorContinuitySource interface {
Item(context.Context, string, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error)
}
type creatorAuthoritySource interface {
LoadGrant(context.Context, authority.GrantID) (authority.AuthorityGrant, authority.Scope, error)
}
type creatorCommitRepository interface {
executionRepository
SaveAuthorized(context.Context, commitcontract.AuthorizedAction) error
}
type creatorCommandHandler struct {
core creatorSessionSource
continuity creatorContinuitySource
authority creatorAuthoritySource
repository creatorCommitRepository
executor ports.Executor
reconciler ports.Reconciler
newID func() (ids.ID, error)
now func() time.Time
}
type creatorSatisfyRequest struct {
Target work.TaskTarget
ProspectiveItemID continuity.ProspectiveItemID
ExpectedRevision continuity.ProspectiveRevision
EvidenceRef string
}
type creatorActionResponse struct {
AuthorizedActionID commitcontract.AuthorizedActionID
ActionIntentID commitcontract.ActionIntentID
ActionAttemptID commitcontract.ActionAttemptID
State commitcontract.ActionState
}
func registerCreatorCommandRoutes(mux *http.ServeMux, handler creatorCommandHandler) {
mux.HandleFunc("POST /v1/creator/continuity/satisfy-delivered", handler.satisfyDelivered)
mux.HandleFunc("POST /v1/creator/authorized-actions/{id}/reconcile", handler.reconcile)
}
func (h creatorCommandHandler) satisfyDelivered(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
session, ok := h.creatorSession(w, r, authorization)
if !ok {
return
}
var request creatorSatisfyRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&request); err != nil ||
request.Target.TaskID == "" || request.Target.GoalID == "" || request.Target.ScopeID == "" || request.Target.Revision == 0 ||
request.ProspectiveItemID == "" || request.ExpectedRevision == 0 || strings.TrimSpace(request.EvidenceRef) == "" || len(request.EvidenceRef) > 512 {
http.Error(w, "invalid Creator satisfaction request", http.StatusBadRequest)
return
}
item, err := h.continuity.Item(r.Context(), authorization, request.ProspectiveItemID)
if errors.Is(err, errCreatorOwnerDenied) {
http.Error(w, "prospective item denied", http.StatusForbidden)
return
}
if errors.Is(err, errCreatorSubjectMissing) {
http.Error(w, "prospective item not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Continuity owner validation unavailable", http.StatusServiceUnavailable)
return
}
if item.OwnerPrincipalID != session.PrincipalID {
http.Error(w, "prospective item denied", http.StatusForbidden)
return
}
if item.State != continuity.ProspectiveDelivered || item.Revision != request.ExpectedRevision ||
item.ScopeID != request.Target.ScopeID || item.GoalID != request.Target.GoalID {
http.Error(w, "prospective item is stale or outside justification", http.StatusConflict)
return
}
target, err := h.core.Task(r.Context(), request.Target.TaskID)
if err != nil || target != request.Target {
http.Error(w, "work target is stale or unavailable", http.StatusConflict)
return
}
grant, scope, err := h.authority.LoadGrant(r.Context(), creatorGrantID)
if err != nil {
http.Error(w, "Creator authority unavailable", http.StatusConflict)
return
}
binding, err := h.core.Binding(r.Context(), session.ActorID, session.PrincipalID)
if err != nil {
http.Error(w, "Creator identity binding unavailable", http.StatusConflict)
return
}
intentID, authorizedID, attemptID, err := h.actionIDs()
if err != nil {
http.Error(w, "action identity unavailable", http.StatusServiceUnavailable)
return
}
proposal := commitcontract.ActionProposal{
IntentID: intentID, ActorID: session.ActorID, CapabilityID: commitcontract.CapabilityContinuityTransition,
Target: request.Target, Operation: commitcontract.OperationSatisfyDelivered,
ContinuitySubject: continuity.SatisfyDeliveredSubject{
ProspectiveItemID: request.ProspectiveItemID, ExpectedRevision: request.ExpectedRevision,
FromState: continuity.ProspectiveDelivered, ToState: continuity.ProspectiveSatisfied,
EvidenceRef: strings.TrimSpace(request.EvidenceRef),
},
}
action, err := commitdomain.Authorize(proposal, commitdomain.AuthorizationContext{Binding: binding, Scope: scope, Grant: grant}, authorityValidator{}, authorizedID, h.now().UnixMilli())
if err != nil {
http.Error(w, "Creator authorization denied", http.StatusConflict)
return
}
if err = h.repository.SaveAuthorized(r.Context(), action); err != nil {
http.Error(w, "commit authorized action", http.StatusInternalServerError)
return
}
attempt, operationErr := executeAuthorized(r.Context(), h.repository, h.executor, action, attemptID)
if operationErr != nil && !errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
http.Error(w, "execute committed action", http.StatusInternalServerError)
return
}
status := http.StatusCreated
if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
status = http.StatusAccepted
}
writeCreatorActionResponse(w, status, action, attempt)
}
func (h creatorCommandHandler) reconcile(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
session, ok := h.creatorSession(w, r, authorization)
if !ok {
return
}
action, err := h.repository.LoadAuthorized(r.Context(), commitcontract.AuthorizedActionID(r.PathValue("id")))
if err != nil {
http.Error(w, "authorized action not found", http.StatusNotFound)
return
}
if action.PrincipalID != session.PrincipalID || action.Proposal.ActorID != session.ActorID {
http.Error(w, "authorized action denied", http.StatusForbidden)
return
}
newID, err := h.newID()
if err != nil {
http.Error(w, "action identity unavailable", http.StatusServiceUnavailable)
return
}
attempt, operationErr := reconcileAuthorized(r.Context(), h.repository, h.reconciler, action, commitcontract.ActionAttemptID(newID))
if errors.Is(operationErr, errActionDoesNotNeedReconcile) {
http.Error(w, "action does not require reconciliation", http.StatusConflict)
return
}
if operationErr != nil && !errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
http.Error(w, "reconciliation unavailable", http.StatusInternalServerError)
return
}
status := http.StatusOK
if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
status = http.StatusAccepted
}
writeCreatorActionResponse(w, status, action, attempt)
}
func (h creatorCommandHandler) creatorSession(w http.ResponseWriter, r *http.Request, authorization string) (identity.CreatorSession, bool) {
session, err := h.core.Session(r.Context(), authorization)
if errors.Is(err, errCreatorSessionDenied) {
http.Error(w, "Creator session denied", http.StatusUnauthorized)
return identity.CreatorSession{}, false
}
if err != nil {
http.Error(w, "Identity validation unavailable", http.StatusServiceUnavailable)
return identity.CreatorSession{}, false
}
return session, true
}
func (h creatorCommandHandler) actionIDs() (commitcontract.ActionIntentID, commitcontract.AuthorizedActionID, commitcontract.ActionAttemptID, error) {
intent, err := h.newID()
if err != nil {
return "", "", "", err
}
authorized, err := h.newID()
if err != nil {
return "", "", "", err
}
attempt, err := h.newID()
if err != nil {
return "", "", "", err
}
return commitcontract.ActionIntentID(intent), commitcontract.AuthorizedActionID(authorized), commitcontract.ActionAttemptID(attempt), nil
}
func writeCreatorActionResponse(w http.ResponseWriter, status int, action commitcontract.AuthorizedAction, attempt commitcontract.ActionAttempt) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(creatorActionResponse{AuthorizedActionID: action.ID, ActionIntentID: action.Proposal.IntentID, ActionAttemptID: attempt.ID, State: attempt.State})
}