89d8433d17
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.
284 lines
8.1 KiB
Go
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)
|
|
}
|