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