Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion internal/api/core/session_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/api/core/session_workspace_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"`
Expand Down
8 changes: 8 additions & 0 deletions internal/cli/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions internal/cli/open.go
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +33 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and show the relevant portion of internal/cli/open.go
sed -n '1,120p' internal/cli/open.go | nl -ba | sed -n '1,120p'

# Show surrounding lines around the cited location (lines 25-60)
sed -n '20,70p' internal/cli/open.go | nl -ba

# Find where status.HTTPHost/HTTPPort are defined and what types they are
rg -n "HTTPHost|HTTPPort" internal | head -n 50

# Print the definitions for HTTPHost/HTTPPort if found
rg -n "HTTPHost" -S internal --glob='*.go' | head -n 20

# Search for URL formatting patterns elsewhere for consistency
rg -n "http://%s:%d|JoinHostPort\\(|url\\.URL|http://\\s*\\+" internal/cli internal | head -n 50

Repository: compozy/agh

Length of output: 98


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== internal/cli/open.go (top) =="
cat -n internal/cli/open.go | head -n 160

echo "== internal/cli/open.go (around lines ~20-70) =="
sed -n '20,70p' internal/cli/open.go | cat -n

echo "== occurrences of HTTPHost / HTTPPort =="
rg -n "HTTPHost|HTTPPort" internal | head -n 80

echo "== definitions (HTTPHost token) =="
rg -n "HTTPHost" -S internal --glob='*.go' | head -n 80

echo "== URL formatting patterns elsewhere =="
rg -n "http://%s:%d|JoinHostPort\\(|url\\.URL|http://\\s*\\+" internal/cli internal | head -n 120

Repository: compozy/agh

Length of output: 14010


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== internal/daemon/settings.go around status HTTPHost/HTTPPort =="
sed -n '120,190p' internal/daemon/settings.go | cat -n

echo "== internal/api/core/status.go around HTTPHost/HTTPPort =="
sed -n '150,240p' internal/api/core/status.go | cat -n

echo "== internal/settings/models.go HTTPHost/HTTPPort fields =="
rg -n "type .*HTTPHost|HTTPHost[[:space:]]+string|HTTPPort[[:space:]]+int" internal/settings/models.go
sed -n '420,470p' internal/settings/models.go | cat -n

echo "== open.go current imports =="
sed -n '1,25p' internal/cli/open.go | cat -n

Repository: compozy/agh

Length of output: 7934


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find where HTTP.Host is set/validated (both config defaults and parsing)
rg -n "config\\.HTTP\\.Host|HTTP\\.Host|http\\.host|Host:\\s*.*HTTP" internal --glob='*.go' | head -n 120

# Specifically look for any bracket/IPv6 normalization around HTTP.Host
rg -n "JoinHostPort\\(|\\[.*\\]|IPv6|To16\\(|IsIP\\(|HasPrefix\\(\"\\[\"\\)" internal --glob='*.go' | head -n 120

# Show likely config HTTP host field usages
rg -n "HTTP\\.(Host|Port)" internal --glob='*.go' | head -n 120

# Also check contract fields: how daemon status HTTPHost is serialized
rg -n "http_host" -S internal --glob='*.go' | head -n 120

Repository: compozy/agh

Length of output: 29399


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== internal/config/config.go around http.host validation =="
# Show around the reported line number 1216 for "http.host is required"
sed -n '1180,1260p' internal/config/config.go | cat -n

echo "== internal/api/httpapi/server.go around host normalization/binding =="
sed -n '500,620p' internal/api/httpapi/server.go | cat -n

echo "== internal/api/httpapi/handlers.go around boundHost assignment =="
sed -n '60,130p' internal/api/httpapi/handlers.go | cat -n

echo "== internal/api/httpapi/helpers_test.go mustURL for URL patterns =="
sed -n '680,760p' internal/api/httpapi/helpers_test.go | cat -n

Repository: compozy/agh

