Implement Hexis confirmations, disabled-by-default risk, and timeout=>unknown
Closes the biggest gap between the running execution engine and ECOSYSTEM-SPEC.md §4.3: confirmations were entirely unmodeled, so any capability could execute unconfirmed regardless of requires_confirmation. - New confirmations table + Confirmation domain type; POST /api/v1/confirmations mints a TTL-bound (120s) confirmation binding capability id+version, target entity, and a sorted-key args hash. - Execute() now requires a valid pending confirmation when the capability demands one: rejects missing, expired, consumed, or args/version-mismatched confirmations; consumes on success. - Capabilities gain enabled (destructive risk defaults to disabled, matching "must be turned on explicitly") and timeout_seconds. - One in-flight execution per (capability_id, target_entity_id); a second concurrent attempt is rejected (surfaced as 409 over HTTP). - Wall-clock timeout per capability now wraps the provider call; on timeout the outcome is "unknown" (new ExecutionStatus), never "failed", and the run is never auto-retried. - 9 new engine tests cover each guard from the spec's Phase 6 gate. Vikunja #274.
This commit is contained in:
+95
-25
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,6 +27,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/capabilities", h.handleCapabilities)
|
||||
mux.HandleFunc("/api/v1/capabilities/", h.handleCapabilityByID)
|
||||
mux.HandleFunc("/api/v1/execute", h.handleExecute)
|
||||
mux.HandleFunc("/api/v1/confirmations", h.handleConfirmations)
|
||||
mux.HandleFunc("/api/v1/executions/", h.handleExecutionByID)
|
||||
mux.HandleFunc("/api/v1/changes", h.handleChanges)
|
||||
}
|
||||
@@ -87,16 +89,19 @@ func (h *Handler) listCapabilities(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) createCapability(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
TargetTypes []string `json:"target_types"`
|
||||
TargetEntityID string `json:"target_entity_id,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
Operation string `json:"operation"`
|
||||
Risk string `json:"risk,omitempty"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
ExpectedSideEffects string `json:"expected_side_effects,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
TargetTypes []string `json:"target_types"`
|
||||
TargetEntityID string `json:"target_entity_id,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
Operation string `json:"operation"`
|
||||
Risk string `json:"risk,omitempty"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
ExpectedSideEffects string `json:"expected_side_effects,omitempty"`
|
||||
RequiresConfirmation bool `json:"requires_confirmation,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("invalid JSON"))
|
||||
@@ -107,22 +112,32 @@ func (h *Handler) createCapability(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destructive capabilities are disabled by default and must be turned on
|
||||
// explicitly (ECOSYSTEM-SPEC.md §4.3).
|
||||
enabled := req.Risk != "destructive"
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
cap := &domain.Capability{
|
||||
ID: domain.NewCapabilityID(),
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
TargetTypes: req.TargetTypes,
|
||||
TargetEntityID: req.TargetEntityID,
|
||||
Provider: req.Provider,
|
||||
Operation: req.Operation,
|
||||
Risk: req.Risk,
|
||||
ReadOnly: req.ReadOnly,
|
||||
ExpectedSideEffects: req.ExpectedSideEffects,
|
||||
Attributes: req.Attributes,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
ID: domain.NewCapabilityID(),
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
TargetTypes: req.TargetTypes,
|
||||
TargetEntityID: req.TargetEntityID,
|
||||
Provider: req.Provider,
|
||||
Operation: req.Operation,
|
||||
Risk: req.Risk,
|
||||
ReadOnly: req.ReadOnly,
|
||||
ExpectedSideEffects: req.ExpectedSideEffects,
|
||||
RequiresConfirmation: req.RequiresConfirmation,
|
||||
Enabled: enabled,
|
||||
TimeoutSeconds: req.TimeoutSeconds,
|
||||
Attributes: req.Attributes,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
}
|
||||
if cap.TargetTypes == nil {
|
||||
cap.TargetTypes = []string{}
|
||||
@@ -198,13 +213,68 @@ func (h *Handler) handleExecute(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result, err := h.engine.Execute(&req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse(err.Error()))
|
||||
writeJSON(w, executeErrorStatus(err), errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result.Execution)
|
||||
}
|
||||
|
||||
func executeErrorStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrExecutionInFlight):
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, domain.ErrCapabilityNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, domain.ErrConfirmationRequired),
|
||||
errors.Is(err, domain.ErrConfirmationInvalid),
|
||||
errors.Is(err, domain.ErrConfirmationExpired),
|
||||
errors.Is(err, domain.ErrConfirmationConsumed),
|
||||
errors.Is(err, domain.ErrConfirmationNotFound),
|
||||
errors.Is(err, domain.ErrCapabilityDisabled),
|
||||
errors.Is(err, domain.ErrCapabilityNotBound):
|
||||
return http.StatusForbidden
|
||||
default:
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleConfirmations(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
CapabilityID string `json:"capability_id"`
|
||||
TargetEntityID string `json:"target_entity_id"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
Requester string `json:"requester,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("invalid JSON"))
|
||||
return
|
||||
}
|
||||
if req.CapabilityID == "" || req.TargetEntityID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("capability_id and target_entity_id are required"))
|
||||
return
|
||||
}
|
||||
|
||||
conf, err := h.engine.CreateConfirmation(req.CapabilityID, req.TargetEntityID, req.Requester, req.Arguments)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, domain.ErrCapabilityNotFound) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, domain.ErrCapabilityNotBound) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeJSON(w, status, errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, conf)
|
||||
}
|
||||
|
||||
func (h *Handler) handleExecutionByID(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
||||
|
||||
Reference in New Issue
Block a user