diff --git a/internal/acp/dto.go b/internal/acp/dto.go index 9355d35..eb5f720 100644 --- a/internal/acp/dto.go +++ b/internal/acp/dto.go @@ -46,3 +46,69 @@ type UpdateAgentKindReq struct { Env map[string]string `json:"env,omitempty"` Enabled *bool `json:"enabled,omitempty"` } + +// CreateSessionReq is the request body for POST /api/v1/acp/sessions. +type CreateSessionReq struct { + WorkspaceID string `json:"workspace_id"` + AgentKindID string `json:"agent_kind_id"` + Branch *string `json:"branch,omitempty"` + IssueNumber *int `json:"issue_number,omitempty"` + RequirementNumber *int `json:"requirement_number,omitempty"` + InitialPrompt *string `json:"initial_prompt,omitempty"` +} + +// SessionDTO is the response shape for sessions. +type SessionDTO struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + ProjectID string `json:"project_id"` + AgentKindID string `json:"agent_kind_id"` + UserID string `json:"user_id"` + IssueID *string `json:"issue_id"` + RequirementID *string `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + PID *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt string `json:"started_at"` + EndedAt *string `json:"ended_at"` +} + +func sessionToDTO(s *Session) SessionDTO { + var issueID, reqID, ended *string + if s.IssueID != nil { + v := s.IssueID.String() + issueID = &v + } + if s.RequirementID != nil { + v := s.RequirementID.String() + reqID = &v + } + if s.EndedAt != nil { + v := s.EndedAt.UTC().Format("2006-01-02T15:04:05Z") + ended = &v + } + return SessionDTO{ + ID: s.ID.String(), + WorkspaceID: s.WorkspaceID.String(), + ProjectID: s.ProjectID.String(), + AgentKindID: s.AgentKindID.String(), + UserID: s.UserID.String(), + IssueID: issueID, + RequirementID: reqID, + AgentSessionID: s.AgentSessionID, + Branch: s.Branch, + CwdPath: s.CwdPath, + IsMainWorktree: s.IsMainWorktree, + Status: string(s.Status), + PID: s.PID, + ExitCode: s.ExitCode, + LastError: s.LastError, + StartedAt: s.StartedAt.UTC().Format("2006-01-02T15:04:05Z"), + EndedAt: ended, + } +} diff --git a/internal/acp/handler.go b/internal/acp/handler.go index 787f3c8..948c84f 100644 --- a/internal/acp/handler.go +++ b/internal/acp/handler.go @@ -1,5 +1,6 @@ // Package acp handler.go exposes the ACP module's HTTP / WS endpoints. -// Part 1: agent_kinds routes only. Part 2 will add sessions REST + WS upgrader. +// Part 1: agent_kinds routes. Part 2 (G1/G2): sessions REST endpoints. +// Part 3 (G3): WebSocket upgrader. package acp import ( @@ -7,6 +8,7 @@ import ( "encoding/json" "net/http" "sort" + "strconv" "strings" "github.com/go-chi/chi/v5" @@ -23,17 +25,24 @@ type AdminLookup interface { IsAdmin(ctx context.Context, userID uuid.UUID) (bool, error) } -// Handler exposes ACP HTTP endpoints. Part 2 will extend with SessionService and Supervisor. +// Handler exposes ACP HTTP endpoints. type Handler struct { akSvc AgentKindService + sessSvc SessionService + sup *Supervisor + repo Repository resolver middleware.SessionResolver adminLookup AdminLookup enc *crypto.Encryptor } -// NewHandler constructs Handler (Part 1: agent_kinds only). -func NewHandler(ak AgentKindService, resolver middleware.SessionResolver, al AdminLookup, enc *crypto.Encryptor) *Handler { - return &Handler{akSvc: ak, resolver: resolver, adminLookup: al, enc: enc} +// NewHandler constructs Handler with full dependencies. +func NewHandler(ak AgentKindService, ss SessionService, sup *Supervisor, repo Repository, + resolver middleware.SessionResolver, al AdminLookup, enc *crypto.Encryptor) *Handler { + return &Handler{ + akSvc: ak, sessSvc: ss, sup: sup, repo: repo, + resolver: resolver, adminLookup: al, enc: enc, + } } // Mount registers /api/v1/acp/* routes. @@ -46,6 +55,15 @@ func (h *Handler) Mount(r chi.Router) { r.Patch("/{id}", h.updateAgentKind) r.Delete("/{id}", h.deleteAgentKind) }) + r.Route("/api/v1/acp/sessions", func(r chi.Router) { + r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) + r.Get("/", h.listSessions) + r.Post("/", h.createSession) + r.Get("/{id}", h.getSession) + r.Delete("/{id}", h.terminateSession) + r.Get("/{id}/events", h.getEvents) + // /{id}/ws — added in G3 (WebSocket) + }) } func (h *Handler) caller(r *http.Request) (Caller, error) { @@ -191,6 +209,145 @@ func (h *Handler) deleteAgentKind(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// ===== Session endpoints ===== + +func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + all := r.URL.Query().Get("all") == "true" + list, err := h.sessSvc.List(r.Context(), c, all) + if err != nil { + writeErr(w, r, err) + return + } + out := make([]SessionDTO, 0, len(list)) + for _, s := range list { + out = append(out, sessionToDTO(s)) + } + httpx.WriteJSON(w, http.StatusOK, out) +} + +func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + var req CreateSessionReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json")) + return + } + wsID, perr := uuid.Parse(req.WorkspaceID) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad workspace_id")) + return + } + akID, perr := uuid.Parse(req.AgentKindID) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad agent_kind_id")) + return + } + in := CreateSessionInput{ + WorkspaceID: wsID, + AgentKindID: akID, + Branch: req.Branch, + IssueNumber: req.IssueNumber, + RequirementNumber: req.RequirementNumber, + InitialPrompt: req.InitialPrompt, + } + out, err := h.sessSvc.Create(r.Context(), c, in) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, sessionToDTO(out)) +} + +func (h *Handler) getSession(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + id, perr := uuid.Parse(chi.URLParam(r, "id")) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id")) + return + } + s, err := h.sessSvc.Get(r.Context(), c, id) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, sessionToDTO(s)) +} + +func (h *Handler) terminateSession(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + id, perr := uuid.Parse(chi.URLParam(r, "id")) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id")) + return + } + if err := h.sessSvc.Terminate(r.Context(), c, id); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) getEvents(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + id, perr := uuid.Parse(chi.URLParam(r, "id")) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id")) + return + } + if _, err := h.sessSvc.Get(r.Context(), c, id); err != nil { + writeErr(w, r, err) + return + } + var since int64 + if v := r.URL.Query().Get("since"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + since = n + } + } + evs, err := h.repo.ListEventsSince(r.Context(), id, since, 1000) + if err != nil { + writeErr(w, r, err) + return + } + out := make([]EventEnvelope, 0, len(evs)) + for _, e := range evs { + method := "" + if e.Method != nil { + method = *e.Method + } + out = append(out, EventEnvelope{ + ID: e.ID, + Direction: string(e.Direction), + Kind: string(e.RPCKind), + Method: method, + Payload: e.Payload, + Truncated: e.Truncated, + }) + } + httpx.WriteJSON(w, http.StatusOK, out) +} + // ===== DTO converters ===== // agentKindToAdminDTO is a Handler method because env-key extraction needs the encryptor. diff --git a/internal/acp/handler_test.go b/internal/acp/handler_test.go index 7f77e40..5ab064c 100644 --- a/internal/acp/handler_test.go +++ b/internal/acp/handler_test.go @@ -46,6 +46,9 @@ func mountHandlerWithRepo(t *testing.T, callerUID uuid.UUID, isAdmin bool, repo r := chi.NewRouter() h := acp.NewHandler( svc, + nil, // sessSvc — not exercised by agent-kinds tests + nil, // sup — not exercised by agent-kinds tests + nil, // repo — not exercised by agent-kinds tests fakeResolver{uid: callerUID}, fakeAdminLookup{admin: map[uuid.UUID]bool{callerUID: isAdmin}}, enc, diff --git a/internal/app/app.go b/internal/app/app.go index 6f80674..3a09d6c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -327,10 +327,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { MaxActivePerUser: cfg.Acp.MaxActivePerUser, }, ) - _ = sessSvc // wired in G1 (handler refactor) - - // Handler: G1 will refactor to take sessSvc + acpSup. For now use Part 1 signature. - acp.NewHandler(akSvc, userSvc, userAdminAdapter{svc: userSvc}, enc).Mount(r) + acp.NewHandler(akSvc, sessSvc, acpSup, acpRepo, userSvc, userAdminAdapter{svc: userSvc}, enc).Mount(r) // ===== jobs module wiring ===== jobsRepo := jobs.NewPostgresRepository(pool)