package operations import ( "encoding/json" "fmt" "orchestra/internal/authz" "orchestra/internal/domain" "orchestra/internal/registry" "orchestra/internal/store" "orchestra/internal/workphase" ) // AdvanceWorkPhase moves a task to the next phase on its project's declared // path and seals the artifact the phase produced. // // Only Orchestra changes phase. An agent that believes the phase should // change says so through the approval surface, and this is what acts on that // belief. The artifact is validated before the transition is recorded, so a // phase can never be left with an artifact the next phase cannot read. // // Review is the end of the path. Its only move is back to implement, because // a review that passes ends the task through the lifecycle, not the phase. func AdvanceWorkPhase(s *store.Store, project registry.Project, taskID string, artifact []byte) (domain.Event, error) { return advanceWorkPhase(s, project, taskID, artifact, nil) } // advanceWorkPhase carries extra payload fields a specific transition needs, // such as the commit a review phase is entered against. func advanceWorkPhase(s *store.Store, project registry.Project, taskID string, artifact []byte, extra map[string]any) (domain.Event, error) { t, ok := s.Task(taskID) if !ok { return domain.Event{}, domain.ErrNotFound } next, ok := project.NextPhase(t.WorkPhase) if !ok { return domain.Event{}, fmt.Errorf("%w: work phase %q is the end of project %s's path", domain.ErrInvalid, current(t), project.ID) } // The gate sits between the sealed artifact and the next phase, so the // human confirms a direction that is already written down. if project.GateRequired(current(t), next) { switch { case trajectoryGateOpen(s, taskID): cleared, err := clearTrajectoryGate(s, t) if err != nil { return domain.Event{}, err } t = cleared case t.State == domain.StateBlocked && t.BlockReason == domain.BlockReasonTrajectoryGate: // Already waiting. Re-raising would spam the human and reset the // position the open check depends on. return domain.Event{}, fmt.Errorf("%w (task %s, %s to %s)", ErrTrajectoryGate, taskID, current(t), next) default: // The artifact is not sealed yet, so the packet reads the proposal // from the bytes in hand. The caller retries this same advance with // the same artifact once the human has answered. return domain.Event{}, raiseTrajectoryGate(s, t, current(t), next, artifact) } } payload := map[string]any{"phase": string(next), "from": string(current(t))} for k, v := range extra { payload[k] = v } if len(artifact) > 0 { // Validate against the phase being left, which is the phase that // produced this artifact. switch current(t) { case domain.WorkPhaseResearch: if _, err := workphase.DecodeResearch(artifact); err != nil { return domain.Event{}, err } case domain.WorkPhasePlan: if _, err := workphase.DecodePlan(artifact); err != nil { return domain.Event{}, err } } ref, err := s.PutArtifact(artifact) if err != nil { return domain.Event{}, err } payload["artifact_ref"] = ref } b, err := json.Marshal(payload) if err != nil { return domain.Event{}, err } e := domain.Event{ID: domain.NewID(), Type: domain.EventWorkPhaseChanged, TaskID: taskID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)} return e, s.Append(e) } func current(t domain.Task) domain.WorkPhase { if t.WorkPhase == "" { return domain.WorkPhaseFrame } return t.WorkPhase }