package controllers import ( "encoding/json" "errors" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" ) // HTTP response envelopes for the projects surface — the SINGLE definition of // each wire shape. The handlers encode these (envelope.WriteJSON), and // apispec.Build reflects these same types into openapi.yaml, so the served // contract and the generated spec can't disagree. The request side needs no // wrappers: handlers decode the body straight into the project commands // (projectsvc.AddInput), which apispec also reflects. // ProjectIDParam is the {id} path parameter shared by the /projects/{id} // routes. Handlers read it via chi.URLParam (see projectID); it is declared here // so every wire input/output shape has one home, and apispec.Build reflects it // as the path parameter. type ProjectIDParam struct { ID string `path:"id" description:"Project identifier (registry key)."` } // ListProjectsResponse is the body of GET /api/v1/projects. type ListProjectsResponse struct { Projects []projectsvc.Summary `json:"projects"` } // ProjectResponse is the { project } body shared by POST /projects (201). type ProjectResponse struct { Project projectsvc.Project `json:"project"` } // GetProjectResponse is the { status, project } body of GET /projects/{id}, // where project is oneOf Project|Degraded discriminated by status. type GetProjectResponse struct { Status string `json:"status" enum:"ok,degraded"` Project ProjectOrDegraded `json:"project"` } // ProjectOrDegraded is the discriminated `project` field: exactly one of // Project/Degraded is set. It marshals as whichever is present (so the handler // emits the right object) and exposes the oneOf variants to the spec reflector // (so apispec.Build emits `oneOf: [Project, Degraded]`) — one type, both jobs. type ProjectOrDegraded struct { Project *projectsvc.Project Degraded *projectsvc.Degraded } // MarshalJSON encodes whichever variant is set (Project or Degraded). func (p ProjectOrDegraded) MarshalJSON() ([]byte, error) { switch { case p.Degraded != nil: return json.Marshal(p.Degraded) case p.Project != nil: return json.Marshal(p.Project) default: // Unreachable in practice: the handler validates the GetResult via // newGetProjectResponse and writes a 500 before committing the 200 // status, so this never encodes. Kept as a last-resort backstop — // erroring is still better than emitting a contract-breaking `null`, // though by here the status is already sent, so the real guard is // upstream. return nil, errEmptyProjectOrDegraded } } // errEmptyProjectOrDegraded marks a GetResult that set neither variant — a // Manager-contract violation. newGetProjectResponse returns it so the handler // can map it to a 500 before any response bytes are written. var errEmptyProjectOrDegraded = errors.New("controllers: GetResult has neither Project nor Degraded set") // JSONSchemaOneOf is read by swaggest's reflector (apispec.Build) to emit the // oneOf for this field; it is not used at runtime. func (ProjectOrDegraded) JSONSchemaOneOf() []interface{} { return []interface{}{projectsvc.Project{}, projectsvc.Degraded{}} } // newGetProjectResponse maps the internal GetResult onto the wire envelope — // the explicit project→httpd boundary the result type exists for. It errors // when the result sets neither variant, so the handler can return a clean 500 // BEFORE writing the 200 status rather than flushing a truncated body. func newGetProjectResponse(res projectsvc.GetResult) (GetProjectResponse, error) { if res.Project == nil && res.Degraded == nil { return GetProjectResponse{}, errEmptyProjectOrDegraded } return GetProjectResponse{ Status: res.Status, Project: ProjectOrDegraded{Project: res.Project, Degraded: res.Degraded}, }, nil } // SessionIDParam is the {sessionId} path parameter shared by session routes. type SessionIDParam struct { SessionID string `path:"sessionId" description:"Session identifier, e.g. project-1."` } // ListSessionsQuery is the query string accepted by GET /api/v1/sessions. type ListSessionsQuery struct { Project string `query:"project,omitempty" description:"Project id filter."` Active *bool `query:"active,omitempty" description:"When true, return non-terminated sessions; when false, return terminated sessions."` OrchestratorOnly *bool `query:"orchestratorOnly,omitempty" description:"When true, return only orchestrator sessions."` Fresh *bool `query:"fresh,omitempty" description:"When true, return only fresh non-terminated sessions."` } // CleanupSessionsQuery is the query string accepted by POST /api/v1/sessions/cleanup. type CleanupSessionsQuery struct { Project string `query:"project,omitempty" description:"Project id filter. When omitted, clean terminated sessions across all projects."` } // ListSessionsResponse is the body of GET /api/v1/sessions. type ListSessionsResponse struct { Sessions []domain.Session `json:"sessions"` } // SpawnSessionRequest is the body of POST /api/v1/sessions. type SpawnSessionRequest struct { ProjectID domain.ProjectID `json:"projectId"` IssueID domain.IssueID `json:"issueId,omitempty"` Kind domain.SessionKind `json:"kind,omitempty" enum:"worker,orchestrator"` Harness domain.AgentHarness `json:"harness,omitempty" enum:"claude-code,codex,aider,opencode,grok,droid,amp,agy,crush,cursor,qwen,copilot,goose,auggie,continue,devin,cline,kimi,kiro,kilocode,vibe,pi,autohand"` Branch string `json:"branch,omitempty"` Prompt string `json:"prompt,omitempty" maxLength:"4096"` AgentRules string `json:"agentRules,omitempty"` } // SessionResponse is the { session } body shared by session create/get. type SessionResponse struct { Session domain.Session `json:"session"` } // RenameSessionRequest is the body of PATCH /api/v1/sessions/{sessionId}. type RenameSessionRequest struct { DisplayName string `json:"displayName" minLength:"1"` } // RenameSessionResponse is the body of PATCH /api/v1/sessions/{sessionId}. type RenameSessionResponse struct { OK bool `json:"ok"` SessionID domain.SessionID `json:"sessionId"` DisplayName string `json:"displayName"` } // RestoreSessionResponse is the body of POST /api/v1/sessions/{sessionId}/restore. type RestoreSessionResponse struct { OK bool `json:"ok"` SessionID domain.SessionID `json:"sessionId"` Session domain.Session `json:"session"` } // KillSessionResponse is the body of POST /api/v1/sessions/{sessionId}/kill. type KillSessionResponse struct { OK bool `json:"ok"` SessionID domain.SessionID `json:"sessionId"` Freed bool `json:"freed,omitempty"` } // CleanupSessionsResponse is the body of POST /api/v1/sessions/cleanup. type CleanupSessionsResponse struct { OK bool `json:"ok"` Cleaned []domain.SessionID `json:"cleaned"` } // SendSessionMessageRequest is the body of POST /api/v1/sessions/{sessionId}/send. type SendSessionMessageRequest struct { Message string `json:"message" minLength:"1" maxLength:"4096"` } // SendSessionMessageResponse is the body of POST /api/v1/sessions/{sessionId}/send. type SendSessionMessageResponse struct { OK bool `json:"ok"` SessionID domain.SessionID `json:"sessionId"` Message string `json:"message"` } // SessionPRFacts is the pull-request read shape returned under session PR routes. type SessionPRFacts struct { URL string `json:"url"` Number int `json:"number"` State string `json:"state" enum:"draft,open,merged,closed"` CI domain.CIState `json:"ci"` Review domain.ReviewDecision `json:"review"` Mergeability domain.Mergeability `json:"mergeability"` ReviewComments bool `json:"reviewComments"` UpdatedAt time.Time `json:"updatedAt"` } // ListSessionPRsResponse is the body of GET /sessions/{sessionId}/pr. type ListSessionPRsResponse struct { SessionID domain.SessionID `json:"sessionId"` PRs []SessionPRFacts `json:"prs"` } // ClaimPRRequest is the body of POST /sessions/{sessionId}/pr/claim. type ClaimPRRequest struct { PR string `json:"pr" minLength:"1"` AllowTakeover *bool `json:"allowTakeover,omitempty"` } // ClaimPRResponse is the body of POST /sessions/{sessionId}/pr/claim. type ClaimPRResponse struct { OK bool `json:"ok"` SessionID domain.SessionID `json:"sessionId"` PRs []SessionPRFacts `json:"prs"` BranchChanged bool `json:"branchChanged"` TakenOverFrom []domain.SessionID `json:"takenOverFrom"` } // SetActivityRequest is the body of POST /api/v1/sessions/{sessionId}/activity. type SetActivityRequest struct { State string `json:"state" enum:"active,idle,waiting_input,exited" description:"Agent activity state reported by an agent hook."` } // SetActivityResponse is the body of POST /api/v1/sessions/{sessionId}/activity. type SetActivityResponse struct { OK bool `json:"ok"` SessionID domain.SessionID `json:"sessionId"` State string `json:"state"` } // OrchestratorIDParam is the {id} path parameter for orchestrator routes. type OrchestratorIDParam struct { ID string `path:"id" description:"Orchestrator session identifier, e.g. project-orchestrator."` } // SpawnOrchestratorRequest is the body of POST /api/v1/orchestrators. type SpawnOrchestratorRequest struct { ProjectID domain.ProjectID `json:"projectId"` Clean bool `json:"clean,omitempty"` } // SpawnOrchestratorResponse is the body of POST /api/v1/orchestrators. type SpawnOrchestratorResponse struct { Orchestrator OrchestratorResponse `json:"orchestrator"` } // OrchestratorResponse is the minimal orchestrator read model returned after spawn. type OrchestratorResponse struct { ID domain.SessionID `json:"id"` ProjectID domain.ProjectID `json:"projectId"` ProjectName string `json:"projectName,omitempty"` } // PRIDParam is the {id} path parameter shared by the /prs/{id} routes. type PRIDParam struct { ID string `path:"id" description:"PR number."` } // MergePRResponse is the body of POST /api/v1/prs/{id}/merge (200). type MergePRResponse struct { OK bool `json:"ok"` PRNumber int `json:"prNumber"` Method string `json:"method"` } // ResolveCommentsRequest is the optional body of POST /api/v1/prs/{id}/resolve-comments. type ResolveCommentsRequest struct { CommentIDs []string `json:"commentIds,omitempty"` } // ResolveCommentsResponse is the body of POST /api/v1/prs/{id}/resolve-comments (200). type ResolveCommentsResponse struct { OK bool `json:"ok"` Resolved int `json:"resolved"` }