Files
kandev/apps/backend/config/workflows/builtin_engine_test.go
T
2026-09-02 16:55:39 +00:00

192 lines
7.0 KiB
Go

package workflows
import (
"testing"
"github.com/kandev/kandev/internal/workflow/engine"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
// TestOfficeDefault_TriggersCompileThroughEngine verifies that every step in
// the embedded office-default template can be compiled into engine.StepSpec
// without panic and that the new event-driven triggers surface as compiled
// engine.Action lists. This pins the template's JSON shape against the
// engine's compile path so future schema drift is caught at build time.
func TestOfficeDefault_TriggersCompileThroughEngine(t *testing.T) {
tmpl := loadTemplateForTest(t, "office-default")
for _, def := range tmpl.Steps {
step := stepFromDefinition(def)
spec := engine.CompileStep(step)
// Every compiled spec must carry the seven new trigger keys, even
// if the slice is empty — Engine.HandleTrigger reads them by key.
for _, trig := range []engine.Trigger{
engine.TriggerOnComment,
engine.TriggerOnBlockerResolved,
engine.TriggerOnChildrenCompleted,
engine.TriggerOnApprovalResolved,
engine.TriggerOnHeartbeat,
engine.TriggerOnBudgetAlert,
engine.TriggerOnAgentError,
} {
if _, ok := spec.Events[trig]; !ok {
t.Errorf("step %q: compiled spec missing trigger %q", def.Name, trig)
}
}
}
work := findStep(tmpl.Steps, "Work")
if work == nil {
t.Fatal("Work step not present in template")
}
workSpec := engine.CompileStep(stepFromDefinition(*work))
commentActions := workSpec.Events[engine.TriggerOnComment]
if len(commentActions) != 1 {
t.Fatalf("Work.on_comment compiled to %d actions, want 1", len(commentActions))
}
if commentActions[0].Kind != engine.ActionQueueRun {
t.Errorf("Work.on_comment[0].Kind = %q, want queue_run", commentActions[0].Kind)
}
if commentActions[0].QueueRun == nil {
t.Fatal("Work.on_comment[0].QueueRun is nil")
}
if commentActions[0].QueueRun.Target != engine.TargetPrimary {
t.Errorf("Work.on_comment[0] target = %q, want primary", commentActions[0].QueueRun.Target)
}
done := findStep(tmpl.Steps, "Done")
if done == nil {
t.Fatal("Done step not present in template")
}
doneSpec := engine.CompileStep(stepFromDefinition(*done))
doneCommentActions := doneSpec.Events[engine.TriggerOnComment]
if len(doneCommentActions) != 1 {
t.Fatalf("Done.on_comment compiled to %d actions, want 1", len(doneCommentActions))
}
if doneCommentActions[0].Kind != engine.ActionQueueRun {
t.Errorf("Done.on_comment[0].Kind = %q, want queue_run", doneCommentActions[0].Kind)
}
if doneCommentActions[0].QueueRun == nil {
t.Fatal("Done.on_comment[0].QueueRun is nil")
}
if doneCommentActions[0].QueueRun.Target != engine.TargetPrimary {
t.Errorf("Done.on_comment[0] target = %q, want primary", doneCommentActions[0].QueueRun.Target)
}
review := findStep(tmpl.Steps, "Review")
if review == nil {
t.Fatal("Review step not present in template")
}
reviewSpec := engine.CompileStep(stepFromDefinition(*review))
enterActions := reviewSpec.Events[engine.TriggerOnEnter]
if len(enterActions) != 3 {
t.Fatalf("Review.on_enter compiled to %d actions, want 3", len(enterActions))
}
if enterActions[0].Kind != engine.ActionClearDecisions {
t.Errorf("Review.on_enter[0].Kind = %q, want clear_decisions", enterActions[0].Kind)
}
if enterActions[1].Kind != engine.ActionEnsureParticipantSeat {
t.Errorf("Review.on_enter[1].Kind = %q, want ensure_participant_seat", enterActions[1].Kind)
}
if enterActions[1].EnsureParticipantSeat == nil {
t.Fatal("Review.on_enter[1].EnsureParticipantSeat is nil")
}
if enterActions[1].EnsureParticipantSeat.Role != "reviewer" {
t.Errorf("Review.on_enter[1] role = %q, want reviewer", enterActions[1].EnsureParticipantSeat.Role)
}
if enterActions[2].Kind != engine.ActionQueueRunForEachParticipant {
t.Errorf("Review.on_enter[2].Kind = %q, want queue_run_for_each_participant", enterActions[2].Kind)
}
if enterActions[2].QueueRunForEachParticipant == nil {
t.Fatal("Review.on_enter[2].QueueRunForEachParticipant is nil")
}
if enterActions[2].QueueRunForEachParticipant.Role != "reviewer" {
t.Errorf("Review.on_enter[2] role = %q, want reviewer", enterActions[2].QueueRunForEachParticipant.Role)
}
completeActions := reviewSpec.Events[engine.TriggerOnTurnComplete]
if len(completeActions) != 2 {
t.Fatalf("Review.on_turn_complete compiled to %d actions, want 2", len(completeActions))
}
for i, action := range completeActions {
if action.Guard == nil || action.Guard.WaitForQuorum == nil {
t.Errorf("Review.on_turn_complete[%d] missing wait_for_quorum guard", i)
}
}
}
// TestOfficeDefault_OnAgentErrorEscalatesFromEveryAgentLaunchingStep verifies
// that every agent-launching step in office-default (Work, Review, Approval,
// Done) compiles on_agent_error to a single queue_run action targeting the
// CEO agent. Before this fix only Work declared the block, so a reviewer or
// approver session failure left Engine.HandleTrigger with zero actions
// (ActionCount == 0) and no escalation ever reached the coordinator.
func TestOfficeDefault_OnAgentErrorEscalatesFromEveryAgentLaunchingStep(t *testing.T) {
tmpl := loadTemplateForTest(t, "office-default")
for _, stepName := range []string{"Work", "Review", "Approval", "Done"} {
t.Run(stepName, func(t *testing.T) {
def := findStep(tmpl.Steps, stepName)
if def == nil {
t.Fatalf("step %q not present in template", stepName)
}
spec := engine.CompileStep(stepFromDefinition(*def))
actions := spec.Events[engine.TriggerOnAgentError]
if len(actions) != 1 {
t.Fatalf("%s.on_agent_error compiled to %d actions, want 1", stepName, len(actions))
}
action := actions[0]
if action.Kind != engine.ActionQueueRun {
t.Errorf("%s.on_agent_error[0].Kind = %q, want queue_run", stepName, action.Kind)
}
if action.QueueRun == nil {
t.Fatalf("%s.on_agent_error[0].QueueRun is nil", stepName)
}
if action.QueueRun.Target != engine.TargetWorkspaceCEO {
t.Errorf("%s.on_agent_error[0] target = %q, want %q", stepName, action.QueueRun.Target, engine.TargetWorkspaceCEO)
}
if action.QueueRun.Reason != "agent_error" {
t.Errorf("%s.on_agent_error[0] reason = %q, want %q", stepName, action.QueueRun.Reason, "agent_error")
}
})
}
}
func loadTemplateForTest(t *testing.T, id string) *wfmodels.WorkflowTemplate {
t.Helper()
templates, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates: %v", err)
}
for _, tmpl := range templates {
if tmpl.ID == id {
return tmpl
}
}
t.Fatalf("template %q not found", id)
return nil
}
func findStep(steps []wfmodels.StepDefinition, name string) *wfmodels.StepDefinition {
for i := range steps {
if steps[i].Name == name {
return &steps[i]
}
}
return nil
}
// stepFromDefinition copies the StepDefinition fields used by engine.CompileStep
// onto a WorkflowStep. The compiler only reads Name, Position, Prompt, Events.
func stepFromDefinition(def wfmodels.StepDefinition) *wfmodels.WorkflowStep {
return &wfmodels.WorkflowStep{
ID: def.ID,
Name: def.Name,
Position: def.Position,
Prompt: def.Prompt,
Events: def.Events,
StageType: def.StageType,
}
}