mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Manifest-backed MCP tools, prompts, and resources with surface affordance contracts; agent capability manifest and governance projection; API contract tests and capability route projection; operations-loop and intelligence-funnel telemetry; release-control subsystem documentation, registry, and tooling; licensing and configuration.
597 lines
23 KiB
Go
597 lines
23 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
|
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
const maxActionPlanRequestBytes = 1 << 20
|
|
const maxActionDecisionRequestBytes = 64 << 10
|
|
const maxActionExecutionRequestBytes = 64 << 10
|
|
|
|
// ActionExecutor runs a previously planned and approved action through the
|
|
// API-owned execution contract.
|
|
type ActionExecutor interface {
|
|
ExecuteAction(ctx context.Context, record unified.ActionAuditRecord) (*unified.ExecutionResult, error)
|
|
}
|
|
|
|
// ActionAvailabilityChecker lets an executor contribute live readiness checks
|
|
// before Pulse advertises or persists an executable action plan.
|
|
type ActionAvailabilityChecker interface {
|
|
CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness
|
|
}
|
|
|
|
type actionDecisionRequest struct {
|
|
Outcome unified.ApprovalOutcome `json:"outcome"`
|
|
Reason string `json:"reason,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"`
|
|
}
|
|
|
|
type actionExecutionResponse struct {
|
|
ActionID string `json:"actionId"`
|
|
State unified.ActionState `json:"state"`
|
|
Result *unified.ExecutionResult `json:"result,omitempty"`
|
|
Audit unified.ActionAuditRecord `json:"audit"`
|
|
}
|
|
|
|
func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req unified.ActionRequest
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionPlanRequestBytes))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&req); 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
|
|
}
|
|
|
|
req.ResourceID = unified.CanonicalResourceID(req.ResourceID)
|
|
if req.ResourceID == "" {
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action planning request", map[string]string{
|
|
"resourceId": "resource id is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
orgID := GetOrgID(r.Context())
|
|
registry, err := h.buildRegistry(orgID)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "resource_registry_unavailable", sanitizeErrorForClient(err, "Resource registry unavailable"))
|
|
return
|
|
}
|
|
|
|
resource, ok := registry.Get(req.ResourceID)
|
|
if !ok || resource == nil {
|
|
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeResourceNotFound, "Resource not found", map[string]string{
|
|
"resourceId": req.ResourceID,
|
|
})
|
|
return
|
|
}
|
|
|
|
plan, err := (actionplanner.Planner{}).Plan(req, *resource)
|
|
if err != nil {
|
|
if validationErr, ok := actionplanner.AsValidationError(err); ok {
|
|
details := map[string]string{}
|
|
if validationErr.Field != "" {
|
|
details[validationErr.Field] = validationErr.Message
|
|
}
|
|
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action planning request", details)
|
|
return
|
|
}
|
|
if errors.Is(err, actionplanner.ErrCapabilityNotFound) {
|
|
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeCapabilityNotFound, "Capability not found on resource", map[string]string{
|
|
"resourceId": req.ResourceID,
|
|
"capabilityName": req.CapabilityName,
|
|
})
|
|
return
|
|
}
|
|
writeJSONError(w, http.StatusInternalServerError, "action_plan_failed", sanitizeErrorForClient(err, "Action planning failed"))
|
|
return
|
|
}
|
|
|
|
req = normalizeActionRequestForAudit(req)
|
|
if checker, ok := h.actionExecutor.(ActionAvailabilityChecker); ok {
|
|
if readiness := checker.CheckActionAvailable(r.Context(), req, *resource); readiness.Name != "" && !readiness.Available {
|
|
writeJSONErrorWithDetails(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionExecutionUnavailable, "Action execution is unavailable", map[string]string{
|
|
"resourceId": req.ResourceID,
|
|
"capabilityName": req.CapabilityName,
|
|
"reasonCode": readiness.ReasonCode,
|
|
"reason": firstNonEmpty(readiness.Reason, "action execution is unavailable"),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
store, err := h.getStore(orgID)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available")
|
|
return
|
|
}
|
|
if err := persistActionPlanAudit(store, req, plan); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_audit_persist_failed", sanitizeErrorForClient(err, "Failed to persist action audit"))
|
|
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")
|
|
}
|
|
}
|
|
|
|
func normalizeActionRequestForAudit(req unified.ActionRequest) unified.ActionRequest {
|
|
req.RequestID = strings.TrimSpace(req.RequestID)
|
|
req.ResourceID = unified.CanonicalResourceID(req.ResourceID)
|
|
req.CapabilityName = strings.TrimSpace(req.CapabilityName)
|
|
req.Reason = strings.TrimSpace(req.Reason)
|
|
req.RequestedBy = strings.TrimSpace(req.RequestedBy)
|
|
if req.Params == nil {
|
|
req.Params = map[string]any{}
|
|
}
|
|
return req
|
|
}
|
|
|
|
func persistActionPlanAudit(store unified.ResourceStore, req unified.ActionRequest, plan unified.ActionPlan) error {
|
|
state := plannedActionState(plan)
|
|
record := unified.ActionAuditRecord{
|
|
ID: plan.ActionID,
|
|
CreatedAt: plan.PlannedAt,
|
|
UpdatedAt: plan.PlannedAt,
|
|
State: state,
|
|
Request: req,
|
|
Plan: plan,
|
|
}
|
|
if err := store.RecordActionAudit(record); err != nil {
|
|
return err
|
|
}
|
|
|
|
existingEvents, err := store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 100)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
seenStates := map[unified.ActionState]bool{}
|
|
for _, event := range existingEvents {
|
|
seenStates[event.State] = true
|
|
}
|
|
|
|
if !seenStates[unified.ActionStatePlanned] {
|
|
if err := store.RecordActionLifecycleEvent(unified.ActionLifecycleEvent{
|
|
ActionID: plan.ActionID,
|
|
Timestamp: plan.PlannedAt,
|
|
State: unified.ActionStatePlanned,
|
|
Actor: req.RequestedBy,
|
|
Message: "Action plan created.",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if state != unified.ActionStatePlanned && !seenStates[state] {
|
|
if err := store.RecordActionLifecycleEvent(unified.ActionLifecycleEvent{
|
|
ActionID: plan.ActionID,
|
|
Timestamp: plan.PlannedAt,
|
|
State: state,
|
|
Actor: req.RequestedBy,
|
|
Message: "Action is waiting for approval before execution.",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func plannedActionState(plan unified.ActionPlan) unified.ActionState {
|
|
if plan.RequiresApproval {
|
|
return unified.ActionStatePending
|
|
}
|
|
return unified.ActionStatePlanned
|
|
}
|
|
|
|
func (h *ResourceHandlers) HandleDecideAction(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
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)
|
|
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())
|
|
store, err := h.getStore(orgID)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available")
|
|
return
|
|
}
|
|
record, ok, err := store.GetActionAudit(actionID)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit")
|
|
return
|
|
}
|
|
if !ok {
|
|
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{
|
|
"actionId": actionID,
|
|
})
|
|
return
|
|
}
|
|
|
|
actor := actionDecisionActor(h, r)
|
|
now := time.Now().UTC()
|
|
approval := unified.ActionApprovalRecord{
|
|
Actor: actor,
|
|
Method: unified.MethodAPI,
|
|
Timestamp: now,
|
|
Outcome: decision.Outcome,
|
|
Reason: decision.Reason,
|
|
}
|
|
updated, event, err := unified.ApplyActionDecision(record, approval, now)
|
|
if err != nil {
|
|
writeActionDecisionApplyError(w, err)
|
|
return
|
|
}
|
|
if err := store.RecordActionDecision(updated, event); err != nil {
|
|
if errors.Is(err, unified.ErrActionNotPending) {
|
|
writeActionDecisionApplyError(w, err)
|
|
return
|
|
}
|
|
writeJSONError(w, http.StatusInternalServerError, "action_decision_persist_failed", sanitizeErrorForClient(err, "Failed to persist action decision"))
|
|
return
|
|
}
|
|
|
|
responseApproval := approval
|
|
if len(updated.Approvals) > 0 {
|
|
responseApproval = updated.Approvals[len(updated.Approvals)-1]
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(actionDecisionResponse{
|
|
ActionID: updated.ID,
|
|
State: updated.State,
|
|
Approval: responseApproval,
|
|
Audit: updated,
|
|
}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_decision_encode_failed", "Failed to encode action decision")
|
|
}
|
|
}
|
|
|
|
func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
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)
|
|
|
|
orgID := GetOrgID(r.Context())
|
|
store, err := h.getStore(orgID)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "action_audit_unavailable", "Action audit history is not available")
|
|
return
|
|
}
|
|
record, ok, err := store.GetActionAudit(actionID)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit")
|
|
return
|
|
}
|
|
if !ok {
|
|
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{
|
|
"actionId": actionID,
|
|
})
|
|
return
|
|
}
|
|
|
|
actor := actionDecisionActor(h, r)
|
|
now := time.Now().UTC()
|
|
if err := unified.ValidateActionExecutionStart(record, now); err != nil {
|
|
if unified.IsPermanentActionExecutionRefusal(err) {
|
|
if failed, persistErr := recordRefusedActionExecution(store, record, actor, now, err); persistErr == nil {
|
|
h.publishActionCompleted(failed)
|
|
} else {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_execution_persist_failed", sanitizeErrorForClient(persistErr, "Failed to persist refused action execution"))
|
|
return
|
|
}
|
|
}
|
|
writeActionExecutionApplyError(w, err)
|
|
return
|
|
}
|
|
if h.actionExecutor == nil {
|
|
writeJSONError(w, http.StatusNotImplemented, agentcapabilities.AgentErrCodeActionExecutorUnavailable, "No action executor is configured for this API instance")
|
|
return
|
|
}
|
|
if err := h.validateActionPlanFresh(orgID, record); err != nil {
|
|
if errors.Is(err, unified.ErrActionPlanDrift) {
|
|
if failed, persistErr := recordRefusedActionExecution(store, record, actor, now, err); persistErr == nil {
|
|
h.publishActionCompleted(failed)
|
|
} else {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_execution_persist_failed", sanitizeErrorForClient(persistErr, "Failed to persist refused action execution"))
|
|
return
|
|
}
|
|
writeActionExecutionApplyError(w, err)
|
|
return
|
|
}
|
|
writeJSONError(w, http.StatusInternalServerError, "action_plan_validation_failed", sanitizeErrorForClient(err, "Failed to validate action plan freshness"))
|
|
return
|
|
}
|
|
if err := h.validateActionExecutionPolicy(store, record); err != nil {
|
|
if unified.IsPermanentActionExecutionRefusal(err) {
|
|
if failed, persistErr := recordRefusedActionExecution(store, record, actor, now, err); persistErr == nil {
|
|
h.publishActionCompleted(failed)
|
|
} else {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_execution_persist_failed", sanitizeErrorForClient(persistErr, "Failed to persist refused action execution"))
|
|
return
|
|
}
|
|
writeActionExecutionApplyError(w, err)
|
|
return
|
|
}
|
|
writeJSONError(w, http.StatusInternalServerError, "action_policy_validation_failed", sanitizeErrorForClient(err, "Failed to validate action policy"))
|
|
return
|
|
}
|
|
|
|
started, startEvent, err := unified.BeginActionExecution(record, actor, now)
|
|
if err != nil {
|
|
writeActionExecutionApplyError(w, err)
|
|
return
|
|
}
|
|
if execution.Reason != "" {
|
|
startEvent.Message = "Action execution started: " + execution.Reason
|
|
}
|
|
if err := store.RecordActionExecutionStart(started, startEvent); err != nil {
|
|
writeActionExecutionPersistError(w, err)
|
|
return
|
|
}
|
|
|
|
result, execErr := h.actionExecutor.ExecuteAction(r.Context(), started)
|
|
if execErr != nil {
|
|
result = &unified.ExecutionResult{Success: false, ErrorMessage: execErr.Error()}
|
|
}
|
|
completed, doneEvent, err := unified.CompleteActionExecution(started, result, actor, time.Now().UTC())
|
|
if err != nil {
|
|
writeActionExecutionApplyError(w, err)
|
|
return
|
|
}
|
|
if err := store.RecordActionExecutionResult(completed, doneEvent); err != nil {
|
|
writeActionExecutionPersistError(w, err)
|
|
return
|
|
}
|
|
h.publishActionCompleted(completed)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(actionExecutionResponse{
|
|
ActionID: completed.ID,
|
|
State: completed.State,
|
|
Result: completed.Result,
|
|
Audit: completed,
|
|
}); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "action_execution_encode_failed", "Failed to encode action execution")
|
|
}
|
|
}
|
|
|
|
func (h *ResourceHandlers) validateActionPlanFresh(orgID string, record unified.ActionAuditRecord) error {
|
|
if h == nil {
|
|
return fmt.Errorf("%w: resource handler unavailable", unified.ErrActionPlanDrift)
|
|
}
|
|
normalized, err := unified.NormalizeActionAuditRecord(record)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", unified.ErrActionPlanDrift, err)
|
|
}
|
|
registry, err := h.buildRegistry(orgID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resource, ok := registry.Get(normalized.Request.ResourceID)
|
|
if !ok || resource == nil {
|
|
return fmt.Errorf("%w: resource %q is no longer present", unified.ErrActionPlanDrift, normalized.Request.ResourceID)
|
|
}
|
|
currentPlan, err := (actionplanner.Planner{Now: func() time.Time {
|
|
return normalized.Plan.PlannedAt
|
|
}}).Plan(normalized.Request, *resource)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", unified.ErrActionPlanDrift, err)
|
|
}
|
|
if currentPlan.ActionID != normalized.Plan.ActionID {
|
|
return fmt.Errorf("%w: action identity changed", unified.ErrActionPlanDrift)
|
|
}
|
|
if currentPlan.PlanHash != normalized.Plan.PlanHash {
|
|
return fmt.Errorf("%w: plan hash changed", unified.ErrActionPlanDrift)
|
|
}
|
|
if currentPlan.ResourceVersion != normalized.Plan.ResourceVersion {
|
|
return fmt.Errorf("%w: resource version changed", unified.ErrActionPlanDrift)
|
|
}
|
|
if currentPlan.PolicyVersion != normalized.Plan.PolicyVersion {
|
|
return fmt.Errorf("%w: capability policy changed", unified.ErrActionPlanDrift)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *ResourceHandlers) validateActionExecutionPolicy(store unified.ResourceStore, record unified.ActionAuditRecord) error {
|
|
if store == nil {
|
|
return errors.New("action audit store unavailable")
|
|
}
|
|
normalized, err := unified.NormalizeActionAuditRecord(record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
state, found, err := store.GetResourceOperatorState(normalized.Request.ResourceID)
|
|
if err != nil || !found {
|
|
return err
|
|
}
|
|
if state.NeverAutoRemediate {
|
|
return unified.ErrResourceRemediationLocked
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func recordRefusedActionExecution(store unified.ResourceStore, record unified.ActionAuditRecord, actor string, now time.Time, reason error) (unified.ActionAuditRecord, error) {
|
|
failed, event, err := unified.RefuseActionExecution(record, reason, actor, now)
|
|
if err != nil {
|
|
return unified.ActionAuditRecord{}, err
|
|
}
|
|
if store == nil {
|
|
return unified.ActionAuditRecord{}, errors.New("action audit store unavailable")
|
|
}
|
|
if err := store.RecordActionAudit(failed); err != nil {
|
|
return unified.ActionAuditRecord{}, err
|
|
}
|
|
if err := store.RecordActionLifecycleEvent(event); err != nil {
|
|
return unified.ActionAuditRecord{}, err
|
|
}
|
|
return failed, nil
|
|
}
|
|
|
|
func (h *ResourceHandlers) publishActionCompleted(record unified.ActionAuditRecord) {
|
|
if h == nil || h.actionCompleted == nil {
|
|
return
|
|
}
|
|
if record.State != unified.ActionStateCompleted && record.State != unified.ActionStateFailed {
|
|
return
|
|
}
|
|
h.actionCompleted(record)
|
|
}
|
|
|
|
func actionDecisionActor(h *ResourceHandlers, r *http.Request) string {
|
|
if h != nil {
|
|
if actor := strings.TrimSpace(getAuthUsername(h.cfg, r)); actor != "" {
|
|
return actor
|
|
}
|
|
}
|
|
if actor := strings.TrimSpace(getUserID(r)); actor != "" {
|
|
return actor
|
|
}
|
|
return "api:authenticated"
|
|
}
|
|
|
|
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")
|
|
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, "action_not_executing", "Action is not executing")
|
|
case errors.Is(err, unified.ErrActionPlanExpired):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanExpired, "Action plan has expired")
|
|
case errors.Is(err, unified.ErrActionDryRunOnly):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionDryRunOnly, "Action plan is dry-run only and cannot be executed")
|
|
case errors.Is(err, unified.ErrActionPlanDrift):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanDrift, "Action plan no longer matches the current resource contract; re-plan before executing")
|
|
case errors.Is(err, unified.ErrResourceRemediationLocked):
|
|
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeResourceRemediationLocked, "Resource is operator-locked against automated remediation")
|
|
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"))
|
|
}
|
|
}
|