package operations import ( "encoding/json" "errors" "strings" "testing" "orchestra/internal/authz" "orchestra/internal/domain" "orchestra/internal/registry" "orchestra/internal/store" "orchestra/internal/workphase" ) func gatedProject() registry.Project { return registry.Project{ID: "p", TrajectoryGate: map[string]string{"plan_to_implement": "required"}} } func humanReply(t *testing.T, s *store.Store, taskID, id, value string) { t.Helper() task, _ := s.Task(taskID) if err := s.Append(domain.Event{ ID: domain.NewID(), Type: domain.EventHumanDecisionRecorded, TaskID: taskID, Version: task.Version + 1, Surface: string(authz.System), Payload: mustJSONBytes(t, map[string]any{ "decision_id": id, "kind": "correction", "subject": "operator_instruction", "value": value, "source": map[string]any{"provider": "gitea", "external_id": "c-" + id}, }), }); err != nil { t.Fatal(err) } } func mustJSONBytes(t *testing.T, v any) []byte { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatal(err) } return b } // The gate stops plan to implement, hands the human a packet built from state // that already exists, and lets the work through once they answer. func TestTrajectoryGateBlocksThenClears(t *testing.T) { s, id := phaseStore(t) project := gatedProject() if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil { t.Fatal(err) } if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil { t.Fatal(err) } // plan to implement is gated. proposal := sealed(t, workphase.Plan{ Changes: []workphase.Change{{Target: "internal/attr/attr.go", Intent: "add the cache"}}, Verification: []string{"go test ./internal/attr/"}, Risks: []string{"cache invalidation on rename"}, }) _, err := AdvanceWorkPhase(s, project, id, proposal) if !errors.Is(err, ErrTrajectoryGate) { t.Fatalf("want ErrTrajectoryGate, got %v", err) } blocked, _ := s.Task(id) if blocked.State != domain.StateBlocked || blocked.BlockReason != domain.BlockReasonTrajectoryGate { t.Fatalf("task = %+v", blocked) } if blocked.WorkPhase != domain.WorkPhasePlan { t.Fatalf("phase moved before the human answered: %q", blocked.WorkPhase) } // The packet carries the proposal that is not sealed yet, plus the // research it came from. for _, want := range []string{ "Trajectory gate: plan to implement", "add the cache", "go test ./internal/attr/", "cache invalidation on rename", "runs per figure", } { if !strings.Contains(blocked.Blocker, want) { t.Fatalf("packet missing %q:\n%s", want, blocked.Blocker) } } // Asking again while waiting must not re-raise the gate. before := len(s.Events(0)) if _, err := AdvanceWorkPhase(s, project, id, proposal); !errors.Is(err, ErrTrajectoryGate) { t.Fatalf("want ErrTrajectoryGate, got %v", err) } if len(s.Events(0)) != before { t.Fatal("a second gate event was appended while waiting") } // The human answers. Any wording counts: an imported comment carries no // gate-specific subject. humanReply(t, s, id, "d1", "keep the per-person aggregation, but do not add the cache, add the index") if _, err := AdvanceWorkPhase(s, project, id, proposal); err != nil { t.Fatal(err) } got, _ := s.Task(id) if got.State != domain.StateLeased && got.State != domain.StateQueued { t.Fatalf("state = %s, want queued after the gate cleared", got.State) } if got.WorkPhase != domain.WorkPhaseImplement { t.Fatalf("phase = %q", got.WorkPhase) } if got.PlanRef == "" { t.Fatal("the plan was not sealed once the gate cleared") } } // An ungated project never stops. func TestUngatedProjectAdvances(t *testing.T) { s, id := phaseStore(t) project := registry.Project{ID: "p"} if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil { t.Fatal(err) } if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil { t.Fatal(err) } if _, err := AdvanceWorkPhase(s, project, id, sealed(t, plan)); err != nil { t.Fatal(err) } if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseImplement { t.Fatalf("phase = %q", got.WorkPhase) } } // A decision recorded before the gate was raised is not an answer to it. func TestOlderDecisionDoesNotOpenTheGate(t *testing.T) { s, id := phaseStore(t) project := gatedProject() humanReply(t, s, id, "d0", "an earlier instruction") if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil { t.Fatal(err) } if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil { t.Fatal(err) } if _, err := AdvanceWorkPhase(s, project, id, sealed(t, plan)); !errors.Is(err, ErrTrajectoryGate) { t.Fatalf("want ErrTrajectoryGate, got %v", err) } }