439 lines
16 KiB
Go
439 lines
16 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// fakeDecisionStore lets tests pre-populate decisions and watch writes.
|
|
type fakeDecisionStore struct {
|
|
byKey map[string][]DecisionInfo
|
|
cleared map[string]int64
|
|
recordErr error
|
|
clearedErr error
|
|
listErr error
|
|
}
|
|
|
|
func newFakeDecisionStore() *fakeDecisionStore {
|
|
return &fakeDecisionStore{byKey: map[string][]DecisionInfo{}, cleared: map[string]int64{}}
|
|
}
|
|
|
|
func dkey(taskID, stepID string) string { return taskID + "|" + stepID }
|
|
|
|
func (f *fakeDecisionStore) ListStepDecisions(_ context.Context, taskID, stepID string) ([]DecisionInfo, error) {
|
|
if f.listErr != nil {
|
|
return nil, f.listErr
|
|
}
|
|
return f.byKey[dkey(taskID, stepID)], nil
|
|
}
|
|
|
|
func (f *fakeDecisionStore) RecordStepDecision(_ context.Context, d DecisionInfo) error {
|
|
if f.recordErr != nil {
|
|
return f.recordErr
|
|
}
|
|
k := dkey(d.TaskID, d.StepID)
|
|
f.byKey[k] = append(f.byKey[k], d)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeDecisionStore) ClearStepDecisions(_ context.Context, taskID, stepID string) (int64, error) {
|
|
if f.clearedErr != nil {
|
|
return 0, f.clearedErr
|
|
}
|
|
k := dkey(taskID, stepID)
|
|
n := int64(len(f.byKey[k]))
|
|
delete(f.byKey, k)
|
|
f.cleared[k] += n
|
|
return n, nil
|
|
}
|
|
|
|
// stepStoreForQuorum is a TransitionStore where the step holds a single
|
|
// transition action gated by a quorum guard.
|
|
type stepStoreForQuorum struct {
|
|
state MachineState
|
|
step StepSpec
|
|
next StepSpec
|
|
applied map[string]bool
|
|
}
|
|
|
|
func (s *stepStoreForQuorum) LoadState(_ context.Context, _, _ string) (MachineState, error) {
|
|
return s.state, nil
|
|
}
|
|
func (s *stepStoreForQuorum) LoadStep(_ context.Context, _, _ string) (StepSpec, error) {
|
|
return s.step, nil
|
|
}
|
|
func (s *stepStoreForQuorum) LoadNextStep(_ context.Context, _ string, _ int) (StepSpec, error) {
|
|
return s.next, nil
|
|
}
|
|
func (s *stepStoreForQuorum) LoadPreviousStep(_ context.Context, _ string, _ int) (StepSpec, error) {
|
|
return StepSpec{}, nil
|
|
}
|
|
func (s *stepStoreForQuorum) ApplyTransition(_ context.Context, _, _, _, _ string, _ Trigger) error {
|
|
return nil
|
|
}
|
|
func (s *stepStoreForQuorum) PersistData(_ context.Context, _ string, _ map[string]any) error {
|
|
return nil
|
|
}
|
|
func (s *stepStoreForQuorum) IsOperationApplied(_ context.Context, op string) (bool, error) {
|
|
return s.applied[op], nil
|
|
}
|
|
func (s *stepStoreForQuorum) MarkOperationApplied(_ context.Context, op string) error {
|
|
s.applied[op] = true
|
|
return nil
|
|
}
|
|
|
|
func quorumStore(guard *TransitionGuard) *stepStoreForQuorum {
|
|
return &stepStoreForQuorum{
|
|
state: MachineState{TaskID: "task-1", SessionID: "sess-1", WorkflowID: "wf", CurrentStepID: "review"},
|
|
step: StepSpec{
|
|
ID: "review", WorkflowID: "wf", Position: 1,
|
|
Events: map[Trigger][]Action{
|
|
TriggerOnTurnComplete: {
|
|
{Kind: ActionMoveToNext, Guard: guard},
|
|
},
|
|
},
|
|
},
|
|
next: StepSpec{ID: "approval", Position: 2},
|
|
applied: map[string]bool{},
|
|
}
|
|
}
|
|
|
|
func TestApplyThreshold_AllApprove(t *testing.T) {
|
|
required := []ParticipantInfo{
|
|
{ID: "p1"}, {ID: "p2"},
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
decisions []DecisionInfo
|
|
want bool
|
|
}{
|
|
{"all approved", []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionApproved}}, true},
|
|
{"partial approved", []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}}, false},
|
|
{"none yet", nil, false},
|
|
{"approve+reject", []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionRejected}}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := applyThreshold(QuorumAllApprove, required, tc.decisions); got != tc.want {
|
|
t.Fatalf("applyThreshold all_approve = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyThreshold_AllDecide(t *testing.T) {
|
|
required := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}}
|
|
d1 := []DecisionInfo{{ParticipantID: "p1", Decision: "approved"}, {ParticipantID: "p2", Decision: "rejected"}}
|
|
if !applyThreshold(QuorumAllDecide, required, d1) {
|
|
t.Fatalf("all_decide should be true when both decided")
|
|
}
|
|
d2 := []DecisionInfo{{ParticipantID: "p1", Decision: "approved"}}
|
|
if applyThreshold(QuorumAllDecide, required, d2) {
|
|
t.Fatalf("all_decide should be false when only one decided")
|
|
}
|
|
}
|
|
|
|
func TestApplyThreshold_AnyReject(t *testing.T) {
|
|
required := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}}
|
|
d := []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionRejected}}
|
|
if !applyThreshold(QuorumAnyReject, required, d) {
|
|
t.Fatalf("any_reject should be true when one rejected")
|
|
}
|
|
if applyThreshold(QuorumAnyReject, required, []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}}) {
|
|
t.Fatalf("any_reject should be false when no rejection")
|
|
}
|
|
}
|
|
|
|
func TestApplyThreshold_MajorityApprove(t *testing.T) {
|
|
required := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}}
|
|
// 2/3 approve => majority
|
|
d := []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionApproved}}
|
|
if !applyThreshold(QuorumMajorityApprove, required, d) {
|
|
t.Fatalf("majority_approve true expected for 2/3 approves")
|
|
}
|
|
// 1/3 approve, 1 reject => not majority
|
|
d2 := []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionRejected}}
|
|
if applyThreshold(QuorumMajorityApprove, required, d2) {
|
|
t.Fatalf("majority_approve false expected when not strictly more than half")
|
|
}
|
|
}
|
|
|
|
func TestApplyThreshold_NApprove(t *testing.T) {
|
|
required := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}}
|
|
d := []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionApproved}}
|
|
if !applyThreshold("n_approve:2", required, d) {
|
|
t.Fatalf("n_approve:2 expected true")
|
|
}
|
|
if applyThreshold("n_approve:3", required, d) {
|
|
t.Fatalf("n_approve:3 expected false")
|
|
}
|
|
if applyThreshold("n_approve:notanint", required, d) {
|
|
t.Fatalf("malformed n_approve threshold should fail closed")
|
|
}
|
|
if applyThreshold("n_approve:0", required, d) {
|
|
t.Fatalf("n_approve:0 should fail closed")
|
|
}
|
|
}
|
|
|
|
func TestApplyThreshold_RemovedParticipantDropped(t *testing.T) {
|
|
// p1 was required and approved, then removed. p2 still required and approved.
|
|
required := []ParticipantInfo{{ID: "p2"}}
|
|
decisions := []DecisionInfo{
|
|
{ParticipantID: "p1", Decision: DecisionApproved},
|
|
{ParticipantID: "p2", Decision: DecisionApproved},
|
|
}
|
|
if !applyThreshold(QuorumAllApprove, required, decisions) {
|
|
t.Fatalf("all_approve should be true when removed participant's decision is ignored")
|
|
}
|
|
}
|
|
|
|
func TestApplyThreshold_LatestDecisionPerParticipant(t *testing.T) {
|
|
required := []ParticipantInfo{{ID: "p1"}}
|
|
// p1 approved then rejected
|
|
decisions := []DecisionInfo{
|
|
{ParticipantID: "p1", Decision: DecisionApproved},
|
|
{ParticipantID: "p1", Decision: DecisionRejected},
|
|
}
|
|
if applyThreshold(QuorumAllApprove, required, decisions) {
|
|
t.Fatalf("expected latest reject to override earlier approve")
|
|
}
|
|
if !applyThreshold(QuorumAnyReject, required, decisions) {
|
|
t.Fatalf("any_reject should be true given latest rejection")
|
|
}
|
|
}
|
|
|
|
func TestApplyThreshold_NoRequiredParticipants_FailsClosedHandledByCaller(t *testing.T) {
|
|
// applyThreshold itself, with empty required and a non-N threshold,
|
|
// returns true for all_approve (vacuously true). Engine wraps this and
|
|
// fails closed when len(required) == 0 — pinned in TestWaitForQuorum_*.
|
|
if !applyThreshold(QuorumAllApprove, nil, nil) {
|
|
t.Fatalf("documented behavior: applyThreshold returns true for vacuous all_approve")
|
|
}
|
|
}
|
|
|
|
func TestEngine_WaitForQuorum_BlocksUntilSatisfied(t *testing.T) {
|
|
store := quorumStore(&TransitionGuard{
|
|
WaitForQuorum: &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove},
|
|
})
|
|
decisions := newFakeDecisionStore()
|
|
parts := fakeParticipants{list: []ParticipantInfo{
|
|
{ID: "p1", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-A"},
|
|
{ID: "p2", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-B"},
|
|
}}
|
|
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
|
|
|
|
res, err := eng.HandleTrigger(context.Background(), HandleInput{
|
|
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.Transitioned {
|
|
t.Fatalf("expected guard to block transition with no decisions")
|
|
}
|
|
|
|
// Record one approval - still not enough for all_approve.
|
|
if err := decisions.RecordStepDecision(context.Background(), DecisionInfo{
|
|
TaskID: "task-1", StepID: "review", ParticipantID: "p1", Decision: DecisionApproved,
|
|
}); err != nil {
|
|
t.Fatalf("record: %v", err)
|
|
}
|
|
res, err = eng.HandleTrigger(context.Background(), HandleInput{
|
|
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.Transitioned {
|
|
t.Fatalf("expected guard to block transition with partial decisions")
|
|
}
|
|
|
|
// Record second approval - quorum reached.
|
|
if err := decisions.RecordStepDecision(context.Background(), DecisionInfo{
|
|
TaskID: "task-1", StepID: "review", ParticipantID: "p2", Decision: DecisionApproved,
|
|
}); err != nil {
|
|
t.Fatalf("record: %v", err)
|
|
}
|
|
res, err = eng.HandleTrigger(context.Background(), HandleInput{
|
|
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !res.Transitioned {
|
|
t.Fatalf("expected transition once quorum satisfied")
|
|
}
|
|
if res.ToStepID != "approval" {
|
|
t.Fatalf("expected target = approval, got %q", res.ToStepID)
|
|
}
|
|
}
|
|
|
|
func TestEngine_WaitForQuorum_NoRequiredParticipants_FailsClosed(t *testing.T) {
|
|
store := quorumStore(&TransitionGuard{
|
|
WaitForQuorum: &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove},
|
|
})
|
|
decisions := newFakeDecisionStore()
|
|
// All participants present but DecisionRequired=false.
|
|
parts := fakeParticipants{list: []ParticipantInfo{
|
|
{ID: "p1", Role: "reviewer", DecisionRequired: false, AgentProfileID: "rev-A"},
|
|
}}
|
|
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
|
|
res, err := eng.HandleTrigger(context.Background(), HandleInput{
|
|
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.Transitioned {
|
|
t.Fatalf("expected guard to fail closed when no required participants exist")
|
|
}
|
|
}
|
|
|
|
func TestEngine_WaitForQuorum_NoStoresWired_FailsClosed(t *testing.T) {
|
|
store := quorumStore(&TransitionGuard{
|
|
WaitForQuorum: &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove},
|
|
})
|
|
eng := New(store, MapRegistry{}) // no DecisionStore / ParticipantStore
|
|
res, err := eng.HandleTrigger(context.Background(), HandleInput{
|
|
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.Transitioned {
|
|
t.Fatalf("expected guard to fail closed when stores not wired")
|
|
}
|
|
}
|
|
|
|
func TestClearDecisionsCallback_DeletesRows(t *testing.T) {
|
|
decisions := newFakeDecisionStore()
|
|
_ = decisions.RecordStepDecision(context.Background(), DecisionInfo{TaskID: "t", StepID: "s", ParticipantID: "p", Decision: "approved"})
|
|
cb := ClearDecisionsCallback{Decisions: decisions}
|
|
_, err := cb.Execute(context.Background(), ActionInput{
|
|
State: MachineState{TaskID: "t"},
|
|
Step: StepSpec{ID: "s"},
|
|
Action: Action{Kind: ActionClearDecisions, ClearDecisions: &ClearDecisionsAction{}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("clear: %v", err)
|
|
}
|
|
if got, _ := decisions.ListStepDecisions(context.Background(), "t", "s"); len(got) != 0 {
|
|
t.Fatalf("expected decisions cleared, got %d", len(got))
|
|
}
|
|
if decisions.cleared[dkey("t", "s")] != 1 {
|
|
t.Fatalf("expected cleared count for (t,s) to be 1, got %d", decisions.cleared[dkey("t", "s")])
|
|
}
|
|
}
|
|
|
|
func TestClearDecisionsCallback_NoStore_Errors(t *testing.T) {
|
|
cb := ClearDecisionsCallback{}
|
|
_, err := cb.Execute(context.Background(), ActionInput{
|
|
Action: Action{Kind: ActionClearDecisions, ClearDecisions: &ClearDecisionsAction{}},
|
|
})
|
|
if err == nil || !errors.Is(err, ErrActionNotYetWired) {
|
|
t.Fatalf("expected ErrActionNotYetWired, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEngine_RecordParticipantDecision_PersistsAndReevaluates(t *testing.T) {
|
|
store := quorumStore(&TransitionGuard{
|
|
WaitForQuorum: &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove},
|
|
})
|
|
decisions := newFakeDecisionStore()
|
|
parts := fakeParticipants{list: []ParticipantInfo{
|
|
{ID: "p1", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-A"},
|
|
}}
|
|
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
|
|
|
|
if err := eng.RecordParticipantDecision(context.Background(), "task-1", "sess-1", "review", "p1", DecisionApproved, "lgtm"); err != nil {
|
|
t.Fatalf("RecordParticipantDecision: %v", err)
|
|
}
|
|
got, _ := decisions.ListStepDecisions(context.Background(), "task-1", "review")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 recorded decision, got %d", len(got))
|
|
}
|
|
if got[0].Decision != DecisionApproved || got[0].Note != "lgtm" {
|
|
t.Fatalf("unexpected decision shape: %#v", got[0])
|
|
}
|
|
}
|
|
|
|
func TestEngine_RecordParticipantDecision_RequiresStore(t *testing.T) {
|
|
store := quorumStore(nil)
|
|
eng := New(store, MapRegistry{}) // no DecisionStore wired
|
|
err := eng.RecordParticipantDecision(context.Background(), "task-1", "sess-1", "review", "p1", DecisionApproved, "")
|
|
if err == nil {
|
|
t.Fatalf("expected error when DecisionStore missing")
|
|
}
|
|
}
|
|
|
|
func TestEngine_RecordParticipantDecision_RequiresIDs(t *testing.T) {
|
|
decisions := newFakeDecisionStore()
|
|
eng := New(quorumStore(nil), MapRegistry{}, WithDecisionStore(decisions))
|
|
cases := []struct {
|
|
name string
|
|
task, step, participant string
|
|
decision string
|
|
expectErr bool
|
|
}{
|
|
{"missing task", "", "step", "p", "approved", true},
|
|
{"missing step", "task", "", "p", "approved", true},
|
|
{"missing participant", "task", "step", "", "approved", true},
|
|
{"missing decision", "task", "step", "p", "", true},
|
|
{"valid (no session => skip reeval)", "task", "step", "p", "approved", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Signature: (ctx, taskID, sessionID, stepID, participantID, decision, note)
|
|
err := eng.RecordParticipantDecision(context.Background(), tc.task, "", tc.step, tc.participant, tc.decision, "")
|
|
if (err != nil) != tc.expectErr {
|
|
t.Fatalf("err=%v, expectErr=%v", err, tc.expectErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigTransitionGuard_ParsesWaitForQuorum(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"if": map[string]any{
|
|
"wait_for_quorum": map[string]any{
|
|
"role": "reviewer",
|
|
"threshold": "all_approve",
|
|
},
|
|
},
|
|
}
|
|
g := ConfigTransitionGuard(cfg)
|
|
if g == nil || g.WaitForQuorum == nil {
|
|
t.Fatalf("expected wait_for_quorum guard")
|
|
}
|
|
if g.WaitForQuorum.Role != "reviewer" || g.WaitForQuorum.Threshold != "all_approve" {
|
|
t.Fatalf("unexpected guard: %+v", g.WaitForQuorum)
|
|
}
|
|
}
|
|
|
|
func TestConfigTransitionGuard_ParsesLegacyTopLevelWaitForQuorum(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"wait_for_quorum": map[string]any{
|
|
"role": "reviewer",
|
|
"threshold": "all_approve",
|
|
},
|
|
}
|
|
g := ConfigTransitionGuard(cfg)
|
|
if g == nil || g.WaitForQuorum == nil {
|
|
t.Fatalf("expected guard, got %#v", g)
|
|
}
|
|
if g.WaitForQuorum.Role != "reviewer" || g.WaitForQuorum.Threshold != "all_approve" {
|
|
t.Fatalf("unexpected guard: %#v", g.WaitForQuorum)
|
|
}
|
|
if got := ConfigTransitionGuard(nil); got != nil {
|
|
t.Fatalf("expected nil for nil config")
|
|
}
|
|
if got := ConfigTransitionGuard(map[string]any{}); got != nil {
|
|
t.Fatalf("expected nil when key absent")
|
|
}
|
|
// Missing fields => no guard.
|
|
if got := ConfigTransitionGuard(map[string]any{"wait_for_quorum": map[string]any{"role": ""}}); got != nil {
|
|
t.Fatalf("expected nil for malformed guard")
|
|
}
|
|
}
|