Pulse/internal/api/actions.go
rcourtman ee8a24e14a backend and governance: MCP contract, agent capabilities, API, and release-control
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.
2026-06-23 17:26:15 +01:00

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"))
}
}