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:
@@ -1,6 +1,7 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
"github.com/kami/hexis/internal/storage"
|
||||
)
|
||||
|
||||
const defaultTimeout = 30 * time.Second
|
||||
|
||||
type Engine struct {
|
||||
store storage.Interface
|
||||
registry *provider.Registry
|
||||
@@ -36,10 +39,25 @@ func (e *Engine) Execute(req *domain.ExecuteRequest) (*ExecuteResult, error) {
|
||||
return nil, fmt.Errorf("capability: %w", err)
|
||||
}
|
||||
|
||||
if !capability.Enabled {
|
||||
return nil, domain.ErrCapabilityDisabled
|
||||
}
|
||||
|
||||
if capability.TargetEntityID != "" && capability.TargetEntityID != req.TargetEntityID {
|
||||
return nil, domain.ErrCapabilityNotBound
|
||||
}
|
||||
|
||||
if capability.RequiresConfirmation {
|
||||
if err := e.consumeConfirmation(req, capability); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// One in-flight execution per (capability_id, target_entity_id).
|
||||
if _, err := e.store.GetInFlightExecution(req.CapabilityID, req.TargetEntityID); err == nil {
|
||||
return nil, domain.ErrExecutionInFlight
|
||||
}
|
||||
|
||||
prov, err := e.registry.Get(capability.Provider)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider: %w", err)
|
||||
@@ -55,6 +73,7 @@ func (e *Engine) Execute(req *domain.ExecuteRequest) (*ExecuteResult, error) {
|
||||
RequestedBy: req.RequestedBy,
|
||||
Origin: req.Origin,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
ConfirmationID: req.ConfirmationID,
|
||||
Status: domain.ExecutionStarted,
|
||||
CorrelationID: req.CorrelationID,
|
||||
ResolutionEvidence: req.ResolutionEvidence,
|
||||
@@ -76,16 +95,33 @@ func (e *Engine) Execute(req *domain.ExecuteRequest) (*ExecuteResult, error) {
|
||||
}
|
||||
|
||||
e.emitEvent(domain.EventExecutionStarted, exec.ID, map[string]any{
|
||||
"capability_id": req.CapabilityID,
|
||||
"capability_id": req.CapabilityID,
|
||||
"target_entity_id": req.TargetEntityID,
|
||||
"provider": capability.Provider,
|
||||
"correlation_id": req.CorrelationID,
|
||||
"provider": capability.Provider,
|
||||
"correlation_id": req.CorrelationID,
|
||||
})
|
||||
|
||||
result, execErr := prov.Execute(capability, req)
|
||||
timeout := defaultTimeout
|
||||
if capability.TimeoutSeconds > 0 {
|
||||
timeout = time.Duration(capability.TimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
result, execErr, timedOut := e.runWithTimeout(prov, capability, req, timeout)
|
||||
exec.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if execErr != nil {
|
||||
switch {
|
||||
case timedOut:
|
||||
// Outcome is never "failed" on timeout, and never retried automatically
|
||||
// per ECOSYSTEM-SPEC.md §4.3 — the side effect may or may not have landed.
|
||||
exec.Status = domain.ExecutionUnknown
|
||||
exec.Error = "execution timed out after " + timeout.String()
|
||||
exec.Result = map[string]any{"error": exec.Error}
|
||||
e.emitEvent(domain.EventExecutionFailed, exec.ID, map[string]any{
|
||||
"capability_id": req.CapabilityID,
|
||||
"outcome": "unknown",
|
||||
"reason": "timeout",
|
||||
})
|
||||
case execErr != nil:
|
||||
exec.Status = domain.ExecutionFailed
|
||||
exec.Error = execErr.Error()
|
||||
exec.Result = map[string]any{"error": execErr.Error()}
|
||||
@@ -93,7 +129,7 @@ func (e *Engine) Execute(req *domain.ExecuteRequest) (*ExecuteResult, error) {
|
||||
"capability_id": req.CapabilityID,
|
||||
"error": execErr.Error(),
|
||||
})
|
||||
} else {
|
||||
default:
|
||||
exec.Status = domain.ExecutionSucceeded
|
||||
exec.Result = result
|
||||
e.emitEvent(domain.EventExecutionSucceeded, exec.ID, map[string]any{
|
||||
@@ -108,6 +144,100 @@ func (e *Engine) Execute(req *domain.ExecuteRequest) (*ExecuteResult, error) {
|
||||
return &ExecuteResult{Execution: exec}, nil
|
||||
}
|
||||
|
||||
// runWithTimeout executes the provider call on a goroutine and returns
|
||||
// (nil, nil, true) if it doesn't finish within timeout. The goroutine is not
|
||||
// cancelled — providers don't accept a context today — so a slow executor
|
||||
// keeps running in the background; its eventual result is discarded, never
|
||||
// retried, and the caller has already moved on with outcome=unknown.
|
||||
func (e *Engine) runWithTimeout(prov provider.Provider, capability *domain.Capability, req *domain.ExecuteRequest, timeout time.Duration) (map[string]any, error, bool) {
|
||||
type outcome struct {
|
||||
result map[string]any
|
||||
err error
|
||||
}
|
||||
ch := make(chan outcome, 1)
|
||||
go func() {
|
||||
r, err := prov.Execute(capability, req)
|
||||
ch <- outcome{result: r, err: err}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case o := <-ch:
|
||||
return o.result, o.err, false
|
||||
case <-ctx.Done():
|
||||
return nil, nil, true
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) consumeConfirmation(req *domain.ExecuteRequest, capability *domain.Capability) error {
|
||||
if req.ConfirmationID == "" {
|
||||
return domain.ErrConfirmationRequired
|
||||
}
|
||||
conf, err := e.store.GetConfirmation(req.ConfirmationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if conf.State != domain.ConfirmationPending {
|
||||
return domain.ErrConfirmationConsumed
|
||||
}
|
||||
if time.Now().UTC().After(conf.ExpiresAt) {
|
||||
e.store.UpdateConfirmationState(conf.ID, domain.ConfirmationExpired)
|
||||
return domain.ErrConfirmationExpired
|
||||
}
|
||||
if conf.CapabilityID != capability.ID || conf.CapabilityVersion != capability.Version {
|
||||
return domain.ErrConfirmationInvalid
|
||||
}
|
||||
if conf.TargetEntityID != req.TargetEntityID {
|
||||
return domain.ErrConfirmationInvalid
|
||||
}
|
||||
normalized, err := domain.NormalizeArgs(req.Arguments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if domain.HashArgs(normalized) != conf.ArgsHash {
|
||||
return domain.ErrConfirmationInvalid
|
||||
}
|
||||
|
||||
return e.store.UpdateConfirmationState(conf.ID, domain.ConfirmationConsumed)
|
||||
}
|
||||
|
||||
// CreateConfirmation validates a would-be execute request against a
|
||||
// capability and mints a time-bounded confirmation for it.
|
||||
func (e *Engine) CreateConfirmation(capabilityID, targetEntityID, requester string, args map[string]any) (*domain.Confirmation, error) {
|
||||
capability, err := e.store.GetCapability(capabilityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capability: %w", err)
|
||||
}
|
||||
if capability.TargetEntityID != "" && capability.TargetEntityID != targetEntityID {
|
||||
return nil, domain.ErrCapabilityNotBound
|
||||
}
|
||||
|
||||
normalized, err := domain.NormalizeArgs(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
conf := &domain.Confirmation{
|
||||
ID: domain.NewConfirmationID(),
|
||||
CapabilityID: capability.ID,
|
||||
CapabilityVersion: capability.Version,
|
||||
TargetEntityID: targetEntityID,
|
||||
ArgsNormalized: normalized,
|
||||
ArgsHash: domain.HashArgs(normalized),
|
||||
Requester: requester,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(domain.ConfirmationTTL),
|
||||
State: domain.ConfirmationPending,
|
||||
}
|
||||
if err := e.store.CreateConfirmation(conf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
func (e *Engine) ValidateTarget(capabilityID, entityID string) error {
|
||||
cap, err := e.store.GetCapability(capabilityID)
|
||||
if err != nil {
|
||||
@@ -134,9 +264,9 @@ type ResolveRequest struct {
|
||||
}
|
||||
|
||||
type ResolveResult struct {
|
||||
Status string `json:"status"`
|
||||
Candidates []map[string]any `json:"candidates,omitempty"`
|
||||
EntityID string `json:"entity_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Candidates []map[string]any `json:"candidates,omitempty"`
|
||||
EntityID string `json:"entity_id,omitempty"`
|
||||
}
|
||||
|
||||
var _ json.Marshaler = (*ResolveResult)(nil)
|
||||
|
||||
Reference in New Issue
Block a user