agent-orchestrator/backend/internal/cli/session.go

803 lines
22 KiB
Go

package cli
import (
"bufio"
"context"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
)
type sessionOptions struct {
project string
json bool
}
type sessionListOptions struct {
sessionOptions
all bool
includeTerminated bool
}
type sessionCleanupOptions struct {
project string
yes bool
}
type sessionClaimPROptions struct {
project string
json bool
noTakeover 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"`
UpdatedAt time.Time `json:"updatedAt"`
Status string `json:"status"`
}
type sessionActivity struct {
State string `json:"state"`
LastActivityAt time.Time `json:"lastActivityAt"`
}
type sessionListResponse struct {
Sessions []sessionDTO `json:"sessions"`
}
type sessionResponse struct {
Session sessionDTO `json:"session"`
}
type killSessionResponse struct {
SessionID string `json:"sessionId"`
Freed bool `json:"freed"`
}
type restoreSessionResponse struct {
SessionID string `json:"sessionId"`
Session sessionDTO `json:"session"`
}
type renameSessionResponse struct {
SessionID string `json:"sessionId"`
DisplayName string `json:"displayName"`
}
type cleanupSkippedSession struct {
SessionID string `json:"sessionId"`
Reason string `json:"reason"`
}
type cleanupSessionsResponse struct {
Cleaned []string `json:"cleaned"`
Skipped []cleanupSkippedSession `json:"skipped"`
}
type claimPRRequest struct {
PR string `json:"pr"`
AllowTakeover bool `json:"allowTakeover"`
}
type sessionPRDTO struct {
URL string `json:"url"`
Number int `json:"number"`
State string `json:"state"`
CI string `json:"ci"`
Review string `json:"review"`
Mergeability string `json:"mergeability"`
ReviewComments bool `json:"reviewComments"`
UpdatedAt time.Time `json:"updatedAt"`
}
type claimPRResponse struct {
OK bool `json:"ok"`
SessionID string `json:"sessionId"`
PRs []sessionPRDTO `json:"prs"`
BranchChanged bool `json:"branchChanged"`
TakenOverFrom []string `json:"takenOverFrom"`
}
type sessionListEntry struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Role string `json:"role"`
Status string `json:"status,omitempty"`
IssueID string `json:"issueId,omitempty"`
Harness string `json:"harness,omitempty"`
IsTerminated bool `json:"isTerminated"`
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type sessionListOutput struct {
Data []sessionListEntry `json:"data"`
Meta struct {
HiddenTerminatedCount int `json:"hiddenTerminatedCount"`
} `json:"meta"`
}
func newSessionCommand(ctx *commandContext) *cobra.Command {
cmd := &cobra.Command{
Use: "session",
Short: "Manage agent sessions",
}
cmd.AddCommand(newSessionListCommand(ctx))
cmd.AddCommand(newSessionGetCommand(ctx))
cmd.AddCommand(newSessionKillCommand(ctx))
cmd.AddCommand(newSessionRestoreCommand(ctx))
cmd.AddCommand(newSessionRenameCommand(ctx))
cmd.AddCommand(newSessionCleanupCommand(ctx))
cmd.AddCommand(newSessionClaimPRCommand(ctx))
return cmd
}
func newSessionListCommand(ctx *commandContext) *cobra.Command {
var opts sessionListOptions
cmd := &cobra.Command{
Use: "ls",
Short: "List sessions",
Args: noArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return ctx.listSessions(cmd.Context(), cmd, opts)
},
}
f := cmd.Flags()
addSessionProjectFlag(f, &opts.project, "Filter by project ID")
f.BoolVarP(&opts.all, "all", "a", false, "Include orchestrator sessions")
f.BoolVar(&opts.includeTerminated, "include-terminated", false, "Include terminated sessions")
f.BoolVar(&opts.json, "json", false, "Output as JSON")
return cmd
}
func newSessionGetCommand(ctx *commandContext) *cobra.Command {
var opts sessionOptions
cmd := &cobra.Command{
Use: "get <id>",
Short: "Fetch one session",
Args: oneSessionIDArg,
RunE: func(cmd *cobra.Command, args []string) error {
id, err := normalizeSessionID(args[0])
if err != nil {
return err
}
return ctx.getSession(cmd.Context(), cmd, id, opts)
},
}
f := cmd.Flags()
addSessionProjectFlag(f, &opts.project, "Project id to scope the lookup")
f.BoolVar(&opts.json, "json", false, "Output as JSON")
return cmd
}
func newSessionKillCommand(ctx *commandContext) *cobra.Command {
var opts sessionOptions
cmd := &cobra.Command{
Use: "kill <id>",
Short: "Terminate a session",
Args: oneSessionIDArg,
RunE: func(cmd *cobra.Command, args []string) error {
id, err := normalizeSessionID(args[0])
if err != nil {
return err
}
return ctx.killSession(cmd.Context(), cmd, id, opts)
},
}
addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup")
return cmd
}
func newSessionRestoreCommand(ctx *commandContext) *cobra.Command {
var opts sessionOptions
cmd := &cobra.Command{
Use: "restore <id>",
Short: "Relaunch a terminated session",
Args: oneSessionIDArg,
RunE: func(cmd *cobra.Command, args []string) error {
id, err := normalizeSessionID(args[0])
if err != nil {
return err
}
return ctx.restoreSession(cmd.Context(), cmd, id, opts)
},
}
addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup")
return cmd
}
func newSessionRenameCommand(ctx *commandContext) *cobra.Command {
var opts sessionOptions
cmd := &cobra.Command{
Use: "rename <id> <name>",
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 newSessionClaimPRCommand(ctx *commandContext) *cobra.Command {
var opts sessionClaimPROptions
cmd := &cobra.Command{
Use: "claim-pr <session-id> <pr-ref>",
Short: "Attach an existing PR to a session",
Args: func(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
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
id, err := normalizeSessionID(args[0])
if err != nil {
return err
}
return ctx.claimSessionPR(cmd.Context(), cmd, id, args[1], opts)
},
}
addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup")
cmd.Flags().BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the PR")
cmd.Flags().BoolVar(&opts.json, "json", false, "Output as JSON")
return cmd
}
func addSessionProjectFlag(flags interface {
StringVarP(*string, string, string, string, string)
}, target *string, usage string) {
flags.StringVarP(target, "project", "p", "", usage)
}
func oneSessionIDArg(cmd *cobra.Command, args []string) error {
if err := cobra.ExactArgs(1)(cmd, args); err != nil {
return usageError{err}
}
if _, err := normalizeSessionID(args[0]); err != nil {
return err
}
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) claimSessionPR(ctx context.Context, cmd *cobra.Command, id, ref string, opts sessionClaimPROptions) error {
sess, err := c.fetchScopedSession(ctx, id, opts.project)
if err != nil {
return err
}
project, err := c.fetchProjectDetails(ctx, sess.ProjectID)
if err != nil {
return err
}
resolvedRef, err := c.resolvePRRef(ctx, ref, project)
if err != nil {
return err
}
var res claimPRResponse
req := claimPRRequest{PR: resolvedRef, AllowTakeover: !opts.noTakeover}
if err := c.postJSON(ctx, "sessions/"+url.PathEscape(id)+"/pr/claim", req, &res); err != nil {
return err
}
if opts.json {
return writeJSON(cmd.OutOrStdout(), res)
}
return writeClaimPRResult(cmd, res)
}
func (c *commandContext) fetchProjectDetails(ctx context.Context, id string) (projectDetails, error) {
var res projectGetResult
if err := c.getJSON(ctx, "projects/"+url.PathEscape(id), &res); err != nil {
return projectDetails{}, err
}
return res.Project, nil
}
func writeClaimPRResult(cmd *cobra.Command, res claimPRResponse) error {
out := cmd.OutOrStdout()
if len(res.PRs) == 0 {
_, err := fmt.Fprintf(out, "session %s claimed PR\n", res.SessionID)
return err
}
pr := res.PRs[0]
if _, err := fmt.Fprintf(out, "session %s claimed PR #%d\n", res.SessionID, pr.Number); err != nil {
return err
}
if _, err := fmt.Fprintf(out, " pr: %s\n", pr.URL); err != nil {
return err
}
checkout := "already on PR branch"
if res.BranchChanged {
checkout = "switched to PR branch"
}
if _, err := fmt.Fprintf(out, " checkout: %s\n", checkout); err != nil {
return err
}
for _, owner := range res.TakenOverFrom {
if _, err := fmt.Fprintf(out, " taking over from %s\n", owner); err != nil {
return err
}
}
return nil
}
func (c *commandContext) listSessions(ctx context.Context, cmd *cobra.Command, opts sessionListOptions) error {
params := url.Values{}
if opts.project != "" {
params.Set("project", opts.project)
}
if !opts.includeTerminated {
params.Set("active", "true")
}
var res sessionListResponse
if err := c.getJSON(ctx, apiPath("sessions", params), &res); err != nil {
return err
}
sessions := filterAndSortSessions(res.Sessions, opts.all)
hiddenTerminatedCount := 0
if !opts.includeTerminated {
count, err := c.countHiddenTerminated(ctx, opts.project, opts.all)
if err != nil {
return err
}
hiddenTerminatedCount = count
}
if opts.json {
out := sessionListOutput{Data: sessionListEntries(sessions)}
out.Meta.HiddenTerminatedCount = hiddenTerminatedCount
return writeJSON(cmd.OutOrStdout(), out)
}
return writeSessionList(cmd, sessions, hiddenTerminatedCount)
}
func (c *commandContext) countHiddenTerminated(ctx context.Context, project string, includeOrchestrators bool) (int, error) {
params := url.Values{}
if project != "" {
params.Set("project", project)
}
params.Set("active", "false")
var res sessionListResponse
if err := c.getJSON(ctx, apiPath("sessions", params), &res); err != nil {
return 0, err
}
return len(filterAndSortSessions(res.Sessions, includeOrchestrators)), nil
}
func (c *commandContext) getSession(ctx context.Context, cmd *cobra.Command, id string, opts sessionOptions) error {
sess, err := c.fetchScopedSession(ctx, id, opts.project)
if err != nil {
return err
}
if opts.json {
return writeJSON(cmd.OutOrStdout(), sessionResponse{Session: sess})
}
return writeSessionDetails(cmd, sess)
}
func (c *commandContext) killSession(ctx context.Context, cmd *cobra.Command, id string, opts sessionOptions) error {
if opts.project != "" {
if _, err := c.fetchScopedSession(ctx, id, opts.project); err != nil {
return err
}
}
var res killSessionResponse
if err := c.postJSON(ctx, "sessions/"+url.PathEscape(id)+"/kill", struct{}{}, &res); err != nil {
return err
}
if res.Freed {
_, err := fmt.Fprintf(cmd.OutOrStdout(), "session %s killed\n", res.SessionID)
return err
}
// freed=false: the workspace was preserved (e.g. uncommitted changes) — the
// session is terminated either way, but the worktree is left for inspection.
_, err := fmt.Fprintf(cmd.OutOrStdout(), "session %s killed (workspace preserved)\n", res.SessionID)
return err
}
func (c *commandContext) restoreSession(ctx context.Context, cmd *cobra.Command, id string, opts sessionOptions) error {
if opts.project != "" {
if _, err := c.fetchScopedSession(ctx, id, opts.project); err != nil {
return err
}
}
var res restoreSessionResponse
if err := c.postJSON(ctx, "sessions/"+url.PathEscape(id)+"/restore", struct{}{}, &res); err != nil {
return err
}
out := cmd.OutOrStdout()
if _, err := fmt.Fprintf(out, "session %s restored\n", res.SessionID); err != nil {
return err
}
if res.Session.ProjectID != "" {
if _, err := fmt.Fprintf(out, " project: %s\n", res.Session.ProjectID); err != nil {
return err
}
}
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
}
}
for _, skip := range res.Skipped {
label := skip.SessionID
if mapped := labelByID[skip.SessionID]; mapped != "" {
label = mapped
}
if _, err := fmt.Fprintf(out, " Skipped: %s (%s)\n", label, skip.Reason); err != nil {
return err
}
}
summary := fmt.Sprintf("\nCleanup complete. %d session%s cleaned", len(cleaned), pluralS(len(cleaned)))
if len(res.Skipped) > 0 {
summary += fmt.Sprintf(", %d skipped", len(res.Skipped))
}
_, err = fmt.Fprintf(out, "%s.\n", summary)
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 {
return sessionDTO{}, err
}
if project != "" && res.Session.ProjectID != project {
return sessionDTO{}, usageError{fmt.Errorf("session %s is not in project %s", id, project)}
}
return res.Session, nil
}
func filterAndSortSessions(sessions []sessionDTO, includeOrchestrators bool) []sessionDTO {
out := make([]sessionDTO, 0, len(sessions))
for _, sess := range sessions {
if !includeOrchestrators && 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 sessionListEntries(sessions []sessionDTO) []sessionListEntry {
entries := make([]sessionListEntry, 0, len(sessions))
for _, sess := range sessions {
var last *time.Time
if !sess.Activity.LastActivityAt.IsZero() {
activity := sess.Activity.LastActivityAt
last = &activity
}
entries = append(entries, sessionListEntry{
ID: sess.ID,
ProjectID: sess.ProjectID,
Role: sessionRole(sess),
Status: sess.Status,
IssueID: sess.IssueID,
Harness: sess.Harness,
IsTerminated: sess.IsTerminated,
LastActivityAt: last,
CreatedAt: sess.CreatedAt,
UpdatedAt: sess.UpdatedAt,
})
}
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 {
if _, err := fmt.Fprintln(out, "(no active sessions)"); err != nil {
return err
}
} else {
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 := sessionLineParts(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
}
}
}
if hiddenTerminatedCount > 0 {
_, err := fmt.Fprintf(out, "%d terminated session%s hidden. Use --include-terminated to show.\n", hiddenTerminatedCount, pluralS(hiddenTerminatedCount))
return err
}
return nil
}
func sessionLineParts(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.Kind != "" {
parts = append(parts, sess.Kind)
}
if sess.IssueID != "" {
parts = append(parts, sess.IssueID)
}
return parts
}
func writeSessionDetails(cmd *cobra.Command, sess sessionDTO) error {
out := cmd.OutOrStdout()
fields := [][2]string{
{"id", sess.ID},
{"project", sess.ProjectID},
{"name", sess.DisplayName},
{"role", sessionRole(sess)},
{"status", sess.Status},
{"activity", sess.Activity.State},
{"harness", sess.Harness},
{"issue", sess.IssueID},
{"terminated", fmt.Sprintf("%t", sess.IsTerminated)},
}
for _, field := range fields {
if field[1] == "" {
continue
}
if _, err := fmt.Fprintf(out, "%s: %s\n", field[0], field[1]); err != nil {
return err
}
}
if !sess.CreatedAt.IsZero() {
if _, err := fmt.Fprintf(out, "created: %s\n", sess.CreatedAt.Format(time.RFC3339)); err != nil {
return err
}
}
if !sess.UpdatedAt.IsZero() {
if _, err := fmt.Fprintf(out, "updated: %s\n", sess.UpdatedAt.Format(time.RFC3339)); err != nil {
return err
}
}
return nil
}
func sessionRole(sess sessionDTO) string {
if sess.Kind == "orchestrator" {
return "orchestrator"
}
return "worker"
}
func formatSessionAge(d time.Duration) string {
if d < 0 {
d = 0
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh", int(d.Hours()))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
func pluralS(n int) string {
if n == 1 {
return ""
}
return "s"
}
func apiPath(path string, params url.Values) string {
if len(params) == 0 {
return path
}
return path + "?" + params.Encode()
}
func normalizeSessionID(id string) (string, error) {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return "", usageError{errors.New("session id is required")}
}
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
}