Files
pulse/internal/api/actions.go
T
rcourtman ea5c105ff2 Log refused actions and name the missed command-agent lookup
A refused action plan, decision, or execution returned its 409 to the
client and left no trace in the server journal, so every remote report
of "Docker / Podman command agent is not connected" stalled on greps
that could never match anything. Refusals now log one warn line with
the resource, capability, and reason code, and the Docker command-agent
resolver reports which lookup missed (stale enrollment token binding vs
agent-id/hostname session), carried as an optional diagnostic detail on
the readiness contract and in the refusal envelope.

Refs #1728
2026-08-23 06:33:30 +01:00

978 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, &notFound):
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.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, &notFound):
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"))
}
}