From dc6b7efa9b98bd04a4b0cf58054ec529ff1c6199 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 3 Jun 2026 15:41:02 +0530 Subject: [PATCH] feat: add light backend CLI commands --- backend/internal/cli/client.go | 6 + backend/internal/cli/dto_drift_e2e_test.go | 8 + backend/internal/cli/orchestrator.go | 121 +++++++++++ backend/internal/cli/orchestrator_test.go | 83 ++++++++ backend/internal/cli/root.go | 1 + backend/internal/cli/session.go | 199 ++++++++++++++++++ backend/internal/cli/session_test.go | 127 ++++++++++- backend/internal/domain/session.go | 1 + backend/internal/httpd/apispec/openapi.yaml | 132 +++++++++++- .../internal/httpd/apispec/specgen/build.go | 36 +++- backend/internal/httpd/controllers/dto.go | 23 ++ .../internal/httpd/controllers/sessions.go | 72 ++++++- .../httpd/controllers/sessions_test.go | 162 +++++++++++++- .../internal/service/project/service_test.go | 3 + backend/internal/service/session/service.go | 19 ++ .../internal/service/session/service_test.go | 36 ++++ backend/internal/session_manager/manager.go | 10 +- backend/internal/storage/sqlite/gen/models.go | 1 + .../storage/sqlite/gen/projects.sql.go | 2 +- .../storage/sqlite/gen/sessions.sql.go | 37 +++- .../0003_add_session_display_name.sql | 9 + .../storage/sqlite/queries/sessions.sql | 15 +- .../storage/sqlite/store/session_store.go | 29 ++- .../storage/sqlite/store/store_test.go | 25 +++ 24 files changed, 1127 insertions(+), 30 deletions(-) create mode 100644 backend/internal/cli/orchestrator.go create mode 100644 backend/internal/cli/orchestrator_test.go create mode 100644 backend/internal/storage/sqlite/migrations/0003_add_session_display_name.sql diff --git a/backend/internal/cli/client.go b/backend/internal/cli/client.go index c4e9f4ee7..c4e405f5c 100644 --- a/backend/internal/cli/client.go +++ b/backend/internal/cli/client.go @@ -56,6 +56,12 @@ func (c *commandContext) postJSON(ctx context.Context, path string, body, out an return c.doJSON(ctx, http.MethodPost, path, body, out) } +// patchJSON sends body as JSON to PATCH /api/v1/ on the running daemon +// and decodes a 2xx response into out. +func (c *commandContext) patchJSON(ctx context.Context, path string, body, out any) error { + return c.doJSON(ctx, http.MethodPatch, path, body, out) +} + // deleteJSON sends DELETE /api/v1/ to the running daemon and decodes a // 2xx response into out. func (c *commandContext) deleteJSON(ctx context.Context, path string, out any) error { diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 07c10a656..9135278c0 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -74,6 +74,14 @@ func (f *fakeSessionService) Kill(context.Context, domain.SessionID) (bool, erro return false, nil } +func (f *fakeSessionService) Cleanup(context.Context, domain.ProjectID) ([]domain.SessionID, error) { + return nil, nil +} + +func (f *fakeSessionService) Rename(context.Context, domain.SessionID, string) error { + return nil +} + func (f *fakeSessionService) Send(context.Context, domain.SessionID, string) error { return nil } diff --git a/backend/internal/cli/orchestrator.go b/backend/internal/cli/orchestrator.go new file mode 100644 index 000000000..65cce1b0f --- /dev/null +++ b/backend/internal/cli/orchestrator.go @@ -0,0 +1,121 @@ +package cli + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" +) + +type orchestratorListOptions struct { + json bool +} + +type orchestratorListOutput struct { + Data []sessionListEntry `json:"data"` +} + +func newOrchestratorCommand(ctx *commandContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "orchestrator", + Short: "Manage orchestrator sessions", + } + cmd.AddCommand(newOrchestratorListCommand(ctx)) + return cmd +} + +func newOrchestratorListCommand(ctx *commandContext) *cobra.Command { + var opts orchestratorListOptions + cmd := &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List orchestrator sessions", + Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return ctx.listOrchestrators(cmd.Context(), cmd, opts) + }, + } + cmd.Flags().BoolVar(&opts.json, "json", false, "Output as JSON") + return cmd +} + +func (c *commandContext) listOrchestrators(ctx context.Context, cmd *cobra.Command, opts orchestratorListOptions) error { + var res sessionListResponse + if err := c.getJSON(ctx, "orchestrators", &res); err != nil { + return err + } + orchestrators := filterAndSortOrchestrators(res.Sessions) + if opts.json { + return writeJSON(cmd.OutOrStdout(), orchestratorListOutput{Data: sessionListEntries(orchestrators)}) + } + return writeOrchestratorList(cmd, orchestrators) +} + +func filterAndSortOrchestrators(sessions []sessionDTO) []sessionDTO { + out := make([]sessionDTO, 0, len(sessions)) + for _, sess := range sessions { + if sess.Kind != "orchestrator" { + continue + } + out = append(out, sess) + } + sort.Slice(out, func(i, j int) bool { + if out[i].ProjectID != out[j].ProjectID { + return out[i].ProjectID < out[j].ProjectID + } + return out[i].ID < out[j].ID + }) + return out +} + +func writeOrchestratorList(cmd *cobra.Command, sessions []sessionDTO) error { + out := cmd.OutOrStdout() + if len(sessions) == 0 { + _, err := fmt.Fprintln(out, "(no orchestrators)") + return err + } + currentProject := "" + for _, sess := range sessions { + if sess.ProjectID != currentProject { + if currentProject != "" { + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } + currentProject = sess.ProjectID + if _, err := fmt.Fprintf(out, "%s:\n", currentProject); err != nil { + return err + } + } + if _, err := fmt.Fprintf(out, " %s", sess.ID); err != nil { + return err + } + parts := orchestratorLineParts(sess) + if len(parts) > 0 { + if _, err := fmt.Fprintf(out, " %s", strings.Join(parts, " ")); err != nil { + return err + } + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } + return nil +} + +func orchestratorLineParts(sess sessionDTO) []string { + parts := []string{} + if !sess.Activity.LastActivityAt.IsZero() { + parts = append(parts, "("+formatSessionAge(time.Since(sess.Activity.LastActivityAt))+")") + } + if sess.Status != "" { + parts = append(parts, "["+sess.Status+"]") + } + if sess.IsTerminated { + parts = append(parts, "terminated") + } + return parts +} diff --git a/backend/internal/cli/orchestrator_test.go b/backend/internal/cli/orchestrator_test.go new file mode 100644 index 000000000..31fdd7c12 --- /dev/null +++ b/backend/internal/cli/orchestrator_test.go @@ -0,0 +1,83 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +func orchestratorCommandServer(t *testing.T) (*httptest.Server, *sessionRequestLog) { + t.Helper() + log := &sessionRequestLog{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.append(r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/orchestrators": + _, _ = io.WriteString(w, `{"sessions":[`+ + sessionJSON("other-orch", "other", "orchestrator", "idle", false)+`,`+ + sessionJSON("demo-worker", "demo", "worker", "working", false)+`,`+ + sessionJSON("demo-orch", "demo", "orchestrator", "working", false)+`]}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv, log +} + +func TestOrchestratorList_TableOutput(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := orchestratorCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "orchestrator", "ls") + if err != nil { + t.Fatalf("orchestrator ls failed: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, "demo:") || !strings.Contains(out, "demo-orch") { + t.Fatalf("output missing demo orchestrator:\n%s", out) + } + if !strings.Contains(out, "other:") || !strings.Contains(out, "other-orch") { + t.Fatalf("output missing other orchestrator:\n%s", out) + } + if strings.Contains(out, "demo-worker") { + t.Fatalf("worker session should not be shown in orchestrator ls:\n%s", out) + } + want := []string{"GET /api/v1/orchestrators"} + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestOrchestratorList_JSONOutputDecodes(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := orchestratorCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "orchestrator", "ls", "--json") + if err != nil { + t.Fatalf("orchestrator ls --json failed: %v\nstderr=%s", err, errOut) + } + var got orchestratorListOutput + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("orchestrator ls --json output is not decodable: %v\noutput=%s", err, out) + } + if len(got.Data) != 2 { + t.Fatalf("len(data) = %d, want 2; data=%#v", len(got.Data), got.Data) + } + if got.Data[0].ID != "demo-orch" || got.Data[0].ProjectID != "demo" || got.Data[0].Role != "orchestrator" { + t.Fatalf("unexpected first JSON entry: %#v", got.Data[0]) + } + if got.Data[1].ID != "other-orch" || got.Data[1].ProjectID != "other" || got.Data[1].Role != "orchestrator" { + t.Fatalf("unexpected second JSON entry: %#v", got.Data[1]) + } +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index e815c7fb9..61e18a0a8 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -151,6 +151,7 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newSendCommand(ctx)) root.AddCommand(newProjectCommand(ctx)) root.AddCommand(newSessionCommand(ctx)) + root.AddCommand(newOrchestratorCommand(ctx)) root.AddCommand(newCompletionCommand()) root.AddCommand(newVersionCommand()) diff --git a/backend/internal/cli/session.go b/backend/internal/cli/session.go index 28581f51e..9f6696263 100644 --- a/backend/internal/cli/session.go +++ b/backend/internal/cli/session.go @@ -1,6 +1,7 @@ package cli import ( + "bufio" "context" "errors" "fmt" @@ -23,12 +24,22 @@ type sessionListOptions struct { includeTerminated bool } +type sessionCleanupOptions struct { + project string + yes bool +} + +type sessionRenameRequest struct { + DisplayName string `json:"displayName"` +} + type sessionDTO struct { ID string `json:"id"` ProjectID string `json:"projectId"` IssueID string `json:"issueId,omitempty"` Kind string `json:"kind"` Harness string `json:"harness,omitempty"` + DisplayName string `json:"displayName,omitempty"` Activity sessionActivity `json:"activity"` IsTerminated bool `json:"isTerminated"` CreatedAt time.Time `json:"createdAt"` @@ -58,6 +69,15 @@ type restoreSessionResponse struct { Session sessionDTO `json:"session"` } +type renameSessionResponse struct { + SessionID string `json:"sessionId"` + DisplayName string `json:"displayName"` +} + +type cleanupSessionsResponse struct { + Cleaned []string `json:"cleaned"` +} + type sessionListEntry struct { ID string `json:"id"` ProjectID string `json:"projectId"` @@ -87,6 +107,8 @@ func newSessionCommand(ctx *commandContext) *cobra.Command { cmd.AddCommand(newSessionGetCommand(ctx)) cmd.AddCommand(newSessionKillCommand(ctx)) cmd.AddCommand(newSessionRestoreCommand(ctx)) + cmd.AddCommand(newSessionRenameCommand(ctx)) + cmd.AddCommand(newSessionCleanupCommand(ctx)) return cmd } @@ -164,6 +186,40 @@ func newSessionRestoreCommand(ctx *commandContext) *cobra.Command { return cmd } +func newSessionRenameCommand(ctx *commandContext) *cobra.Command { + var opts sessionOptions + cmd := &cobra.Command{ + Use: "rename ", + Short: "Rename a session", + Args: sessionRenameArgs, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := normalizeSessionID(args[0]) + if err != nil { + return err + } + return ctx.renameSession(cmd.Context(), cmd, id, args[1], opts) + }, + } + addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup") + return cmd +} + +func newSessionCleanupCommand(ctx *commandContext) *cobra.Command { + var opts sessionCleanupOptions + cmd := &cobra.Command{ + Use: "cleanup", + Short: "Clean up terminated sessions", + Long: "Clean up terminated sessions by reclaiming eligible workspaces. Dirty worktrees are skipped by the daemon.", + Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return ctx.cleanupSessions(cmd.Context(), cmd, opts) + }, + } + addSessionProjectFlag(cmd.Flags(), &opts.project, "Filter by project ID") + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip confirmation prompt") + return cmd +} + func addSessionProjectFlag(flags interface { StringVarP(*string, string, string, string, string) }, target *string, usage string) { @@ -180,6 +236,19 @@ func oneSessionIDArg(cmd *cobra.Command, args []string) error { return nil } +func sessionRenameArgs(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(2)(cmd, args); err != nil { + return usageError{err} + } + if _, err := normalizeSessionID(args[0]); err != nil { + return err + } + if strings.TrimSpace(args[1]) == "" { + return usageError{errors.New("session name is required")} + } + return nil +} + func (c *commandContext) listSessions(ctx context.Context, cmd *cobra.Command, opts sessionListOptions) error { params := url.Values{} if opts.project != "" { @@ -269,6 +338,96 @@ func (c *commandContext) restoreSession(ctx context.Context, cmd *cobra.Command, return nil } +func (c *commandContext) renameSession(ctx context.Context, cmd *cobra.Command, id, displayName string, opts sessionOptions) error { + if opts.project != "" { + if _, err := c.fetchScopedSession(ctx, id, opts.project); err != nil { + return err + } + } + name := strings.TrimSpace(displayName) + var res renameSessionResponse + if err := c.patchJSON(ctx, "sessions/"+url.PathEscape(id), sessionRenameRequest{DisplayName: name}, &res); err != nil { + return err + } + sessionID := res.SessionID + if sessionID == "" { + sessionID = id + } + if res.DisplayName != "" { + name = res.DisplayName + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "session %s renamed to %q\n", sessionID, name) + return err +} + +func (c *commandContext) cleanupSessions(ctx context.Context, cmd *cobra.Command, opts sessionCleanupOptions) error { + candidates, err := c.previewCleanupSessions(ctx, opts.project) + if err != nil { + return err + } + out := cmd.OutOrStdout() + if _, err := fmt.Fprintln(out, "Checking for completed sessions..."); err != nil { + return err + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + if len(candidates) == 0 { + _, err := fmt.Fprintln(out, " No sessions to clean up.") + return err + } + labels := cleanupLabels(candidates, opts.project) + for _, label := range labels { + if _, err := fmt.Fprintf(out, " Would clean %s\n", label); err != nil { + return err + } + } + if !opts.yes { + confirmed, err := confirmSessionCleanup(cmd, len(candidates), opts.project) + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(out, "aborted") + return err + } + } + params := url.Values{} + if opts.project != "" { + params.Set("project", opts.project) + } + var res cleanupSessionsResponse + if err := c.postJSON(ctx, apiPath("sessions/cleanup", params), struct{}{}, &res); err != nil { + return err + } + cleaned := res.Cleaned + labelByID := cleanupLabelByID(candidates, opts.project) + for _, id := range cleaned { + label := id + if mapped := labelByID[id]; mapped != "" { + label = mapped + } + if _, err := fmt.Fprintf(out, " Cleaned: %s\n", label); err != nil { + return err + } + } + _, err = fmt.Fprintf(out, "\nCleanup complete. %d session%s cleaned.\n", len(cleaned), pluralS(len(cleaned))) + return err +} + +func (c *commandContext) previewCleanupSessions(ctx context.Context, project string) ([]sessionDTO, error) { + params := url.Values{} + params.Set("active", "false") + if project != "" { + params.Set("project", project) + } + var res sessionListResponse + if err := c.getJSON(ctx, apiPath("sessions", params), &res); err != nil { + return nil, err + } + return filterAndSortSessions(res.Sessions, true), nil +} + func (c *commandContext) fetchScopedSession(ctx context.Context, id, project string) (sessionDTO, error) { var res sessionResponse if err := c.getJSON(ctx, "sessions/"+url.PathEscape(id), &res); err != nil { @@ -321,6 +480,29 @@ func sessionListEntries(sessions []sessionDTO) []sessionListEntry { return entries } +func cleanupLabels(sessions []sessionDTO, scopedProject string) []string { + labels := make([]string, 0, len(sessions)) + for _, sess := range sessions { + labels = append(labels, cleanupLabel(sess, scopedProject)) + } + return labels +} + +func cleanupLabelByID(sessions []sessionDTO, scopedProject string) map[string]string { + labels := make(map[string]string, len(sessions)) + for _, sess := range sessions { + labels[sess.ID] = cleanupLabel(sess, scopedProject) + } + return labels +} + +func cleanupLabel(sess sessionDTO, scopedProject string) string { + if scopedProject == "" && sess.ProjectID != "" { + return sess.ProjectID + ":" + sess.ID + } + return sess.ID +} + func writeSessionList(cmd *cobra.Command, sessions []sessionDTO, hiddenTerminatedCount int) error { out := cmd.OutOrStdout() if len(sessions) == 0 { @@ -384,6 +566,7 @@ func writeSessionDetails(cmd *cobra.Command, sess sessionDTO) error { fields := [][2]string{ {"id", sess.ID}, {"project", sess.ProjectID}, + {"name", sess.DisplayName}, {"role", sessionRole(sess)}, {"status", sess.Status}, {"activity", sess.Activity.State}, @@ -456,3 +639,19 @@ func normalizeSessionID(id string) (string, error) { } return trimmed, nil } + +func confirmSessionCleanup(cmd *cobra.Command, count int, project string) (bool, error) { + scope := " across all projects" + if project != "" { + scope = fmt.Sprintf(" in project %q", project) + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Clean %d terminated session%s%s? Type yes to confirm: ", count, pluralS(count), scope); err != nil { + return false, err + } + reader := bufio.NewReader(cmd.InOrStdin()) + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return false, err + } + return strings.EqualFold(strings.TrimSpace(line), "yes"), nil +} diff --git a/backend/internal/cli/session_test.go b/backend/internal/cli/session_test.go index 317a8f48b..45e2b283f 100644 --- a/backend/internal/cli/session_test.go +++ b/backend/internal/cli/session_test.go @@ -43,7 +43,9 @@ func sessionCommandServer(t *testing.T) (*httptest.Server, *sessionRequestLog) { active := r.URL.Query().Get("active") switch active { case "false": - _, _ = io.WriteString(w, `{"sessions":[`+sessionJSON("demo-old", "demo", "worker", "terminated", true)+`]}`) + _, _ = io.WriteString(w, `{"sessions":[`+ + sessionJSON("demo-old", "demo", "worker", "terminated", true)+`,`+ + sessionJSON("demo-orch", "demo", "orchestrator", "terminated", true)+`]}`) default: _, _ = io.WriteString(w, `{"sessions":[`+ sessionJSON("demo-2", "demo", "orchestrator", "idle", false)+`,`+ @@ -51,10 +53,19 @@ func sessionCommandServer(t *testing.T) (*httptest.Server, *sessionRequestLog) { } case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/demo-1": _, _ = io.WriteString(w, `{"session":`+sessionJSON("demo-1", "demo", "worker", "working", false)+`}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/cleanup": + _, _ = io.WriteString(w, `{"ok":true,"cleaned":["demo-old","demo-orch"]}`) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/kill": _, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","freed":true}`) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-1/restore": _, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","session":`+sessionJSON("demo-1", "demo", "worker", "idle", false)+`}`) + case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/sessions/demo-1": + var req sessionRenameRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _, _ = io.WriteString(w, `{"ok":true,"sessionId":"demo-1","displayName":`+jsonQuote(req.DisplayName)+`}`) default: http.NotFound(w, r) } @@ -69,6 +80,7 @@ func sessionJSON(id, project, kind, status string, terminated bool) string { "projectId": project, "kind": kind, "harness": "codex", + "displayName": "Current Name", "activity": map[string]any{"state": "idle", "lastActivityAt": "2026-06-02T12:00:00Z"}, "isTerminated": terminated, "createdAt": "2026-06-02T11:00:00Z", @@ -78,6 +90,11 @@ func sessionJSON(id, project, kind, status string, terminated bool) string { return string(b) } +func jsonQuote(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + func TestSessionList_ProjectFilterAndDefaultFiltering(t *testing.T) { cfg := setConfigEnv(t) srv, log := sessionCommandServer(t) @@ -213,6 +230,79 @@ func TestSessionRestore_SuccessWithProjectScope(t *testing.T) { } } +func TestSessionCleanup_YesSkipsPrompt(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + In: strings.NewReader("no\n"), + ProcessAlive: func(int) bool { return true }, + }, "session", "cleanup", "--project", "demo", "--yes") + if err != nil { + t.Fatalf("session cleanup failed: %v\nstderr=%s", err, errOut) + } + if strings.Contains(out, "Type yes to confirm") { + t.Fatalf("--yes should skip confirmation prompt:\n%s", out) + } + for _, want := range []string{"Checking for completed sessions", "Would clean demo-old", "Would clean demo-orch", "Cleaned: demo-old", "Cleaned: demo-orch", "Cleanup complete. 2 sessions cleaned."} { + if !strings.Contains(out, want) { + t.Fatalf("cleanup output missing %q:\n%s", want, out) + } + } + want := []string{ + "GET /api/v1/sessions?active=false&project=demo", + "POST /api/v1/sessions/cleanup?project=demo", + } + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestSessionCleanup_PromptFailsWithoutInput(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, _, err := executeCLI(t, Deps{ + In: strings.NewReader(""), + ProcessAlive: func(int) bool { return true }, + }, "session", "cleanup", "--project", "demo") + if err == nil { + t.Fatal("expected cleanup prompt without input to fail") + } + if got := ExitCode(err); got != 1 { + t.Fatalf("exit code = %d, want 1", got) + } + if !strings.Contains(out, "Type yes to confirm") { + t.Fatalf("output missing confirmation prompt:\n%s", out) + } + want := []string{"GET /api/v1/sessions?active=false&project=demo"} + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestSessionRename_SuccessWithProjectScope(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "session", "rename", "demo-1", "New Name", "-p", "demo") + if err != nil { + t.Fatalf("session rename failed: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, `session demo-1 renamed to "New Name"`) { + t.Fatalf("unexpected rename output:\n%s", out) + } + want := []string{"GET /api/v1/sessions/demo-1", "PATCH /api/v1/sessions/demo-1"} + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + func TestSessionCommands_MissingIDIsUsageError(t *testing.T) { setConfigEnv(t) for _, sub := range []string{"get", "kill", "restore"} { @@ -228,6 +318,18 @@ func TestSessionCommands_MissingIDIsUsageError(t *testing.T) { } } +func TestSessionRename_MissingNameIsUsageError(t *testing.T) { + setConfigEnv(t) + + _, _, err := executeCLI(t, Deps{}, "session", "rename", "demo-1") + if err == nil { + t.Fatal("expected missing name to fail") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2 (err=%v)", got, err) + } +} + func TestSessionGet_ProjectMismatchDoesNotPassScope(t *testing.T) { cfg := setConfigEnv(t) srv, _ := sessionCommandServer(t) @@ -246,3 +348,26 @@ func TestSessionGet_ProjectMismatchDoesNotPassScope(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestSessionRename_ProjectMismatchDoesNotPatch(t *testing.T) { + cfg := setConfigEnv(t) + srv, log := sessionCommandServer(t) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "session", "rename", "demo-1", "New Name", "--project", "other") + if err == nil { + t.Fatal("expected project mismatch to fail") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2", got) + } + if !strings.Contains(err.Error(), "not in project other") { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"GET /api/v1/sessions/demo-1"} + if got := log.all(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index 235407237..96abd6f8d 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -41,6 +41,7 @@ type SessionRecord struct { IssueID IssueID `json:"issueId,omitempty"` Kind SessionKind `json:"kind"` Harness AgentHarness `json:"harness,omitempty"` + DisplayName string `json:"displayName,omitempty"` Activity Activity `json:"activity"` IsTerminated bool `json:"isTerminated"` Metadata SessionMetadata `json:"-"` diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 0439d89f9..7e2ed972e 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -9,6 +9,30 @@ servers: url: http://127.0.0.1:3001 paths: /api/v1/orchestrators: + get: + operationId: listOrchestrators + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListSessionsResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: List orchestrator sessions across projects + tags: + - sessions post: operationId: spawnOrchestrator requestBody: @@ -51,6 +75,45 @@ paths: summary: Spawn an orchestrator session tags: - sessions + /api/v1/orchestrators/{id}: + get: + operationId: getOrchestrator + parameters: + - description: Orchestrator session identifier, e.g. project-orchestrator. + in: path + name: id + required: true + schema: + description: Orchestrator session identifier, e.g. project-orchestrator. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Fetch one orchestrator session + tags: + - sessions /api/v1/projects: get: operationId: listProjects @@ -409,7 +472,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SessionResponse' + $ref: '#/components/schemas/RenameSessionResponse' description: OK "400": content: @@ -423,6 +486,12 @@ paths: schema: $ref: '#/components/schemas/APIError' description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error "501": content: application/json: @@ -555,6 +624,40 @@ paths: summary: Send a message to a running session's agent tags: - sessions + /api/v1/sessions/cleanup: + post: + operationId: cleanupSessions + parameters: + - description: Project id filter. When omitted, clean terminated sessions across + all projects. + in: query + name: project + schema: + description: Project id filter. When omitted, clean terminated sessions + across all projects. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/CleanupSessionsResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Clean up terminated session workspaces + tags: + - sessions components: schemas: APIError: @@ -590,6 +693,18 @@ components: required: - path type: object + CleanupSessionsResponse: + properties: + cleaned: + items: + type: string + type: array + ok: + type: boolean + required: + - ok + - cleaned + type: object DegradedProject: properties: id: @@ -755,6 +870,19 @@ components: required: - displayName type: object + RenameSessionResponse: + properties: + displayName: + type: string + ok: + type: boolean + sessionId: + type: string + required: + - ok + - sessionId + - displayName + type: object ResolveCommentsResponse: properties: ok: @@ -837,6 +965,8 @@ components: createdAt: format: date-time type: string + displayName: + type: string harness: type: string id: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 406d41b81..e178a1532 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -122,11 +122,14 @@ var schemaNames = map[string]string{ "ControllersGetProjectResponse": "ProjectGetResponse", "ControllersProjectOrDegraded": "ProjectOrDegraded", "ControllersListSessionsQuery": "ListSessionsQuery", + "ControllersCleanupSessionsQuery": "CleanupSessionsQuery", "ControllersListSessionsResponse": "ListSessionsResponse", "ControllersSpawnSessionRequest": "SpawnSessionRequest", "ControllersSessionResponse": "SessionResponse", "ControllersRenameSessionRequest": "RenameSessionRequest", + "ControllersRenameSessionResponse": "RenameSessionResponse", "ControllersRestoreSessionResponse": "RestoreSessionResponse", + "ControllersCleanupSessionsResponse": "CleanupSessionsResponse", "ControllersKillSessionResponse": "KillSessionResponse", "ControllersSendSessionMessageRequest": "SendSessionMessageRequest", "ControllersSendSessionMessageResponse": "SendSessionMessageResponse", @@ -311,9 +314,20 @@ func sessionOperations() []operation { pathParams: []any{controllers.SessionIDParam{}}, reqBody: controllers.RenameSessionRequest{}, resps: []respUnit{ - {http.StatusOK, controllers.SessionResponse{}}, + {http.StatusOK, controllers.RenameSessionResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions/cleanup", id: "cleanupSessions", tag: "sessions", + summary: "Clean up terminated session workspaces", + pathParams: []any{controllers.CleanupSessionsQuery{}}, + resps: []respUnit{ + {http.StatusOK, controllers.CleanupSessionsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, {http.StatusNotImplemented, envelope.APIError{}}, }, }, @@ -351,6 +365,15 @@ func sessionOperations() []operation { {http.StatusInternalServerError, envelope.APIError{}}, }, }, + { + method: http.MethodGet, path: "/api/v1/orchestrators", id: "listOrchestrators", tag: "sessions", + summary: "List orchestrator sessions across projects", + resps: []respUnit{ + {http.StatusOK, controllers.ListSessionsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, { method: http.MethodPost, path: "/api/v1/orchestrators", id: "spawnOrchestrator", tag: "sessions", summary: "Spawn an orchestrator session", @@ -363,6 +386,17 @@ func sessionOperations() []operation { {http.StatusNotImplemented, envelope.APIError{}}, }, }, + { + method: http.MethodGet, path: "/api/v1/orchestrators/{id}", id: "getOrchestrator", tag: "sessions", + summary: "Fetch one orchestrator session", + pathParams: []any{controllers.OrchestratorIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.SessionResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, } } diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 8efd581cd..9ccef68b2 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -105,6 +105,11 @@ type ListSessionsQuery struct { 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"` @@ -131,6 +136,13 @@ 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"` @@ -145,6 +157,12 @@ type KillSessionResponse struct { 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"` @@ -157,6 +175,11 @@ type SendSessionMessageResponse struct { Message string `json:"message"` } +// 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"` diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 632d31f44..35524df79 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -30,6 +30,8 @@ type SessionService interface { Get(ctx context.Context, id domain.SessionID) (domain.Session, error) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) Kill(ctx context.Context, id domain.SessionID) (bool, error) + Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) + Rename(ctx context.Context, id domain.SessionID, displayName string) error Send(ctx context.Context, id domain.SessionID, message string) error } @@ -43,12 +45,15 @@ type SessionsController struct { func (c *SessionsController) Register(r chi.Router) { r.Get("/sessions", c.list) r.Post("/sessions", c.spawn) + r.Post("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/sessions/cleanup", c.cleanup) r.Get("/sessions/{sessionId}", c.get) r.Patch("/sessions/{sessionId}", c.rename) r.Post("/sessions/{sessionId}/restore", c.restore) r.Post("/sessions/{sessionId}/kill", c.kill) r.Post("/sessions/{sessionId}/send", c.send) + r.Get("/orchestrators", c.listOrchestrators) r.Post("/orchestrators", c.spawnOrchestrator) + r.Get("/orchestrators/{id}", c.getOrchestrator) } func (c *SessionsController) list(w http.ResponseWriter, r *http.Request) { @@ -112,7 +117,25 @@ func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) { } func (c *SessionsController) rename(w http.ResponseWriter, r *http.Request) { - apispec.NotImplemented(w, r, "PATCH", "/api/v1/sessions/{sessionId}") + if c.Svc == nil { + apispec.NotImplemented(w, r, "PATCH", "/api/v1/sessions/{sessionId}") + return + } + var in RenameSessionRequest + if err := decodeJSON(r, &in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + displayName := strings.TrimSpace(in.DisplayName) + if displayName == "" { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "DISPLAY_NAME_REQUIRED", "displayName is required", nil) + return + } + if err := c.Svc.Rename(r.Context(), sessionID(r), displayName); err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, RenameSessionResponse{OK: true, SessionID: sessionID(r), DisplayName: displayName}) } func (c *SessionsController) restore(w http.ResponseWriter, r *http.Request) { @@ -141,6 +164,19 @@ func (c *SessionsController) kill(w http.ResponseWriter, r *http.Request) { envelope.WriteJSON(w, http.StatusOK, KillSessionResponse{OK: true, SessionID: sessionID(r), Freed: freed}) } +func (c *SessionsController) cleanup(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/cleanup") + return + } + cleaned, err := c.Svc.Cleanup(r.Context(), domain.ProjectID(r.URL.Query().Get("project"))) + if err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, CleanupSessionsResponse{OK: true, Cleaned: cleaned}) +} + func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) { if c.Svc == nil { apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/send") @@ -205,10 +241,44 @@ func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Re }) } +func (c *SessionsController) listOrchestrators(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/orchestrators") + return + } + sessions, err := c.Svc.List(r.Context(), sessionsvc.ListFilter{OrchestratorOnly: true}) + if err != nil { + writeSessionError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, ListSessionsResponse{Sessions: sessions}) +} + +func (c *SessionsController) getOrchestrator(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/orchestrators/{id}") + return + } + sess, err := c.Svc.Get(r.Context(), orchestratorID(r)) + if err != nil { + writeSessionError(w, r, err) + return + } + if sess.Kind != domain.KindOrchestrator { + writeSessionError(w, r, sessionmanager.ErrNotFound) + return + } + envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sess}) +} + func sessionID(r *http.Request) domain.SessionID { return domain.SessionID(chi.URLParam(r, "sessionId")) } +func orchestratorID(r *http.Request) domain.SessionID { + return domain.SessionID(chi.URLParam(r, "id")) +} + func parseSessionListFilter(r *http.Request) (sessionsvc.ListFilter, error) { q := r.URL.Query() filter := sessionsvc.ListFilter{ProjectID: domain.ProjectID(q.Get("project"))} diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 7ec882cc1..4d33fbc7d 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -14,11 +14,14 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/ports" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" ) type fakeSessionService struct { - sessions map[domain.SessionID]domain.Session - sent string + sessions map[domain.SessionID]domain.Session + sent string + cleanupProjects []domain.ProjectID + cleanupResult []domain.SessionID } func newFakeSessionService() *fakeSessionService { @@ -71,6 +74,24 @@ func (f *fakeSessionService) Kill(_ context.Context, id domain.SessionID) (bool, return true, nil } +func (f *fakeSessionService) Cleanup(_ context.Context, project domain.ProjectID) ([]domain.SessionID, error) { + f.cleanupProjects = append(f.cleanupProjects, project) + if f.cleanupResult != nil { + return f.cleanupResult, nil + } + return []domain.SessionID{"ao-1"}, nil +} + +func (f *fakeSessionService) Rename(_ context.Context, id domain.SessionID, displayName string) error { + s, ok := f.sessions[id] + if !ok { + return sessionmanager.ErrNotFound + } + s.DisplayName = displayName + f.sessions[id] = s + return nil +} + func (f *fakeSessionService) Send(_ context.Context, _ domain.SessionID, message string) error { f.sent = message return nil @@ -151,7 +172,21 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { } body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-2", `{"displayName":"Renamed"}`) - assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") + if status != http.StatusOK { + t.Fatalf("rename = %d, want 200; body=%s", status, body) + } + var renamed struct { + OK bool `json:"ok"` + SessionID string `json:"sessionId"` + DisplayName string `json:"displayName"` + } + mustJSON(t, body, &renamed) + if !renamed.OK || renamed.SessionID != "ao-2" || renamed.DisplayName != "Renamed" { + t.Fatalf("rename response = %#v", renamed) + } + if svc.sessions["ao-2"].DisplayName != "Renamed" { + t.Fatalf("session displayName not updated: %+v", svc.sessions["ao-2"]) + } body, status, _ = doRequest(t, srv, "POST", "/api/v1/orchestrators", `{"projectId":"ao"}`) if status != http.StatusCreated { @@ -159,6 +194,73 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { } } +func TestSessionsAPI_RenameNotFound(t *testing.T) { + srv := newSessionTestServer(t, newFakeSessionService()) + + body, status, _ := doRequest(t, srv, "PATCH", "/api/v1/sessions/missing-1", `{"displayName":"Renamed"}`) + assertErrorCode(t, body, status, http.StatusNotFound, "SESSION_NOT_FOUND") +} + +func TestSessionsAPI_RenameValidation(t *testing.T) { + srv := newSessionTestServer(t, newFakeSessionService()) + + body, status, _ := doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-1", `{"displayName":" "}`) + assertErrorCode(t, body, status, http.StatusBadRequest, "DISPLAY_NAME_REQUIRED") + + body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-1", `{`) + assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON") +} + +func TestSessionsAPI_ListOrchestratorsOnly(t *testing.T) { + svc := newFakeSessionService() + now := time.Now().UTC() + svc.sessions["ao-orch"] = domain.Session{ + SessionRecord: domain.SessionRecord{ + ID: "ao-orch", + ProjectID: "ao", + Kind: domain.KindOrchestrator, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, + CreatedAt: now, + UpdatedAt: now, + }, + Status: domain.StatusIdle, + } + svc.sessions["other-orch"] = domain.Session{ + SessionRecord: domain.SessionRecord{ + ID: "other-orch", + ProjectID: "other", + Kind: domain.KindOrchestrator, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, + CreatedAt: now, + UpdatedAt: now, + }, + Status: domain.StatusIdle, + } + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "GET", "/api/v1/orchestrators", "") + if status != http.StatusOK { + t.Fatalf("GET orchestrators = %d, want 200; body=%s", status, body) + } + var list struct { + Sessions []sessionBody `json:"sessions"` + } + mustJSON(t, body, &list) + if len(list.Sessions) != 2 { + t.Fatalf("len(orchestrators) = %d, want 2; body=%s", len(list.Sessions), body) + } + got := map[string]string{} + for _, sess := range list.Sessions { + got[sess.ID] = sess.Kind + } + if got["ao-orch"] != string(domain.KindOrchestrator) || got["other-orch"] != string(domain.KindOrchestrator) { + t.Fatalf("missing orchestrators: %#v", got) + } + if _, ok := got["ao-1"]; ok { + t.Fatalf("worker session leaked into orchestrator list: %#v", got) + } +} + func TestSessionsAPI_SendValidation(t *testing.T) { srv := newSessionTestServer(t, newFakeSessionService()) @@ -166,9 +268,55 @@ func TestSessionsAPI_SendValidation(t *testing.T) { assertErrorCode(t, body, status, http.StatusBadRequest, "MESSAGE_REQUIRED") } +func TestSessionsAPI_CleanupWithProjectFilter(t *testing.T) { + svc := newFakeSessionService() + svc.cleanupResult = []domain.SessionID{"ao-1"} + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/cleanup?project=ao", "") + if status != http.StatusOK { + t.Fatalf("cleanup = %d, want 200; body=%s", status, body) + } + var got struct { + OK bool `json:"ok"` + Cleaned []string `json:"cleaned"` + } + mustJSON(t, body, &got) + if !got.OK || len(got.Cleaned) != 1 || got.Cleaned[0] != "ao-1" { + t.Fatalf("cleanup response = %#v", got) + } + if len(svc.cleanupProjects) != 1 || svc.cleanupProjects[0] != "ao" { + t.Fatalf("cleanupProjects = %#v, want [ao]", svc.cleanupProjects) + } +} + +func TestSessionsAPI_CleanupWithoutProjectFilter(t *testing.T) { + svc := newFakeSessionService() + svc.cleanupResult = []domain.SessionID{"ao-1", "other-1"} + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/cleanup", "") + if status != http.StatusOK { + t.Fatalf("cleanup = %d, want 200; body=%s", status, body) + } + var got struct { + Cleaned []string `json:"cleaned"` + } + mustJSON(t, body, &got) + if len(got.Cleaned) != 2 || got.Cleaned[0] != "ao-1" || got.Cleaned[1] != "other-1" { + t.Fatalf("cleanup response = %#v", got) + } + if len(svc.cleanupProjects) != 1 || svc.cleanupProjects[0] != "" { + t.Fatalf("cleanupProjects = %#v, want empty project filter", svc.cleanupProjects) + } +} + type sessionBody struct { - ID string `json:"id"` - IssueID string `json:"issueId"` - Harness string `json:"harness"` - Status string `json:"status"` + ID string `json:"id"` + ProjectID string `json:"projectId"` + IssueID string `json:"issueId"` + Kind string `json:"kind"` + Harness string `json:"harness"` + DisplayName string `json:"displayName"` + Status string `json:"status"` } diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 03dedfad8..3dd0208ee 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -89,6 +89,9 @@ func TestManager_AddListGetRemove(t *testing.T) { } _, err = m.Get(ctx, "ao") wantCode(t, err, "PROJECT_NOT_FOUND") + + _, err = m.Remove(ctx, "ao") + wantCode(t, err, "PROJECT_NOT_FOUND") } func TestManager_ReaddAfterRemove(t *testing.T) { diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index fe2e63e7f..226de1d69 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -3,6 +3,8 @@ package session import ( "context" "fmt" + "strings" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -14,6 +16,7 @@ type Store interface { GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) + RenameSession(ctx context.Context, id domain.SessionID, displayName string, updatedAt time.Time) (bool, error) GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) } @@ -66,6 +69,22 @@ func (s *Service) Send(ctx context.Context, id domain.SessionID, message string) return s.manager.Send(ctx, id, message) } +// Rename updates the user-facing session display name. +func (s *Service) Rename(ctx context.Context, id domain.SessionID, displayName string) error { + displayName = strings.TrimSpace(displayName) + if displayName == "" { + return fmt.Errorf("rename %s: display name is required", id) + } + renamed, err := s.store.RenameSession(ctx, id, displayName, time.Now().UTC()) + if err != nil { + return fmt.Errorf("rename %s: %w", id, err) + } + if !renamed { + return fmt.Errorf("rename %s: %w", id, sessionmanager.ErrNotFound) + } + return nil +} + // Cleanup delegates terminal workspace cleanup to the internal manager. func (s *Service) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) { return s.manager.Cleanup(ctx, project) diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index 25c9610f6..682841ef0 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -2,10 +2,13 @@ package session import ( "context" + "errors" "fmt" "testing" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" ) type fakeStore struct { @@ -48,6 +51,17 @@ func (f *fakeStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, return out, nil } +func (f *fakeStore) RenameSession(_ context.Context, id domain.SessionID, displayName string, updatedAt time.Time) (bool, error) { + r, ok := f.sessions[id] + if !ok { + return false, nil + } + r.DisplayName = displayName + r.UpdatedAt = updatedAt + f.sessions[id] = r + return true, nil +} + func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) { pr, ok := f.pr[id] return pr, ok, nil @@ -66,3 +80,25 @@ func TestSessionListDerivesStatusFromPRFacts(t *testing.T) { t.Fatalf("got %+v", list) } } + +func TestSessionRenameUpdatesDisplayName(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer"} + + err := (&Service{store: st}).Rename(context.Background(), "mer-1", " Fix issue #90 ") + if err != nil { + t.Fatal(err) + } + if got := st.sessions["mer-1"].DisplayName; got != "Fix issue #90" { + t.Fatalf("display name = %q, want trimmed rename", got) + } +} + +func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) { + st := newFakeStore() + + err := (&Service{store: st}).Rename(context.Background(), "mer-404", "Missing") + if !errors.Is(err, sessionmanager.ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index ba9e84477..40b167660 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -47,6 +47,7 @@ type Store interface { CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) + ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) } // Manager coordinates internal session spawn, restore, kill, and cleanup over @@ -274,7 +275,7 @@ func (m *Manager) Send(ctx context.Context, id domain.SessionID, message string) // Cleanup reclaims the workspaces of terminal sessions in a project. A workspace // whose teardown is refused (uncommitted work) is skipped, never forced. func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) { - recs, err := m.store.ListSessions(ctx, project) + recs, err := m.cleanupRecords(ctx, project) if err != nil { return nil, fmt.Errorf("cleanup %s: %w", project, err) } @@ -298,6 +299,13 @@ func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) ([]doma return cleaned, nil } +func (m *Manager) cleanupRecords(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { + if project == "" { + return m.store.ListAllSessions(ctx) + } + return m.store.ListSessions(ctx, project) +} + // ---- helpers ---- func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index e65add746..32666b2f1 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -79,4 +79,5 @@ type Session struct { Prompt string CreatedAt time.Time UpdatedAt time.Time + DisplayName string } diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go index 89c99d1ea..dea720c65 100644 --- a/backend/internal/storage/sqlite/gen/projects.sql.go +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -14,7 +14,7 @@ import ( ) const archiveProject = `-- name: ArchiveProject :execrows -UPDATE projects SET archived_at = ? WHERE id = ? +UPDATE projects SET archived_at = ? WHERE id = ? AND archived_at IS NULL ` type ArchiveProjectParams struct { diff --git a/backend/internal/storage/sqlite/gen/sessions.sql.go b/backend/internal/storage/sqlite/gen/sessions.sql.go index fc1fa82bb..db18bfe14 100644 --- a/backend/internal/storage/sqlite/gen/sessions.sql.go +++ b/backend/internal/storage/sqlite/gen/sessions.sql.go @@ -15,7 +15,7 @@ import ( const getSession = `-- name: GetSession :one SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name FROM sessions WHERE id = ? ` @@ -39,17 +39,18 @@ func (q *Queries) GetSession(ctx context.Context, id domain.SessionID) (Session, &i.Prompt, &i.CreatedAt, &i.UpdatedAt, + &i.DisplayName, ) return i, err } const insertSession = `-- name: InsertSession :exec INSERT INTO sessions ( - id, project_id, num, issue_id, kind, harness, + id, project_id, num, issue_id, kind, harness, display_name, activity_state, activity_last_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` type InsertSessionParams struct { @@ -59,6 +60,7 @@ type InsertSessionParams struct { IssueID domain.IssueID Kind domain.SessionKind Harness domain.AgentHarness + DisplayName string ActivityState domain.ActivityState ActivityLastAt time.Time IsTerminated bool @@ -79,6 +81,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er arg.IssueID, arg.Kind, arg.Harness, + arg.DisplayName, arg.ActivityState, arg.ActivityLastAt, arg.IsTerminated, @@ -96,7 +99,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er const listAllSessions = `-- name: ListAllSessions :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name FROM sessions ORDER BY project_id, num ` @@ -126,6 +129,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { &i.Prompt, &i.CreatedAt, &i.UpdatedAt, + &i.DisplayName, ); err != nil { return nil, err } @@ -143,7 +147,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { const listSessionsByProject = `-- name: ListSessionsByProject :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name FROM sessions WHERE project_id = ? ORDER BY num ` @@ -173,6 +177,7 @@ func (q *Queries) ListSessionsByProject(ctx context.Context, projectID domain.Pr &i.Prompt, &i.CreatedAt, &i.UpdatedAt, + &i.DisplayName, ); err != nil { return nil, err } @@ -198,9 +203,27 @@ func (q *Queries) NextSessionNum(ctx context.Context, projectID domain.ProjectID return next, err } +const renameSession = `-- name: RenameSession :execrows +UPDATE sessions SET display_name = ?, updated_at = ? WHERE id = ? +` + +type RenameSessionParams struct { + DisplayName string + UpdatedAt time.Time + ID domain.SessionID +} + +func (q *Queries) RenameSession(ctx context.Context, arg RenameSessionParams) (int64, error) { + result, err := q.db.ExecContext(ctx, renameSession, arg.DisplayName, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const updateSession = `-- name: UpdateSession :exec UPDATE sessions SET - issue_id = ?, kind = ?, harness = ?, + issue_id = ?, kind = ?, harness = ?, display_name = ?, activity_state = ?, activity_last_at = ?, is_terminated = ?, branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?, updated_at = ? @@ -211,6 +234,7 @@ type UpdateSessionParams struct { IssueID domain.IssueID Kind domain.SessionKind Harness domain.AgentHarness + DisplayName string ActivityState domain.ActivityState ActivityLastAt time.Time IsTerminated bool @@ -228,6 +252,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) er arg.IssueID, arg.Kind, arg.Harness, + arg.DisplayName, arg.ActivityState, arg.ActivityLastAt, arg.IsTerminated, diff --git a/backend/internal/storage/sqlite/migrations/0003_add_session_display_name.sql b/backend/internal/storage/sqlite/migrations/0003_add_session_display_name.sql new file mode 100644 index 000000000..38a8183dc --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0003_add_session_display_name.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE sessions ADD COLUMN display_name TEXT NOT NULL DEFAULT ''; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE sessions DROP COLUMN display_name; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/queries/sessions.sql b/backend/internal/storage/sqlite/queries/sessions.sql index cec6ad366..9c3e1da78 100644 --- a/backend/internal/storage/sqlite/queries/sessions.sql +++ b/backend/internal/storage/sqlite/queries/sessions.sql @@ -3,15 +3,15 @@ SELECT COALESCE(MAX(num), 0) + 1 AS next FROM sessions WHERE project_id = ?; -- name: InsertSession :exec INSERT INTO sessions ( - id, project_id, num, issue_id, kind, harness, + id, project_id, num, issue_id, kind, harness, display_name, activity_state, activity_last_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateSession :exec UPDATE sessions SET - issue_id = ?, kind = ?, harness = ?, + issue_id = ?, kind = ?, harness = ?, display_name = ?, activity_state = ?, activity_last_at = ?, is_terminated = ?, branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?, updated_at = ? @@ -20,18 +20,21 @@ WHERE id = ?; -- name: GetSession :one SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name FROM sessions WHERE id = ?; -- name: ListSessionsByProject :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name FROM sessions WHERE project_id = ? ORDER BY num; -- name: ListAllSessions :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name FROM sessions ORDER BY project_id, num; + +-- name: RenameSession :execrows +UPDATE sessions SET display_name = ?, updated_at = ? WHERE id = ?; diff --git a/backend/internal/storage/sqlite/store/session_store.go b/backend/internal/storage/sqlite/store/session_store.go index 7c8596ffd..355b955f8 100644 --- a/backend/internal/storage/sqlite/store/session_store.go +++ b/backend/internal/storage/sqlite/store/session_store.go @@ -40,6 +40,22 @@ func (s *Store) UpdateSession(ctx context.Context, rec domain.SessionRecord) err return s.qw.UpdateSession(ctx, recordToUpdate(rec)) } +// RenameSession updates only the user-facing display name for an existing +// session. It returns ok=false when the session id does not exist. +func (s *Store) RenameSession(ctx context.Context, id domain.SessionID, displayName string, updatedAt time.Time) (bool, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + rows, err := s.qw.RenameSession(ctx, gen.RenameSessionParams{ + ID: id, + DisplayName: displayName, + UpdatedAt: updatedAt, + }) + if err != nil { + return false, fmt.Errorf("rename session %s: %w", id, err) + } + return rows > 0, nil +} + // GetSession returns the full record for a session, or ok=false if absent. func (s *Store) GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { row, err := s.qr.GetSession(ctx, id) @@ -80,11 +96,12 @@ func mapSessionRows(rows []gen.Session) []domain.SessionRecord { func rowToRecord(row gen.Session) domain.SessionRecord { return domain.SessionRecord{ - ID: row.ID, - ProjectID: row.ProjectID, - IssueID: row.IssueID, - Kind: row.Kind, - Harness: row.Harness, + ID: row.ID, + ProjectID: row.ProjectID, + IssueID: row.IssueID, + Kind: row.Kind, + Harness: row.Harness, + DisplayName: row.DisplayName, Activity: domain.Activity{ State: row.ActivityState, LastActivityAt: row.ActivityLastAt, @@ -111,6 +128,7 @@ func recordToInsert(rec domain.SessionRecord, num int64) gen.InsertSessionParams IssueID: rec.IssueID, Kind: rec.Kind, Harness: rec.Harness, + DisplayName: rec.DisplayName, ActivityState: activity.State, ActivityLastAt: activity.LastActivityAt, IsTerminated: rec.IsTerminated, @@ -131,6 +149,7 @@ func recordToUpdate(rec domain.SessionRecord) gen.UpdateSessionParams { IssueID: rec.IssueID, Kind: rec.Kind, Harness: rec.Harness, + DisplayName: rec.DisplayName, ActivityState: activity.State, ActivityLastAt: activity.LastActivityAt, IsTerminated: rec.IsTerminated, diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 7731e5ca3..62df84c0d 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -101,6 +101,31 @@ func TestSessionCreateAssignsPerProjectID(t *testing.T) { } } +func TestSessionRenameUpdatesDisplayName(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + + renamedAt := r.UpdatedAt.Add(time.Minute) + ok, err := s.RenameSession(ctx, r.ID, "Fix flaky tests", renamedAt) + if err != nil || !ok { + t.Fatalf("rename: ok=%v err=%v", ok, err) + } + got, _, _ := s.GetSession(ctx, r.ID) + if got.DisplayName != "Fix flaky tests" || !got.UpdatedAt.Equal(renamedAt) { + t.Fatalf("rename not persisted: %+v", got) + } + + ok, err = s.RenameSession(ctx, "mer-missing", "Missing", renamedAt) + if err != nil { + t.Fatalf("rename missing: %v", err) + } + if ok { + t.Fatal("rename missing ok=true, want false") + } +} + func TestSessionUpdateActivityAndTermination(t *testing.T) { s := newTestStore(t) ctx := context.Background()