diff --git a/internal/api/core/session_workspace.go b/internal/api/core/session_workspace.go index 8f159ed2e..3b9031cbf 100644 --- a/internal/api/core/session_workspace.go +++ b/internal/api/core/session_workspace.go @@ -137,7 +137,8 @@ func statusForWorkspaceError(err error) int { return http.StatusGone case errors.Is(err, workspacepkg.ErrWorkspaceNameTaken), errors.Is(err, workspacepkg.ErrWorkspacePathTaken), - errors.Is(err, workspacepkg.ErrWorkspaceHasSessions): + errors.Is(err, workspacepkg.ErrWorkspaceHasSessions), + errors.Is(err, workspacepkg.ErrWorkspaceHasActiveSessions): return http.StatusConflict case errors.Is(err, workspacepkg.ErrWorkspaceResolverUnavailable): return http.StatusServiceUnavailable diff --git a/internal/api/core/session_workspace_internal_test.go b/internal/api/core/session_workspace_internal_test.go index 035b4b53c..8259cb98f 100644 --- a/internal/api/core/session_workspace_internal_test.go +++ b/internal/api/core/session_workspace_internal_test.go @@ -149,6 +149,9 @@ func TestSessionWorkspaceStatusMappings(t *testing.T) { if got := statusForWorkspaceError(workspacepkg.ErrWorkspaceHasSessions); got != http.StatusConflict { t.Fatalf("statusForWorkspaceError(has sessions) = %d, want %d", got, http.StatusConflict) } + if got := statusForWorkspaceError(workspacepkg.ErrWorkspaceHasActiveSessions); got != http.StatusConflict { + t.Fatalf("statusForWorkspaceError(has active sessions) = %d, want %d", got, http.StatusConflict) + } if got := statusForWorkspaceError( workspacepkg.ErrWorkspaceResolverUnavailable, ); got != http.StatusServiceUnavailable { diff --git a/internal/cli/client.go b/internal/cli/client.go index eb32e081f..74dfda449 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -178,6 +178,7 @@ type DaemonClient interface { InspectSession(ctx context.Context, id string, query SessionInspectQuery) (SessionInspectRecord, error) RefreshSessionSoul(ctx context.Context, id string, request SessionSoulRefreshRequest) (AgentSoulRecord, error) StopSession(ctx context.Context, id string) error + DeleteSession(ctx context.Context, id string) error ResumeSession(ctx context.Context, id string) (SessionRecord, error) SessionRecap(ctx context.Context, id string, limit int) (SessionRecapRecord, error) RepairSession(ctx context.Context, id string, query SessionRepairQuery) (SessionRepairRecord, error) @@ -2622,6 +2623,21 @@ func (c *unixSocketClient) StopSession(ctx context.Context, id string) error { ) } +func (c *unixSocketClient) DeleteSession(ctx context.Context, id string) error { + path, err := c.sessionScopedPath(ctx, id, "") + if err != nil { + return err + } + return c.doJSON( + ctx, + http.MethodDelete, + path, + nil, + nil, + nil, + ) +} + func (c *unixSocketClient) ResumeSession(ctx context.Context, id string) (SessionRecord, error) { var response struct { Session SessionRecord `json:"session"` diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index 893bc4dee..d2312e7ab 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -101,6 +101,7 @@ type stubClient struct { inspectSessionFn func(context.Context, string, SessionInspectQuery) (SessionInspectRecord, error) refreshSessionSoulFn func(context.Context, string, SessionSoulRefreshRequest) (AgentSoulRecord, error) stopSessionFn func(context.Context, string) error + deleteSessionFn func(context.Context, string) error resumeSessionFn func(context.Context, string) (SessionRecord, error) sessionRecapFn func(context.Context, string, int) (SessionRecapRecord, error) repairSessionFn func(context.Context, string, SessionRepairQuery) (SessionRepairRecord, error) @@ -1030,6 +1031,13 @@ func (s *stubClient) StopSession(ctx context.Context, id string) error { return errors.New("unexpected StopSession call") } +func (s *stubClient) DeleteSession(ctx context.Context, id string) error { + if s.deleteSessionFn != nil { + return s.deleteSessionFn(ctx, id) + } + return errors.New("unexpected DeleteSession call") +} + func (s *stubClient) ResumeSession(ctx context.Context, id string) (SessionRecord, error) { if s.resumeSessionFn != nil { return s.resumeSessionFn(ctx, id) diff --git a/internal/cli/open.go b/internal/cli/open.go new file mode 100644 index 000000000..b9f4582d0 --- /dev/null +++ b/internal/cli/open.go @@ -0,0 +1,50 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os/exec" + "runtime" + + "github.com/spf13/cobra" +) + +func newOpenCommand(deps commandDeps) *cobra.Command { + return &cobra.Command{ + Use: "open", + Short: "Open the AGH web UI in the default browser", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + client, err := clientFromDeps(deps) + if err != nil { + return err + } + + status, err := client.DaemonStatus(ctx) + if err != nil { + return fmt.Errorf("open: daemon is not running: %w", err) + } + if status.HTTPHost == "" || status.HTTPPort == 0 { + return errors.New("open: daemon did not report a valid HTTP address") + } + + url := fmt.Sprintf("http://%s:%d", status.HTTPHost, status.HTTPPort) + return openBrowser(ctx, url) + }, + } +} + +func openBrowser(ctx context.Context, url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.CommandContext(ctx, "open", url) + case "windows": + cmd = exec.CommandContext(ctx, "cmd", "/c", "start", url) + default: + cmd = exec.CommandContext(ctx, "xdg-open", url) + } + return cmd.Start() +} diff --git a/internal/cli/root.go b/internal/cli/root.go index dc907517e..3df0463b6 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -142,6 +142,7 @@ func newRootCommand(deps commandDeps) *cobra.Command { cmd.AddCommand(newMCPCommand(deps)) cmd.AddCommand(newLogsCommand(deps)) cmd.AddCommand(newWhoamiCommand(deps)) + cmd.AddCommand(newOpenCommand(deps)) cmd.AddCommand(newDocCommand()) return cmd diff --git a/internal/cli/session.go b/internal/cli/session.go index 3cfcf20bd..7d7d6215e 100644 --- a/internal/cli/session.go +++ b/internal/cli/session.go @@ -62,6 +62,7 @@ func newSessionCommand(deps commandDeps) *cobra.Command { cmd.AddCommand(newSessionCreateCommand(deps)) cmd.AddCommand(newSessionListCommand(deps)) cmd.AddCommand(newSessionStopCommand(deps)) + cmd.AddCommand(newSessionRemoveCommand(deps)) cmd.AddCommand(newSessionSoulCommand(deps)) cmd.AddCommand(newSessionHealthCommand(deps)) cmd.AddCommand(newSessionStatusCommand(deps)) @@ -225,6 +226,33 @@ func newSessionStopCommand(deps commandDeps) *cobra.Command { } } +func newSessionRemoveCommand(deps commandDeps) *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove a session and its persisted history", + Example: ` # Remove a stopped session + agh session remove sess_1234 + + # Remove an active session (stops it first) + agh session remove sess_1234`, + Args: exactOneNonBlankArg(), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := clientFromDeps(deps) + if err != nil { + return err + } + info, err := client.GetSession(cmd.Context(), args[0]) + if err != nil { + return err + } + if err := client.DeleteSession(cmd.Context(), args[0]); err != nil { + return err + } + return writeCommandOutput(cmd, sessionBundle(info, deps.now)) + }, + } +} + func newSessionStatusCommand(deps commandDeps) *cobra.Command { return &cobra.Command{ Use: "status ", diff --git a/internal/cli/session_test.go b/internal/cli/session_test.go index 3f055f046..48677fcd8 100644 --- a/internal/cli/session_test.go +++ b/internal/cli/session_test.go @@ -613,6 +613,50 @@ func TestSessionStopFetchesUpdatedSession(t *testing.T) { } } +func TestSessionRemoveDeletesSession(t *testing.T) { + t.Parallel() + + t.Run("Should delete session and return session record", func(t *testing.T) { + t.Parallel() + + var deletedID string + + deps := newTestDeps(t, &stubClient{ + getSessionFn: func(_ context.Context, id string) (SessionRecord, error) { + return SessionRecord{ + ID: id, + AgentName: "coder", + WorkspaceID: "ws-1", + WorkspacePath: "/workspace/project", + State: session.StateStopped, + CreatedAt: fixedTestNow, + UpdatedAt: fixedTestNow, + }, nil + }, + deleteSessionFn: func(_ context.Context, id string) error { + deletedID = id + return nil + }, + }) + + stdout, _, err := executeRootCommand(t, deps, "session", "remove", "sess-1", "-o", "json") + if err != nil { + t.Fatalf("executeRootCommand() error = %v", err) + } + if deletedID != "sess-1" { + t.Fatalf("DeleteSession() id = %q, want %q", deletedID, "sess-1") + } + + var decoded SessionRecord + if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if decoded.ID != "sess-1" { + t.Fatalf("decoded.ID = %q, want %q", decoded.ID, "sess-1") + } + }) +} + func TestSessionStatusReturnsHealthStatus(t *testing.T) { t.Parallel() diff --git a/internal/store/globaldb/global_db_test.go b/internal/store/globaldb/global_db_test.go index 4c6c9f57e..81460371e 100644 --- a/internal/store/globaldb/global_db_test.go +++ b/internal/store/globaldb/global_db_test.go @@ -1549,7 +1549,7 @@ func TestGlobalDBWorkspaceCRUDAndLookups(t *testing.T) { } } -func TestGlobalDBDeleteWorkspaceReturnsHasSessionsWhenReferenced(t *testing.T) { +func TestGlobalDBDeleteWorkspaceCascadeDeletesStoppedSessions(t *testing.T) { t.Parallel() globalDB := openTestGlobalDB(t) @@ -1563,6 +1563,58 @@ func TestGlobalDBDeleteWorkspaceReturnsHasSessionsWhenReferenced(t *testing.T) { ID: "sess-delete-guard", AgentName: "coder", WorkspaceID: workspaceID, + State: "stopped", + CreatedAt: time.Date(2026, 4, 3, 14, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 3, 14, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatalf("RegisterSession() error = %v", err) + } + + if err := globalDB.DeleteWorkspace(testutil.Context(t), workspaceID); err != nil { + t.Fatalf("DeleteWorkspace() error = %v, want nil", err) + } + + sessions, err := globalDB.ListSessions(testutil.Context(t), SessionListQuery{ + WorkspaceID: workspaceID, + }) + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if len(sessions) != 0 { + t.Fatalf("ListSessions() = %d sessions, want 0 (cascade delete)", len(sessions)) + } +} + +func TestGlobalDBDeleteWorkspaceWithoutSessions(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + workspaceID := registerWorkspaceForGlobalTests( + t, + globalDB, + "ws-no-sessions", + filepath.Join(t.TempDir(), "ws-no-sessions"), + ) + + if err := globalDB.DeleteWorkspace(testutil.Context(t), workspaceID); err != nil { + t.Fatalf("DeleteWorkspace() error = %v, want nil", err) + } +} + +func TestGlobalDBDeleteWorkspaceRejectsActiveSessions(t *testing.T) { + t.Parallel() + + globalDB := openTestGlobalDB(t) + workspaceID := registerWorkspaceForGlobalTests( + t, + globalDB, + "ws-active-sessions", + filepath.Join(t.TempDir(), "ws-active-sessions"), + ) + if err := globalDB.RegisterSession(testutil.Context(t), SessionInfo{ + ID: "sess-active-guard", + AgentName: "coder", + WorkspaceID: workspaceID, State: "active", CreatedAt: time.Date(2026, 4, 3, 14, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 4, 3, 14, 0, 0, 0, time.UTC), @@ -1570,14 +1622,9 @@ func TestGlobalDBDeleteWorkspaceReturnsHasSessionsWhenReferenced(t *testing.T) { t.Fatalf("RegisterSession() error = %v", err) } - if err := globalDB.DeleteWorkspace( - testutil.Context(t), - workspaceID, - ); !errors.Is( - err, - aghworkspace.ErrWorkspaceHasSessions, - ) { - t.Fatalf("DeleteWorkspace() error = %v, want ErrWorkspaceHasSessions", err) + err := globalDB.DeleteWorkspace(testutil.Context(t), workspaceID) + if !errors.Is(err, aghworkspace.ErrWorkspaceHasActiveSessions) { + t.Fatalf("DeleteWorkspace() error = %v, want ErrWorkspaceHasActiveSessions", err) } } diff --git a/internal/store/globaldb/global_db_workspace.go b/internal/store/globaldb/global_db_workspace.go index b6626a598..145501e15 100644 --- a/internal/store/globaldb/global_db_workspace.go +++ b/internal/store/globaldb/global_db_workspace.go @@ -83,6 +83,8 @@ func (g *GlobalDB) UpdateWorkspace(ctx context.Context, ws aghworkspace.Workspac } // DeleteWorkspace removes a persisted workspace registration row. +// It refuses to delete if any active sessions reference the workspace. +// Stopped or orphaned sessions are cleaned up automatically before deletion. func (g *GlobalDB) DeleteWorkspace(ctx context.Context, id string) error { if err := g.checkReady(ctx, "delete workspace"); err != nil { return err @@ -93,20 +95,72 @@ func (g *GlobalDB) DeleteWorkspace(ctx context.Context, id string) error { return errors.New("store: workspace id is required") } - result, err := g.db.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, trimmedID) + return store.ExecuteWrite(ctx, g.db, func(ctx context.Context, tx *store.WriteTx) error { + activeSessions, err := g.listActiveSessionIDsByWorkspace(ctx, tx, trimmedID) + if err != nil { + return err + } + if len(activeSessions) > 0 { + return fmt.Errorf( + "store: delete workspace %q: %w: %s", + trimmedID, + aghworkspace.ErrWorkspaceHasActiveSessions, + strings.Join(activeSessions, ", "), + ) + } + + if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE workspace_id = ?`, trimmedID); err != nil { + return fmt.Errorf("store: delete stopped sessions for workspace %q: %w", trimmedID, err) + } + + result, err := tx.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, trimmedID) + if err != nil { + return fmt.Errorf("store: delete workspace %q: %w", trimmedID, mapWorkspaceConstraintError(err)) + } + + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("store: rows affected for workspace %q: %w", trimmedID, err) + } + if affected == 0 { + return fmt.Errorf("store: workspace %q: %w", trimmedID, aghworkspace.ErrWorkspaceNotFound) + } + + return nil + }) +} + +func (g *GlobalDB) listActiveSessionIDsByWorkspace( + ctx context.Context, + tx *store.WriteTx, + workspaceID string, +) ([]string, error) { + rows, err := tx.QueryContext( + ctx, + `SELECT id FROM sessions WHERE workspace_id = ? AND state = 'active'`, + workspaceID, + ) if err != nil { - return fmt.Errorf("store: delete workspace %q: %w", trimmedID, mapWorkspaceConstraintError(err)) + return nil, fmt.Errorf("store: list active sessions for workspace %q: %w", workspaceID, err) } + // rows.Close error is not actionable here: any real failure is already + // captured by rows.Err() below, and the caller cannot recover from a + // close-only error on a read-only result set. + defer func() { _ = rows.Close() }() - affected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("store: rows affected for workspace %q: %w", trimmedID, err) + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("store: scan active session id for workspace %q: %w", workspaceID, err) + } + ids = append(ids, id) } - if affected == 0 { - return fmt.Errorf("store: workspace %q: %w", trimmedID, aghworkspace.ErrWorkspaceNotFound) + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate active sessions for workspace %q: %w", workspaceID, err) } - return nil + return ids, nil } // GetWorkspace loads a workspace registration by primary key. diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index b4a7357f7..eabab4c8e 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -26,6 +26,8 @@ var ( ErrWorkspacePathTaken = errors.New("workspace path already registered") // ErrWorkspaceHasSessions reports that a workspace cannot be deleted because sessions still reference it. ErrWorkspaceHasSessions = errors.New("workspace has sessions") + // ErrWorkspaceHasActiveSessions reports that a workspace cannot be deleted because active sessions are running. + ErrWorkspaceHasActiveSessions = errors.New("workspace has active sessions") // ErrWorkspaceIdentityInvalid reports a malformed .agh/workspace.toml identity file. ErrWorkspaceIdentityInvalid = errors.New("workspace identity invalid") // ErrWorkspaceIdentityPermissionDenied reports a fail-closed identity file permission failure. diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index e1e0c703b..0709e9272 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -25,6 +25,7 @@ func TestWorkspaceErrorsMatchViaErrorsIs(t *testing.T) { {name: "name taken", sentinel: workspace.ErrWorkspaceNameTaken}, {name: "path taken", sentinel: workspace.ErrWorkspacePathTaken}, {name: "has sessions", sentinel: workspace.ErrWorkspaceHasSessions}, + {name: "has active sessions", sentinel: workspace.ErrWorkspaceHasActiveSessions}, } for _, tt := range tests { @@ -67,6 +68,11 @@ func TestWorkspaceErrorsAreDistinct(t *testing.T) { left: workspace.ErrWorkspacePathTaken, want: workspace.ErrWorkspaceHasSessions, }, + { + name: "has sessions does not match has active sessions", + left: workspace.ErrWorkspaceHasSessions, + want: workspace.ErrWorkspaceHasActiveSessions, + }, } for _, tt := range tests {