mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-22 23:43:30 +00:00
700 lines
31 KiB
Go
700 lines
31 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
|
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
const maxActionPlanRequestBytes = 1 << 20
|
|
const maxActionDecisionRequestBytes = 64 << 10
|
|
const maxActionExecutionRequestBytes = 64 << 10
|
|
const maxPendingActionAudits = 100
|
|
|
|
// ActionExecutor is the API-facing name for the canonical action lifecycle
|
|
// execution contract. The interface is owned by internal/actionlifecycle;
|
|
// the alias keeps existing executor implementations and wiring source-stable.
|
|
type ActionExecutor = actionlifecycle.Executor
|
|
|
|
// ActionAvailabilityChecker is the API-facing name for the canonical
|
|
// pre-plan readiness contract owned by internal/actionlifecycle.
|
|
type ActionAvailabilityChecker = actionlifecycle.AvailabilityChecker
|
|
|
|
type actionDecisionRequest struct {
|
|
Outcome unified.ApprovalOutcome `json:"outcome"`
|
|
Reason string `json:"reason,omitempty"`
|
|
// PlanHash is an optional transport-level binding supplied by interactive
|
|
// clients that displayed a specific plan. Older clients may omit it; when
|
|
// present it must match the authoritative persisted plan before Pulse
|
|
// records the decision.
|
|
PlanHash string `json:"planHash,omitempty"`
|
|
}
|
|
|
|
type publicActionPlanRequest struct {
|
|
RequestID string `json:"requestId"`
|
|
ResourceID string `json:"resourceId"`
|
|
CapabilityName string `json:"capabilityName"`
|
|
Params map[string]any `json:"params,omitempty"`
|
|
Reason string `json:"reason"`
|
|
// RequestedBy is accepted only for boundary compatibility and ignored.
|
|
RequestedBy string `json:"requestedBy,omitempty"`
|
|
}
|
|
|
|
type actionDecisionResponse struct {
|
|
ActionID string `json:"actionId"`
|
|
State unified.ActionState `json:"state"`
|
|
Approval unified.ActionApprovalRecord `json:"approval"`
|
|
Audit unified.ActionAuditRecord `json:"audit"`
|
|
}
|
|
|
|
type actionExecutionRequest struct {
|
|
Reason string `json:"reason,omitempty"`
|
|
PlanHash string `json:"planHash,omitempty"`
|
|
}
|
|
|
|
type actionExecutionResponse struct {
|
|
ActionID string `json:"actionId"`
|
|
State unified.ActionState `json:"state"`
|
|
Result *unified.ExecutionResult `json:"result,omitempty"`
|
|
Audit unified.ActionAuditRecord `json:"audit"`
|
|
}
|
|
|
|
// actionResourcePresentation is read-time metadata from the canonical unified
|
|
// resource registry. It is deliberately kept outside ActionRequest so a name
|
|
// change cannot alter a persisted plan identity or plan hash.
|
|
type actionResourcePresentation struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type unified.ResourceType `json:"type"`
|
|
}
|
|
|
|
type actionAuditProjection struct {
|
|
unified.ActionAuditRecord
|
|
Resource *actionResourcePresentation `json:"resource,omitempty"`
|
|
}
|
|
|
|
type pendingActionsResponse struct {
|
|
Actions []actionAuditProjection `json:"actions"`
|
|
Count int `json:"count"`
|
|
ReadOnly bool `json:"readOnly"`
|
|
}
|
|
|
|
type actionInboxResponse struct {
|
|
View actionlifecycle.ActionListView `json:"view"`
|
|
Actions []actionAuditProjection `json:"actions"`
|
|
Count int `json:"count"`
|
|
ReadOnly bool `json:"readOnly"`
|
|
}
|
|
|
|
type actionDetailResponse struct {
|
|
Audit actionAuditProjection `json:"audit"`
|
|
Events []unified.ActionLifecycleEvent `json:"events"`
|
|
Attempt *unified.ActionDispatchAttempt `json:"attempt,omitempty"`
|
|
Receipt *unified.ActionDispatchReceipt `json:"receipt,omitempty"`
|
|
ReadOnly bool `json:"readOnly"`
|
|
}
|
|
|
|
// ActionLifecycle returns the shared transport-independent action lifecycle
|
|
// service bound to this handler set's registry, store, executor, and
|
|
// completion publisher. The REST handlers below and any in-process broker
|
|
// (e.g. Patrol) must route through this one service; there is no other
|
|
// sanctioned path from a typed action request to execution.
|
|
func (h *ResourceHandlers) ActionLifecycle() *actionlifecycle.Service {
|
|
return &actionlifecycle.Service{
|
|
Registry: h.buildRegistry,
|
|
Store: func(orgID string) (actionlifecycle.Store, error) {
|
|
return h.getStore(orgID)
|
|
},
|
|
Executor: h.actionExecutor,
|
|
OnActionCompleted: h.actionCompleted,
|
|
OnActionTransition: h.actionTransition,
|
|
PolicyAdmission: h.policyAdmission,
|
|
EmergencyStop: h.actionEmergencyStop,
|
|
DecisionAuthorizer: h.actionDecisionAuthorizer,
|
|
ExecutionAuthorizer: h.actionExecutionAuthorizer,
|
|
}
|
|
}
|
|
|
|
func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if mock.IsMockEnabled() {
|
|
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeMockModeEnabled, "Cannot plan actions in mock mode")
|
|
return
|
|
}
|
|
|
|
var publicReq publicActionPlanRequest
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionPlanRequestBytes))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&publicReq); err != nil {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action planning request", map[string]string{
|
|
"body": "request body must be a valid ActionRequest JSON object",
|
|
})
|
|
return
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action planning request", map[string]string{
|
|
"body": "request body must contain one JSON object",
|
|
})
|
|
return
|
|
}
|
|
|
|
orgID := GetOrgID(r.Context())
|
|
actor, err := actionActorForRequest(h.cfg, r, orgID)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionActorUnavailable, "Authenticated action actor is unavailable")
|
|
return
|
|
}
|
|
req := unified.ActionRequest{
|
|
RequestID: publicReq.RequestID,
|
|
ResourceID: publicReq.ResourceID,
|
|
CapabilityName: publicReq.CapabilityName,
|
|
Params: publicReq.Params,
|
|
Reason: publicReq.Reason,
|
|
}
|
|
plan, err := h.ActionLifecycle().Plan(r.Context(), orgID, req, actor)
|
|
if err != nil {
|
|
writeActionPlanError(w, err)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(plan); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_plan_encode_failed", "Failed to encode action plan")
|
|
}
|
|
}
|
|
|
|
// HandleListPendingActions returns the canonical decision queue. It is not an
|
|
// audit-log endpoint and has no enterprise audit-log entitlement dependency;
|
|
// authorization is the same ai:execute scope required to decide an action.
|
|
func (h *ResourceHandlers) HandleListPendingActions(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if mock.IsMockEnabled() {
|
|
actions := mockActionAuditsByState(maxPendingActionAudits, unified.ActionStatePending)
|
|
projected := projectActionAudits(actions, mockActionResourceRegistry())
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(pendingActionsResponse{Actions: projected, Count: len(projected), ReadOnly: true}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionQueueEncodeFailed, "Failed to encode pending actions")
|
|
}
|
|
return
|
|
}
|
|
actions, err := h.ActionLifecycle().DecisionQueue(GetOrgID(r.Context()), maxPendingActionAudits)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionQueueQueryFailed, "Failed to query pending actions")
|
|
return
|
|
}
|
|
if actions == nil {
|
|
actions = []unified.ActionAuditRecord{}
|
|
}
|
|
projected := h.projectActionAudits(GetOrgID(r.Context()), actions)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(pendingActionsResponse{Actions: projected, Count: len(projected)}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionQueueEncodeFailed, "Failed to encode pending actions")
|
|
}
|
|
}
|
|
|
|
// HandleListActions returns the global active or settled action projection.
|
|
func (h *ResourceHandlers) HandleListActions(w http.ResponseWriter, r *http.Request) {
|
|
view := actionlifecycle.ActionListView(strings.TrimSpace(r.URL.Query().Get("view")))
|
|
if view == "" {
|
|
view = actionlifecycle.ActionListPending
|
|
}
|
|
limit := maxPendingActionAudits
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
|
parsed, err := strconv.Atoi(raw)
|
|
if err != nil || parsed < 1 || parsed > 500 {
|
|
writeJSONError(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionListLimit, "Action list limit must be between 1 and 500")
|
|
return
|
|
}
|
|
limit = parsed
|
|
}
|
|
if mock.IsMockEnabled() {
|
|
states := []unified.ActionState{unified.ActionStatePlanned, unified.ActionStatePending, unified.ActionStateApproved, unified.ActionStateExecuting}
|
|
if view == actionlifecycle.ActionListSettled {
|
|
states = []unified.ActionState{unified.ActionStateRejected, unified.ActionStateExpired, unified.ActionStateCompleted, unified.ActionStateFailed}
|
|
} else if view != actionlifecycle.ActionListPending {
|
|
writeJSONError(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionListView, "Action list view must be pending or settled")
|
|
return
|
|
}
|
|
actions := mockActionAuditsByState(limit, states...)
|
|
projected := projectActionAudits(actions, mockActionResourceRegistry())
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(actionInboxResponse{View: view, Actions: projected, Count: len(projected), ReadOnly: true}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionListEncodeFailed, "Failed to encode actions")
|
|
}
|
|
return
|
|
}
|
|
actions, err := h.ActionLifecycle().List(GetOrgID(r.Context()), view, limit)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "unsupported action list view") {
|
|
writeJSONError(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionListView, "Action list view must be pending or settled")
|
|
return
|
|
}
|
|
writeActionLifecycleReadError(w, err, func() {
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionListFailed, "Failed to query actions")
|
|
})
|
|
return
|
|
}
|
|
if actions == nil {
|
|
actions = []unified.ActionAuditRecord{}
|
|
}
|
|
projected := h.projectActionAudits(GetOrgID(r.Context()), actions)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(actionInboxResponse{View: view, Actions: projected, Count: len(projected)}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionListEncodeFailed, "Failed to encode actions")
|
|
}
|
|
}
|
|
|
|
// HandleGetAction returns authoritative lifecycle and durable dispatch detail.
|
|
func (h *ResourceHandlers) HandleGetAction(w http.ResponseWriter, r *http.Request) {
|
|
actionID := strings.TrimSpace(r.PathValue("id"))
|
|
if actionID == "" || !validAuditEventID.MatchString(actionID) || len(actionID) > 128 {
|
|
writeJSONError(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidID, "Invalid action ID format")
|
|
return
|
|
}
|
|
if mock.IsMockEnabled() {
|
|
for _, fixture := range mock.ActionFixtures() {
|
|
if fixture.Audit.ID != actionID {
|
|
continue
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(actionDetailResponse{
|
|
Audit: projectActionAudit(fixture.Audit, mockActionResourceRegistry()),
|
|
Events: fixture.Events,
|
|
ReadOnly: true,
|
|
}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailEncodeFailed, "Failed to encode action detail")
|
|
}
|
|
return
|
|
}
|
|
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{"actionId": actionID})
|
|
return
|
|
}
|
|
detail, found, err := h.ActionLifecycle().Detail(GetOrgID(r.Context()), actionID)
|
|
if err != nil {
|
|
writeActionLifecycleReadError(w, err, func() {
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailFailed, "Failed to query action detail")
|
|
})
|
|
return
|
|
}
|
|
if !found {
|
|
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{"actionId": actionID})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(actionDetailResponse{
|
|
Audit: h.projectActionAudit(GetOrgID(r.Context()), detail.Audit),
|
|
Events: detail.Events,
|
|
Attempt: detail.Attempt,
|
|
Receipt: detail.Receipt,
|
|
}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailEncodeFailed, "Failed to encode action detail")
|
|
}
|
|
}
|
|
|
|
func writeActionPlanError(w http.ResponseWriter, err error) {
|
|
var validationErr *actionplanner.ValidationError
|
|
var notFound *actionlifecycle.ResourceNotFoundError
|
|
var unavailable *actionlifecycle.AvailabilityRefusedError
|
|
var persist *actionlifecycle.PersistError
|
|
switch {
|
|
case errors.As(err, &validationErr):
|
|
details := map[string]string{}
|
|
if validationErr.Field != "" {
|
|
details[validationErr.Field] = validationErr.Message
|
|
}
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action planning request", details)
|
|
case errors.Is(err, actionplanner.ErrCapabilityNotFound):
|
|
details := map[string]string{}
|
|
var capabilityNotFound *actionlifecycle.CapabilityNotFoundError
|
|
if errors.As(err, &capabilityNotFound) {
|
|
details["resourceId"] = capabilityNotFound.ResourceID
|
|
details["capabilityName"] = capabilityNotFound.CapabilityName
|
|
}
|
|
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeCapabilityNotFound, "Capability not found on resource", details)
|
|
case errors.As(err, ¬Found):
|
|
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeResourceNotFound, "Resource not found", map[string]string{
|
|
"resourceId": notFound.ResourceID,
|
|
})
|
|
case errors.As(err, &unavailable):
|
|
writeJSONErrorWithDetails(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionExecutionUnavailable, "Action execution is unavailable", map[string]string{
|
|
"resourceId": unavailable.ResourceID,
|
|
"capabilityName": unavailable.CapabilityName,
|
|
"reasonCode": unavailable.Readiness.ReasonCode,
|
|
"reason": firstNonEmpty(unavailable.Readiness.Reason, "action execution is unavailable"),
|
|
})
|
|
case errors.Is(err, actionlifecycle.ErrRegistryUnavailable):
|
|
writeJSONError(w, http.StatusInternalServerError, "resource_registry_unavailable", sanitizeErrorForClient(err, "Resource registry unavailable"))
|
|
case errors.Is(err, actionlifecycle.ErrStoreUnavailable):
|
|
writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available")
|
|
case errors.As(err, &persist):
|
|
writeJSONError(w, http.StatusInternalServerError, "action_audit_persist_failed", sanitizeErrorForClient(err, "Failed to persist action audit"))
|
|
default:
|
|
writeJSONError(w, http.StatusInternalServerError, "action_plan_failed", sanitizeErrorForClient(err, "Action planning failed"))
|
|
}
|
|
}
|
|
|
|
func (h *ResourceHandlers) HandleDecideAction(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if mock.IsMockEnabled() {
|
|
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeMockModeEnabled, "Cannot decide actions in mock mode")
|
|
return
|
|
}
|
|
|
|
actionID := strings.TrimSpace(r.PathValue("id"))
|
|
if actionID == "" {
|
|
writeJSONError(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeMissingID, "Missing action ID")
|
|
return
|
|
}
|
|
if !validAuditEventID.MatchString(actionID) || len(actionID) > 128 {
|
|
writeJSONError(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidID, "Invalid action ID format")
|
|
return
|
|
}
|
|
|
|
var decision actionDecisionRequest
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionDecisionRequestBytes))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&decision); err != nil {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionDecision, "Invalid action decision request", map[string]string{
|
|
"body": "request body must be a valid action decision JSON object",
|
|
})
|
|
return
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionDecision, "Invalid action decision request", map[string]string{
|
|
"body": "request body must contain one JSON object",
|
|
})
|
|
return
|
|
}
|
|
decision.Outcome = unified.ApprovalOutcome(strings.TrimSpace(string(decision.Outcome)))
|
|
decision.Reason = strings.TrimSpace(decision.Reason)
|
|
decision.PlanHash = strings.TrimSpace(decision.PlanHash)
|
|
if decision.Outcome != unified.OutcomeApproved && decision.Outcome != unified.OutcomeRejected {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionDecision, "Invalid action decision request", map[string]string{
|
|
"outcome": "outcome must be approved or rejected",
|
|
})
|
|
return
|
|
}
|
|
|
|
orgID := GetOrgID(r.Context())
|
|
actor, err := actionActorForRequest(h.cfg, r, orgID)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionActorUnavailable, "Authenticated action actor is unavailable")
|
|
return
|
|
}
|
|
lifecycle := h.ActionLifecycle()
|
|
record, found, err := lifecycle.Get(orgID, actionID)
|
|
if err != nil {
|
|
writeActionLifecycleReadError(w, err, func() {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit")
|
|
})
|
|
return
|
|
}
|
|
if !found {
|
|
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{"actionId": actionID})
|
|
return
|
|
}
|
|
if decision.PlanHash != "" && decision.PlanHash != record.Plan.PlanHash {
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanIdentityMismatch, "The reviewed action plan changed; reload it before deciding")
|
|
return
|
|
}
|
|
canonicalDecision := unified.ActionDecision{
|
|
Actor: actor,
|
|
Outcome: decision.Outcome,
|
|
Reason: decision.Reason,
|
|
Evidence: approvalEvidenceForRequest(actor, record, decision.Outcome, time.Now().UTC()),
|
|
}
|
|
updated, err := lifecycle.Decide(r.Context(), orgID, actionID, canonicalDecision)
|
|
if err != nil {
|
|
writeActionLifecycleReadError(w, err, func() {
|
|
var persist *actionlifecycle.PersistError
|
|
if errors.As(err, &persist) {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_decision_persist_failed", sanitizeErrorForClient(err, "Failed to persist action decision"))
|
|
return
|
|
}
|
|
writeActionDecisionApplyError(w, err)
|
|
})
|
|
return
|
|
}
|
|
|
|
responseApproval := unified.ActionApprovalRecord{}
|
|
if len(updated.Approvals) > 0 {
|
|
responseApproval = updated.Approvals[len(updated.Approvals)-1]
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(actionDecisionResponse{
|
|
ActionID: updated.ID,
|
|
State: updated.State,
|
|
Approval: responseApproval,
|
|
Audit: updated,
|
|
}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_decision_encode_failed", "Failed to encode action decision")
|
|
}
|
|
}
|
|
|
|
func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if mock.IsMockEnabled() {
|
|
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeMockModeEnabled, "Cannot execute actions in mock mode")
|
|
return
|
|
}
|
|
|
|
actionID := strings.TrimSpace(r.PathValue("id"))
|
|
if actionID == "" {
|
|
writeJSONError(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeMissingID, "Missing action ID")
|
|
return
|
|
}
|
|
if !validAuditEventID.MatchString(actionID) || len(actionID) > 128 {
|
|
writeJSONError(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidID, "Invalid action ID format")
|
|
return
|
|
}
|
|
|
|
var execution actionExecutionRequest
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionExecutionRequestBytes))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&execution); err != nil {
|
|
if !errors.Is(err, io.EOF) {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionExecution, "Invalid action execution request", map[string]string{
|
|
"body": "request body must be a valid action execution JSON object",
|
|
})
|
|
return
|
|
}
|
|
} else if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionExecution, "Invalid action execution request", map[string]string{
|
|
"body": "request body must contain one JSON object",
|
|
})
|
|
return
|
|
}
|
|
execution.Reason = strings.TrimSpace(execution.Reason)
|
|
execution.PlanHash = strings.TrimSpace(execution.PlanHash)
|
|
|
|
orgID := GetOrgID(r.Context())
|
|
actor, err := actionActorForRequest(h.cfg, r, orgID)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionActorUnavailable, "Authenticated action actor is unavailable")
|
|
return
|
|
}
|
|
if execution.PlanHash != "" {
|
|
record, found, err := h.ActionLifecycle().Get(orgID, actionID)
|
|
if err != nil {
|
|
writeActionLifecycleReadError(w, err, func() {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit")
|
|
})
|
|
return
|
|
}
|
|
if !found {
|
|
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{"actionId": actionID})
|
|
return
|
|
}
|
|
if execution.PlanHash != record.Plan.PlanHash {
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanIdentityMismatch, "The approved action plan changed; reload it before execution")
|
|
return
|
|
}
|
|
}
|
|
completed, err := h.ActionLifecycle().Execute(r.Context(), orgID, actionID, actor, execution.Reason)
|
|
if err != nil {
|
|
writeActionLifecycleReadError(w, err, func() {
|
|
writeActionExecuteError(w, err)
|
|
})
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(actionExecutionResponse{
|
|
ActionID: completed.ID,
|
|
State: completed.State,
|
|
Result: completed.Result,
|
|
Audit: completed,
|
|
}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_execution_encode_failed", "Failed to encode action execution")
|
|
}
|
|
}
|
|
|
|
func mockActionAuditsByState(limit int, states ...unified.ActionState) []unified.ActionAuditRecord {
|
|
allowed := make(map[unified.ActionState]struct{}, len(states))
|
|
for _, state := range states {
|
|
allowed[state] = struct{}{}
|
|
}
|
|
actions := make([]unified.ActionAuditRecord, 0, len(states))
|
|
for _, fixture := range mock.ActionFixtures() {
|
|
if _, ok := allowed[fixture.Audit.State]; !ok {
|
|
continue
|
|
}
|
|
actions = append(actions, fixture.Audit)
|
|
if len(actions) == limit {
|
|
break
|
|
}
|
|
}
|
|
return actions
|
|
}
|
|
|
|
func mockActionResourceRegistry() *unified.ResourceRegistry {
|
|
resources, _ := mock.UnifiedResourceSnapshot()
|
|
registry := unified.NewRegistry(nil)
|
|
registry.IngestResources(resources)
|
|
return registry
|
|
}
|
|
|
|
func (h *ResourceHandlers) projectActionAudit(orgID string, record unified.ActionAuditRecord) actionAuditProjection {
|
|
registry, err := h.buildRegistry(orgID)
|
|
if err != nil {
|
|
return actionAuditProjection{ActionAuditRecord: record}
|
|
}
|
|
return projectActionAudit(record, registry)
|
|
}
|
|
|
|
func (h *ResourceHandlers) projectActionAudits(orgID string, records []unified.ActionAuditRecord) []actionAuditProjection {
|
|
registry, err := h.buildRegistry(orgID)
|
|
if err != nil {
|
|
registry = nil
|
|
}
|
|
return projectActionAudits(records, registry)
|
|
}
|
|
|
|
func projectActionAudits(records []unified.ActionAuditRecord, registry *unified.ResourceRegistry) []actionAuditProjection {
|
|
projected := make([]actionAuditProjection, 0, len(records))
|
|
for _, record := range records {
|
|
projected = append(projected, projectActionAudit(record, registry))
|
|
}
|
|
return projected
|
|
}
|
|
|
|
func projectActionAudit(record unified.ActionAuditRecord, registry *unified.ResourceRegistry) actionAuditProjection {
|
|
projection := actionAuditProjection{ActionAuditRecord: record}
|
|
resource, ok := presentationResourceByID(registry, record.Request.ResourceID)
|
|
if !ok || resource == nil || strings.TrimSpace(resource.Name) == "" {
|
|
return projection
|
|
}
|
|
projection.Resource = &actionResourcePresentation{
|
|
ID: unified.CanonicalResourceID(resource.ID),
|
|
Name: strings.TrimSpace(resource.Name),
|
|
Type: resourceContractType(*resource),
|
|
}
|
|
return projection
|
|
}
|
|
|
|
// writeActionLifecycleReadError handles the store/query/not-found failures
|
|
// shared by the decision and execution endpoints, delegating anything else
|
|
// to the endpoint-specific fallback.
|
|
func writeActionLifecycleReadError(w http.ResponseWriter, err error, fallback func()) {
|
|
var notFound *actionlifecycle.ActionNotFoundError
|
|
var query *actionlifecycle.QueryError
|
|
switch {
|
|
case errors.Is(err, actionlifecycle.ErrStoreUnavailable):
|
|
writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available")
|
|
case errors.As(err, &query):
|
|
writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit")
|
|
case errors.As(err, ¬Found):
|
|
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{
|
|
"actionId": notFound.ActionID,
|
|
})
|
|
default:
|
|
fallback()
|
|
}
|
|
}
|
|
|
|
func writeActionExecuteError(w http.ResponseWriter, err error) {
|
|
var persist *actionlifecycle.PersistError
|
|
var freshness *actionlifecycle.FreshnessCheckError
|
|
var policy *actionlifecycle.PolicyCheckError
|
|
switch {
|
|
case errors.Is(err, actionlifecycle.ErrExecutorUnavailable):
|
|
writeJSONError(w, http.StatusNotImplemented, agentcapabilities.AgentErrCodeActionExecutorUnavailable, "No action executor is configured for this API instance")
|
|
case errors.As(err, &persist):
|
|
writeActionExecutionPersistError(w, err)
|
|
case errors.As(err, &freshness):
|
|
writeJSONError(w, http.StatusInternalServerError, "action_plan_validation_failed", sanitizeErrorForClient(err, "Failed to validate action plan freshness"))
|
|
case errors.As(err, &policy):
|
|
writeJSONError(w, http.StatusInternalServerError, "action_policy_validation_failed", sanitizeErrorForClient(err, "Failed to validate action policy"))
|
|
default:
|
|
writeActionExecutionApplyError(w, err)
|
|
}
|
|
}
|
|
|
|
func writeActionDecisionApplyError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, unified.ErrInvalidApprovalOutcome):
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionDecision, "Invalid action decision request", map[string]string{
|
|
"outcome": "outcome must be approved or rejected",
|
|
})
|
|
case errors.Is(err, unified.ErrActionNotPending):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionNotPending, "Action is not pending approval")
|
|
case errors.Is(err, unified.ErrActionPlanExpired):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanExpired, "Action plan has expired")
|
|
case errors.Is(err, actionlifecycle.ErrActionAuthorizationDenied), errors.Is(err, actionlifecycle.ErrApprovalActorNotHuman):
|
|
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionApprovalForbidden, "Current actor is not authorized to decide this action")
|
|
case errors.Is(err, actionlifecycle.ErrApprovalStepUpUnavailable):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionStepUpUnavailable, "This action requires server-verified cryptographic step-up approval")
|
|
case errors.Is(err, actionlifecycle.ErrApprovalEvidenceInvalid), errors.Is(err, actionlifecycle.ErrDecisionReplayConflict), errors.Is(err, unified.ErrDuplicateApprovalActor):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionDecisionConflict, "Action decision conflicts with the authoritative approval record")
|
|
case errors.Is(err, actionlifecycle.ErrApprovalSeparationRequired):
|
|
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionSeparationRequired, "Requester cannot approve this action")
|
|
case errors.Is(err, unified.ErrActionReplanRequired):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionReplanRequired, "Action authority is outdated; re-plan before deciding")
|
|
default:
|
|
writeJSONError(w, http.StatusInternalServerError, "action_decision_failed", sanitizeErrorForClient(err, "Action decision failed"))
|
|
}
|
|
}
|
|
|
|
func writeActionExecutionApplyError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, unified.ErrActionNotApproved):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionNotApproved, "Action is not approved for execution")
|
|
case errors.Is(err, unified.ErrActionAlreadyExecuting):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionAlreadyExecuting, "Action is already executing")
|
|
case errors.Is(err, unified.ErrActionExecutionFinal):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionExecutionFinal, "Action execution is already final")
|
|
case errors.Is(err, unified.ErrActionNotExecuting):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionNotExecuting, "Action is not executing")
|
|
case errors.Is(err, unified.ErrActionPlanExpired):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanExpired, "Action plan has expired")
|
|
case errors.Is(err, unified.ErrActionDryRunOnly):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionDryRunOnly, "Action plan is dry-run only and cannot be executed")
|
|
case errors.Is(err, unified.ErrActionPlanDrift):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanDrift, "Action plan no longer matches the current resource contract; re-plan before executing")
|
|
case errors.Is(err, unified.ErrResourceRemediationLocked):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeResourceRemediationLocked, "Resource is operator-locked against automated remediation")
|
|
case errors.Is(err, actionlifecycle.ErrActionAuthorizationDenied), errors.Is(err, actionlifecycle.ErrApprovalActorNotHuman):
|
|
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionExecutionForbidden, "Current actor is not authorized to execute this action")
|
|
case errors.Is(err, unified.ErrActionReplanRequired):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionReplanRequired, "Action authority is outdated; re-plan before executing")
|
|
default:
|
|
writeJSONError(w, http.StatusInternalServerError, "action_execution_failed", sanitizeErrorForClient(err, "Action execution failed"))
|
|
}
|
|
}
|
|
|
|
func writeActionExecutionPersistError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, unified.ErrActionNotApproved),
|
|
errors.Is(err, unified.ErrActionAlreadyExecuting),
|
|
errors.Is(err, unified.ErrActionExecutionFinal),
|
|
errors.Is(err, unified.ErrActionNotExecuting),
|
|
errors.Is(err, unified.ErrActionPlanExpired),
|
|
errors.Is(err, unified.ErrActionDryRunOnly),
|
|
errors.Is(err, unified.ErrResourceRemediationLocked):
|
|
writeActionExecutionApplyError(w, err)
|
|
default:
|
|
writeJSONError(w, http.StatusInternalServerError, "action_execution_persist_failed", sanitizeErrorForClient(err, "Failed to persist action execution"))
|
|
}
|
|
}
|