Files
hexis/internal/execution/engine.go
T
kami 89d8433d17 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.
2026-07-20 00:58:42 +04:00

284 lines
8.1 KiB
Go

package execution
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/kami/hexis/internal/domain"
"github.com/kami/hexis/internal/provider"
"github.com/kami/hexis/internal/storage"
)
const defaultTimeout = 30 * time.Second
type Engine struct {
store storage.Interface
registry *provider.Registry
}
func New(store storage.Interface, registry *provider.Registry) *Engine {
return &Engine{store: store, registry: registry}
}
type ExecuteResult struct {
Execution *domain.Execution `json:"execution"`
}
func (e *Engine) Execute(req *domain.ExecuteRequest) (*ExecuteResult, error) {
if req.IdempotencyKey != "" {
existing, err := e.store.GetExecutionByIdempotencyKey(req.IdempotencyKey)
if err == nil {
return &ExecuteResult{Execution: existing}, nil
}
}
capability, err := e.store.GetCapability(req.CapabilityID)
if err != nil {
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)
}
now := time.Now().UTC()
exec := &domain.Execution{
ID: domain.NewExecutionID(),
CapabilityID: req.CapabilityID,
TargetEntityID: req.TargetEntityID,
EntityVersion: req.EntityVersion,
Arguments: req.Arguments,
RequestedBy: req.RequestedBy,
Origin: req.Origin,
IdempotencyKey: req.IdempotencyKey,
ConfirmationID: req.ConfirmationID,
Status: domain.ExecutionStarted,
CorrelationID: req.CorrelationID,
ResolutionEvidence: req.ResolutionEvidence,
CreatedAt: now,
UpdatedAt: now,
}
if exec.Arguments == nil {
exec.Arguments = map[string]any{}
}
if exec.RequestedBy == nil {
exec.RequestedBy = map[string]string{}
}
if exec.Origin == nil {
exec.Origin = map[string]string{}
}
if err := e.store.CreateExecution(exec); err != nil {
return nil, err
}
e.emitEvent(domain.EventExecutionStarted, exec.ID, map[string]any{
"capability_id": req.CapabilityID,
"target_entity_id": req.TargetEntityID,
"provider": capability.Provider,
"correlation_id": req.CorrelationID,
})
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()
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()}
e.emitEvent(domain.EventExecutionFailed, exec.ID, map[string]any{
"capability_id": req.CapabilityID,
"error": execErr.Error(),
})
default:
exec.Status = domain.ExecutionSucceeded
exec.Result = result
e.emitEvent(domain.EventExecutionSucceeded, exec.ID, map[string]any{
"capability_id": req.CapabilityID,
})
}
if err := e.store.UpdateExecution(exec); err != nil {
return nil, err
}
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 {
return err
}
if cap.TargetEntityID != "" && cap.TargetEntityID != entityID {
return domain.ErrCapabilityNotBound
}
return nil
}
func (e *Engine) emitEvent(evtType domain.HexisEventType, entityID string, payload map[string]any) {
e.store.AppendEvent(&domain.Event{
ID: domain.NewEventID(),
Type: evtType,
Timestamp: time.Now().UTC(),
Payload: payload,
})
}
type ResolveRequest struct {
Query string `json:"query"`
Types []string `json:"types,omitempty"`
}
type ResolveResult struct {
Status string `json:"status"`
Candidates []map[string]any `json:"candidates,omitempty"`
EntityID string `json:"entity_id,omitempty"`
}
var _ json.Marshaler = (*ResolveResult)(nil)
func (r *ResolveResult) MarshalJSON() ([]byte, error) {
m := map[string]any{"status": r.Status}
if r.Candidates != nil {
m["candidates"] = r.Candidates
}
if r.EntityID != "" {
m["entity_id"] = r.EntityID
}
return json.Marshal(m)
}