mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
c501376843
Return persisted planning acceptance or refusal inside the investigation turn. Keep model judgment separate from action authority and preserve accepted action identity across provider failures. Enforce actor/request idempotency atomically and retain complete approval and independent verification context. Preserve unknown disk evidence, stream whitespace and historical resolution timestamps. Keep conversation scrolling inside its own panel. Record real-model, disposable-lab and browser qualification with explicit population limits. Refs #1782
980 lines
45 KiB
Go
980 lines
45 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"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/api/resourceapi"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
|
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const maxActionPlanRequestBytes = 1 << 20
|
|
const maxActionDecisionRequestBytes = 64 << 10
|
|
const maxActionExecutionRequestBytes = 64 << 10
|
|
const maxActionRefreshRequestBytes = 64 << 10
|
|
const maxActionForceFailRequestBytes = 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
|
|
// plan-time and pre-dispatch readiness contract owned by 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 actionRefreshRequest struct {
|
|
PlanHash string `json:"planHash"`
|
|
}
|
|
|
|
// actionForceFailRequest carries the operator's justification for the
|
|
// override. It is optional and is recorded verbatim in the terminal result.
|
|
type actionForceFailRequest struct {
|
|
Reason string `json:"reason,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"`
|
|
// CapabilityAutoAuthorization is the read-time auto-authorization class of
|
|
// the planned capability. It only shapes review presentation (e.g. a
|
|
// single-confirmation control for low-risk capabilities); approval and
|
|
// execution authority remain bound to the persisted plan.
|
|
CapabilityAutoAuthorization unified.ActionAutoAuthorizationClass `json:"capabilityAutoAuthorization,omitempty"`
|
|
// BlastRadius carries read-time presentation for the plan's predicted
|
|
// blast-radius resource IDs so review surfaces can show names instead of
|
|
// raw identifiers. Entries without a resolvable resource keep an empty name.
|
|
BlastRadius []actionResourcePresentation `json:"blastRadius,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"`
|
|
Readiness actionlifecycle.ActionReadiness `json:"readiness"`
|
|
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,
|
|
RefreshPlanner: h.actionRefreshPlanner,
|
|
}
|
|
}
|
|
|
|
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,
|
|
Readiness: actionlifecycle.ActionReadiness{
|
|
Code: "mock_read_only", Message: "Mock actions are read-only.",
|
|
Remediation: "Use a live Pulse instance to approve or run actions.", CheckedAt: time.Now().UTC(),
|
|
},
|
|
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,
|
|
Readiness: h.ActionLifecycle().AssessCurrentReadiness(r.Context(), GetOrgID(r.Context()), detail.Audit),
|
|
}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailEncodeFailed, "Failed to encode action detail")
|
|
}
|
|
}
|
|
|
|
// writeActionExecutionUnavailable renders an availability refusal into the
|
|
// stable 409 contract and logs it. Refusals previously produced only a client
|
|
// toast while the server journal stayed empty, so remote reports of "command
|
|
// agent is not connected" were undiagnosable without source access (#1728).
|
|
func writeActionExecutionUnavailable(w http.ResponseWriter, refusal *actionlifecycle.AvailabilityRefusedError) {
|
|
reason := firstNonEmpty(refusal.Readiness.Reason, "action execution is unavailable")
|
|
details := map[string]string{
|
|
"resourceId": refusal.ResourceID,
|
|
"capabilityName": refusal.CapabilityName,
|
|
"reasonCode": refusal.Readiness.ReasonCode,
|
|
"reason": reason,
|
|
}
|
|
detail := strings.TrimSpace(refusal.Readiness.Detail)
|
|
if detail != "" {
|
|
details["detail"] = detail
|
|
}
|
|
log.Warn().
|
|
Str("resource_id", refusal.ResourceID).
|
|
Str("capability", refusal.CapabilityName).
|
|
Str("reason_code", refusal.Readiness.ReasonCode).
|
|
Str("reason", reason).
|
|
Str("detail", detail).
|
|
Msg("Action refused: capability is not currently executable")
|
|
writeJSONErrorWithDetails(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionExecutionUnavailable, "Action execution is unavailable", details)
|
|
}
|
|
|
|
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):
|
|
writeActionExecutionUnavailable(w, 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.Is(err, unified.ErrActionIdentityConflict):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionRequestConflict, "This request ID is already bound to different action intent or ambiguous historical plans. Review the existing action history before creating a new request.")
|
|
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() {
|
|
if writeActionReadinessError(w, err) {
|
|
return
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
// HandleRefreshAction replaces an expired or drifted immutable plan with a
|
|
// newly validated plan. The reviewed hash binds the request to the record the
|
|
// operator actually saw; broker origin and current authority inputs are
|
|
// reconstructed only by trusted server code.
|
|
func (h *ResourceHandlers) HandleRefreshAction(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 refresh actions in mock mode")
|
|
return
|
|
}
|
|
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
|
|
}
|
|
var refresh actionRefreshRequest
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionRefreshRequestBytes))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&refresh); err != nil {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action refresh request", map[string]string{"body": "request body must contain the reviewed plan hash"})
|
|
return
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action refresh request", map[string]string{"body": "request body must contain one JSON object"})
|
|
return
|
|
}
|
|
refresh.PlanHash = strings.TrimSpace(refresh.PlanHash)
|
|
if refresh.PlanHash == "" {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action refresh request", map[string]string{"planHash": "reviewed plan hash is required"})
|
|
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()
|
|
previous, 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 refresh.PlanHash != previous.Plan.PlanHash {
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanIdentityMismatch, "The reviewed action plan changed; reload it before refreshing")
|
|
return
|
|
}
|
|
replacement, err := lifecycle.Refresh(r.Context(), orgID, actionID, actor)
|
|
if err != nil {
|
|
if errors.Is(err, actionlifecycle.ErrActionRefreshNotAllowed) {
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionRefreshNotAllowed, "Only expired or drifted action plans can be refreshed")
|
|
return
|
|
}
|
|
if writeActionReadinessError(w, err) {
|
|
return
|
|
}
|
|
writeActionPlanError(w, err)
|
|
return
|
|
}
|
|
detail, found, err := lifecycle.Detail(orgID, replacement.ID)
|
|
if err != nil || !found {
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailFailed, "Replacement action detail is unavailable")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(actionDetailResponse{
|
|
Audit: h.projectActionAudit(orgID, detail.Audit),
|
|
Events: detail.Events,
|
|
Attempt: detail.Attempt,
|
|
Receipt: detail.Receipt,
|
|
Readiness: lifecycle.AssessCurrentReadiness(r.Context(), orgID, detail.Audit),
|
|
}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailEncodeFailed, "Failed to encode replacement action detail")
|
|
}
|
|
}
|
|
|
|
// writeActionReadinessError maps failures shared by approval and dispatch to
|
|
// the same stable client contract. Returning true indicates that the response
|
|
// was written.
|
|
func writeActionReadinessError(w http.ResponseWriter, err error) bool {
|
|
var availability *actionlifecycle.AvailabilityRefusedError
|
|
var freshness *actionlifecycle.FreshnessCheckError
|
|
var policy *actionlifecycle.PolicyCheckError
|
|
var availabilityCheck *actionlifecycle.AvailabilityCheckError
|
|
switch {
|
|
case errors.As(err, &availability):
|
|
writeActionExecutionUnavailable(w, availability)
|
|
case errors.Is(err, unified.ErrActionPlanDrift):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanDrift, "Action plan no longer matches the current resource contract; refresh the plan before continuing")
|
|
case errors.Is(err, unified.ErrActionEmergencyStop):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionEmergencyStop, "Action dispatch is stopped by the operator")
|
|
case errors.Is(err, unified.ErrResourceRemediationLocked):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeResourceRemediationLocked, "Resource is operator-locked against remediation")
|
|
case errors.Is(err, unified.ErrActionDryRunOnly):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionDryRunOnly, "Action plan is dry-run only and cannot be approved for execution")
|
|
case errors.Is(err, actionlifecycle.ErrExecutorUnavailable):
|
|
writeJSONError(w, http.StatusServiceUnavailable, agentcapabilities.AgentErrCodeActionExecutorUnavailable, "No action executor is available")
|
|
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"))
|
|
case errors.As(err, &availabilityCheck):
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionReadinessCheckFailed, sanitizeErrorForClient(err, "Failed to validate action execution availability"))
|
|
default:
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
// Detach dispatch from the client connection: reverse proxies and browsers
|
|
// drop long requests well before slow operations (e.g. docker image pulls)
|
|
// finish, and a cancelled request context would abandon the committed
|
|
// dispatch mid-flight and strand the action in executing. The transport
|
|
// wait stays bounded by the per-operation timeout and server shutdown.
|
|
completed, err := h.ActionLifecycle().Execute(context.WithoutCancel(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")
|
|
}
|
|
}
|
|
|
|
// HandleForceFailAction is the operator escape hatch for an action wedged in
|
|
// executing because its agent never returned durable completion evidence.
|
|
// Reconciliation self-heals the known stranded shapes on its own; this exists
|
|
// for the residue an operator has verified by hand. It only terminalizes the
|
|
// audit row — it never sends, resends, or cancels anything on the transport —
|
|
// and it refuses actions that are already final.
|
|
func (h *ResourceHandlers) HandleForceFailAction(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 force-fail 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 override actionForceFailRequest
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionForceFailRequestBytes))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&override); err != nil {
|
|
if !errors.Is(err, io.EOF) {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionExecution, "Invalid action force-fail request", map[string]string{
|
|
"body": "request body must be a valid action force-fail JSON object",
|
|
})
|
|
return
|
|
}
|
|
} else if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionExecution, "Invalid action force-fail request", map[string]string{
|
|
"body": "request body must contain one JSON object",
|
|
})
|
|
return
|
|
}
|
|
override.Reason = strings.TrimSpace(override.Reason)
|
|
|
|
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
|
|
}
|
|
failed, err := h.ActionLifecycle().ForceFail(orgID, actionID, actor.SubjectID, override.Reason)
|
|
if err != nil {
|
|
writeActionLifecycleReadError(w, err, func() {
|
|
var persist *actionlifecycle.PersistError
|
|
if errors.As(err, &persist) {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_force_fail_persist_failed", sanitizeErrorForClient(err, "Failed to persist action force-fail"))
|
|
return
|
|
}
|
|
writeActionExecutionApplyError(w, err)
|
|
})
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(actionExecutionResponse{
|
|
ActionID: failed.ID,
|
|
State: failed.State,
|
|
Result: failed.Result,
|
|
Audit: failed,
|
|
}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_force_fail_encode_failed", "Failed to encode action force-fail")
|
|
}
|
|
}
|
|
|
|
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 := resourceapi.PresentationResourceByID(registry, record.Request.ResourceID)
|
|
if ok && resource != nil && strings.TrimSpace(resource.Name) != "" {
|
|
projection.Resource = &actionResourcePresentation{
|
|
ID: unified.CanonicalResourceID(resource.ID),
|
|
Name: strings.TrimSpace(resource.Name),
|
|
Type: resourceapi.ContractType(*resource),
|
|
}
|
|
for _, capability := range resource.Capabilities {
|
|
if capability.Name == record.Request.CapabilityName {
|
|
projection.CapabilityAutoAuthorization = capability.AutoAuthorization
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if record.Plan.PredictedBlastRadius != nil {
|
|
projection.BlastRadius = make([]actionResourcePresentation, 0, len(record.Plan.PredictedBlastRadius))
|
|
for _, affectedID := range record.Plan.PredictedBlastRadius {
|
|
entry := actionResourcePresentation{ID: affectedID}
|
|
if affected, found := resourceapi.PresentationResourceByID(registry, affectedID); found && affected != nil {
|
|
entry.ID = unified.CanonicalResourceID(affected.ID)
|
|
entry.Name = strings.TrimSpace(affected.Name)
|
|
entry.Type = resourceapi.ContractType(*affected)
|
|
}
|
|
projection.BlastRadius = append(projection.BlastRadius, entry)
|
|
}
|
|
}
|
|
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
|
|
var availability *actionlifecycle.AvailabilityRefusedError
|
|
var availabilityCheck *actionlifecycle.AvailabilityCheckError
|
|
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, &availability):
|
|
writeActionExecutionUnavailable(w, availability)
|
|
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"))
|
|
case errors.As(err, &availabilityCheck):
|
|
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionReadinessCheckFailed, sanitizeErrorForClient(err, "Failed to validate action execution availability"))
|
|
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.ErrActionExecutionUnavailable):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionExecutionUnavailable, "Action execution is unavailable")
|
|
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"))
|
|
}
|
|
}
|