Length of output: 13295


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "serverLocalhostKey" internal/api/httpapi/server.go internal/api/httpapi/handlers.go internal/api/httpapi/*.go
rg -n "HTTP\\.Host\\s*=|http\\.host" internal/config/config.go internal/config/*.go internal/settings/*.go | head -n 120
rg -n "JoinHostPort\\(|\\bParseIP\\(|\\bIsIP\\(|\\[::|\\[.*\\]" internal/api/httpapi internal/config internal/settings internal/daemon internal/cli --glob='*.go' | head -n 200

Repository: compozy/agh

Length of output: 19569


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '460,560p' internal/daemon/settings.go | cat -n

Repository: compozy/agh

Length of output: 4228


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "\\[::|::1|IPv6" internal --glob='*.go' | head -n 200
rg -n "Trim\\(.*\"\\[\\]\"|Trim\\(.*\"\\[\\[\\]|strings\\.Trim\\(.*\"\\[\\]\"|Trim\\(.*\\[\\]|\"\\[\\]\"" internal --glob='*.go' | head -n 200
rg -n "Trim\\(\\s*strings\\.TrimSpace\\(.*\\),\\s*\"\\[\\]\"|Trim\\(\\s*strings\\.TrimSpace\\(.*\\),\\s*\"\\[\\]\"" internal --glob='*.go' | head -n 200

rg -n "net\\.JoinHostPort\\(|address := net\\.JoinHostPort" internal/api/httpapi/server.go internal/daemon/settings.go internal/cli/open.go | head -n 200

# Look for any status->URL formatting helper besides open.go
rg -n "http://\\%s:%d|http://\\s*\\+|url\\.URL\\{.*Host" internal/cli --glob='*.go' | head -n 200

Repository: compozy/agh

Length of output: 2652


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== internal/api/httpapi/server.go around address := net.JoinHostPort =="
sed -n '620,710p' internal/api/httpapi/server.go | cat -n

echo "== internal/api/httpapi/middleware.go around host normalization (strings.Trim(..., \"[]\")) =="
sed -n '180,280p' internal/api/httpapi/middleware.go | cat -n

echo "== internal/testutil/e2e/runtime_harness_test.go IPv6 loopback HTTP cases =="
sed -n '80,140p' internal/testutil/e2e/runtime_harness_test.go | cat -n

echo "== config HTTPConfig definition (type HTTPConfig) =="
rg -n "type HTTPConfig" internal/config --glob='*.go'

Repository: compozy/agh

Length of output: 9019


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "type HTTPConfig struct|HTTPConfig\\)|HTTPConfig\\b" internal/config/config.go
sed -n '70,140p' internal/config/config.go | cat -n
rg -n "Trim\\(.*Host.*\"\\[\\]\"|Trim\\(.*\"\\[\\]\".*Host|strings\\.Trim\\(.*host.*\"\\[\\]\"|strings\\.Trim\\(.*Host.*\"\\[\\]\"" internal/config --glob='*.go' | head -n 200
rg -n "http\\.host" internal/config --glob='*.go' | head -n 200

Repository: compozy/agh

Length of output: 3135


Build the open URL with net.JoinHostPort to handle IPv6 correctly.

internal/cli/open.go currently formats the address as http://%s:%d from status.HTTPHost/status.HTTPPort; for IPv6 like ::1 this produces an invalid URL. Construct the host:port portion with net.JoinHostPort (or url.URL) before calling openBrowser.

Suggested patch
 import (
 	"context"
 	"errors"
 	"fmt"
+	"net"
 	"os/exec"
 	"runtime"
+	"strconv"

 	"github.com/spf13/cobra"
 )
@@
-			url := fmt.Sprintf("http://%s:%d", status.HTTPHost, status.HTTPPort)
+			url := "http://" + net.JoinHostPort(status.HTTPHost, strconv.Itoa(status.HTTPPort))
 			return openBrowser(ctx, url)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/open.go` around lines 33 - 34, The URL is built with
fmt.Sprintf("http://%s:%d", status.HTTPHost, status.HTTPPort) which breaks for
IPv6; build the host:port using net.JoinHostPort(status.HTTPHost,
strconv.Itoa(status.HTTPPort)) (or construct a url.URL and set Host via
net.JoinHostPort) and then prepend the scheme before calling openBrowser(ctx,
url); update the code around the URL construction in internal/cli/open.go where
status.HTTPHost/status.HTTPPort are used and pass the corrected url string to
openBrowser.

},
}
}

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()
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions internal/cli/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -225,6 +226,33 @@ func newSessionStopCommand(deps commandDeps) *cobra.Command {
}
}

func newSessionRemoveCommand(deps commandDeps) *cobra.Command {
return &cobra.Command{
Use: "remove <id>",
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 <id>",
Expand Down
44 changes: 44 additions & 0 deletions internal/cli/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestSessionStatusReturnsHealthStatus(t *testing.T) {
t.Parallel()

Expand Down
65 changes: 56 additions & 9 deletions internal/store/globaldb/global_db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1549,7 +1549,7 @@ func TestGlobalDBWorkspaceCRUDAndLookups(t *testing.T) {
}
}

func TestGlobalDBDeleteWorkspaceReturnsHasSessionsWhenReferenced(t *testing.T) {
func TestGlobalDBDeleteWorkspaceCascadeDeletesStoppedSessions(t *testing.T) {
t.Parallel()

globalDB := openTestGlobalDB(t)
Expand All @@ -1563,21 +1563,68 @@ 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),
}); err != nil {
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)
}
}

Expand Down
70 changes: 62 additions & 8 deletions internal/store/globaldb/global_db_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading