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:
@@ -0,0 +1,293 @@
|
||||
package execution_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
"github.com/kami/hexis/internal/execution"
|
||||
"github.com/kami/hexis/internal/provider"
|
||||
"github.com/kami/hexis/internal/storage"
|
||||
)
|
||||
|
||||
type fakeProvider struct {
|
||||
name string
|
||||
delay time.Duration
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *fakeProvider) Name() string { return p.name }
|
||||
|
||||
func (p *fakeProvider) Execute(capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error) {
|
||||
if p.delay > 0 {
|
||||
time.Sleep(p.delay)
|
||||
}
|
||||
if p.err != nil {
|
||||
return nil, p.err
|
||||
}
|
||||
return map[string]any{"ok": true}, nil
|
||||
}
|
||||
|
||||
func newTestEngine(t *testing.T, prov *fakeProvider) (*execution.Engine, *storage.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store, err := storage.Open(filepath.Join(dir, "hexis.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open storage: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
reg := provider.NewRegistry()
|
||||
reg.Register(prov)
|
||||
|
||||
return execution.New(store, reg), store
|
||||
}
|
||||
|
||||
func mustCreateCapability(t *testing.T, store storage.Interface, mutate func(*domain.Capability)) *domain.Capability {
|
||||
t.Helper()
|
||||
now := time.Now().UTC()
|
||||
cap := &domain.Capability{
|
||||
ID: domain.NewCapabilityID(),
|
||||
Name: "test.capability",
|
||||
Provider: "fake",
|
||||
Operation: "noop",
|
||||
Risk: "low",
|
||||
ReadOnly: false,
|
||||
Enabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
TargetTypes: []string{},
|
||||
Attributes: map[string]any{},
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(cap)
|
||||
}
|
||||
if err := store.CreateCapability(cap); err != nil {
|
||||
t.Fatalf("create capability: %v", err)
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
func TestExecute_FreeTextTargetRejectedWhenBound(t *testing.T) {
|
||||
prov := &fakeProvider{name: "fake"}
|
||||
eng, store := newTestEngine(t, prov)
|
||||
cap := mustCreateCapability(t, store, func(c *domain.Capability) {
|
||||
c.TargetEntityID = "ent_bound_only"
|
||||
})
|
||||
|
||||
_, err := eng.Execute(&domain.ExecuteRequest{
|
||||
CapabilityID: cap.ID,
|
||||
TargetEntityID: "ent_someone_else",
|
||||
})
|
||||
if !errors.Is(err, domain.ErrCapabilityNotBound) {
|
||||
t.Fatalf("expected ErrCapabilityNotBound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_DisabledCapabilityRejected(t *testing.T) {
|
||||
prov := &fakeProvider{name: "fake"}
|
||||
eng, store := newTestEngine(t, prov)
|
||||
cap := mustCreateCapability(t, store, func(c *domain.Capability) {
|
||||
c.Enabled = false
|
||||
})
|
||||
|
||||
_, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_x"})
|
||||
if !errors.Is(err, domain.ErrCapabilityDisabled) {
|
||||
t.Fatalf("expected ErrCapabilityDisabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_ConfirmationRequiredButMissing(t *testing.T) {
|
||||
prov := &fakeProvider{name: "fake"}
|
||||
eng, store := newTestEngine(t, prov)
|
||||
cap := mustCreateCapability(t, store, func(c *domain.Capability) {
|
||||
c.RequiresConfirmation = true
|
||||
})
|
||||
|
||||
_, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_x"})
|
||||
if !errors.Is(err, domain.ErrConfirmationRequired) {
|
||||
t.Fatalf("expected ErrConfirmationRequired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_ConfirmationValidSucceedsAndIsConsumed(t *testing.T) {
|
||||
prov := &fakeProvider{name: "fake"}
|
||||
eng, store := newTestEngine(t, prov)
|
||||
cap := mustCreateCapability(t, store, func(c *domain.Capability) {
|
||||
c.RequiresConfirmation = true
|
||||
})
|
||||
|
||||
args := map[string]any{"foo": "bar"}
|
||||
conf, err := eng.CreateConfirmation(cap.ID, "ent_x", "kami", args)
|
||||
if err != nil {
|
||||
t.Fatalf("create confirmation: %v", err)
|
||||
}
|
||||
|
||||
res, err := eng.Execute(&domain.ExecuteRequest{
|
||||
CapabilityID: cap.ID,
|
||||
TargetEntityID: "ent_x",
|
||||
Arguments: args,
|
||||
ConfirmationID: conf.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if res.Execution.Status != domain.ExecutionSucceeded {
|
||||
t.Fatalf("expected succeeded, got %s", res.Execution.Status)
|
||||
}
|
||||
|
||||
got, err := store.GetConfirmation(conf.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get confirmation: %v", err)
|
||||
}
|
||||
if got.State != domain.ConfirmationConsumed {
|
||||
t.Fatalf("expected confirmation consumed, got %s", got.State)
|
||||
}
|
||||
|
||||
// Reusing the same confirmation must fail.
|
||||
_, err = eng.Execute(&domain.ExecuteRequest{
|
||||
CapabilityID: cap.ID,
|
||||
TargetEntityID: "ent_x",
|
||||
Arguments: args,
|
||||
ConfirmationID: conf.ID,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrConfirmationConsumed) {
|
||||
t.Fatalf("expected ErrConfirmationConsumed on replay, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_ConfirmationExpired(t *testing.T) {
|
||||
prov := &fakeProvider{name: "fake"}
|
||||
eng, store := newTestEngine(t, prov)
|
||||
cap := mustCreateCapability(t, store, func(c *domain.Capability) {
|
||||
c.RequiresConfirmation = true
|
||||
})
|
||||
|
||||
conf, err := eng.CreateConfirmation(cap.ID, "ent_x", "kami", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create confirmation: %v", err)
|
||||
}
|
||||
|
||||
// Directly backdate the row's expiry in storage to simulate TTL elapse
|
||||
// without waiting out the real 120s TTL in a test.
|
||||
if _, err := store.DB().Exec(`UPDATE confirmations SET expires_at = ? WHERE id = ?`,
|
||||
time.Now().UTC().Add(-time.Minute).Format("2006-01-02T15:04:05.999999999Z07:00"), conf.ID); err != nil {
|
||||
t.Fatalf("backdate confirmation: %v", err)
|
||||
}
|
||||
|
||||
_, err = eng.Execute(&domain.ExecuteRequest{
|
||||
CapabilityID: cap.ID,
|
||||
TargetEntityID: "ent_x",
|
||||
ConfirmationID: conf.ID,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrConfirmationExpired) {
|
||||
t.Fatalf("expected ErrConfirmationExpired, got %v", err)
|
||||
}
|
||||
|
||||
got, err := store.GetConfirmation(conf.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get confirmation: %v", err)
|
||||
}
|
||||
if got.State != domain.ConfirmationExpired {
|
||||
t.Fatalf("expected confirmation state expired, got %s", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_ArgsHashMismatchRejected(t *testing.T) {
|
||||
prov := &fakeProvider{name: "fake"}
|
||||
eng, store := newTestEngine(t, prov)
|
||||
cap := mustCreateCapability(t, store, func(c *domain.Capability) {
|
||||
c.RequiresConfirmation = true
|
||||
})
|
||||
|
||||
conf, err := eng.CreateConfirmation(cap.ID, "ent_x", "kami", map[string]any{"n": 1})
|
||||
if err != nil {
|
||||
t.Fatalf("create confirmation: %v", err)
|
||||
}
|
||||
|
||||
_, err = eng.Execute(&domain.ExecuteRequest{
|
||||
CapabilityID: cap.ID,
|
||||
TargetEntityID: "ent_x",
|
||||
Arguments: map[string]any{"n": 2},
|
||||
ConfirmationID: conf.ID,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrConfirmationInvalid) {
|
||||
t.Fatalf("expected ErrConfirmationInvalid on args mismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_UnknownCapabilityVersionRejected(t *testing.T) {
|
||||
prov := &fakeProvider{name: "fake"}
|
||||
eng, store := newTestEngine(t, prov)
|
||||
cap := mustCreateCapability(t, store, func(c *domain.Capability) {
|
||||
c.RequiresConfirmation = true
|
||||
})
|
||||
|
||||
conf, err := eng.CreateConfirmation(cap.ID, "ent_x", "kami", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create confirmation: %v", err)
|
||||
}
|
||||
// Bump the capability version (e.g. via update) so the confirmation now
|
||||
// targets a stale version.
|
||||
cap.Description = "changed"
|
||||
if err := store.UpdateCapability(cap); err != nil {
|
||||
t.Fatalf("update capability: %v", err)
|
||||
}
|
||||
|
||||
_, err = eng.Execute(&domain.ExecuteRequest{
|
||||
CapabilityID: cap.ID,
|
||||
TargetEntityID: "ent_x",
|
||||
ConfirmationID: conf.ID,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrConfirmationInvalid) {
|
||||
t.Fatalf("expected ErrConfirmationInvalid on stale version, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_ConcurrentInFlightRejected(t *testing.T) {
|
||||
prov := &fakeProvider{name: "fake", delay: 300 * time.Millisecond}
|
||||
eng, store := newTestEngine(t, prov)
|
||||
cap := mustCreateCapability(t, store, nil)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_x"})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Give the first execution time to register as "started".
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
_, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_x"})
|
||||
if !errors.Is(err, domain.ErrExecutionInFlight) {
|
||||
t.Fatalf("expected ErrExecutionInFlight, got %v", err)
|
||||
}
|
||||
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestExecute_TimeoutYieldsUnknownNotFailed(t *testing.T) {
|
||||
// Smallest supported capability timeout is 1s; use a provider slower
|
||||
// than that so the engine's context deadline fires first.
|
||||
prov := &fakeProvider{name: "slow", delay: 1500 * time.Millisecond}
|
||||
eng, store := newTestEngine(t, prov)
|
||||
cap := mustCreateCapability(t, store, func(c *domain.Capability) {
|
||||
c.TimeoutSeconds = 1
|
||||
c.Provider = "slow"
|
||||
})
|
||||
|
||||
res, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_x"})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if res.Execution.Status != domain.ExecutionUnknown {
|
||||
t.Fatalf("expected status unknown on timeout, got %s", res.Execution.Status)
|
||||
}
|
||||
|
||||
// Let the background provider goroutine finish so t.Cleanup can close
|
||||
// the store without a dangling write racing it.
|
||||
time.Sleep(1600 * time.Millisecond)
|
||||
}
|
||||
Reference in New Issue
Block a user