Files
kandev/apps/backend/internal/task/handlers/executor_handlers_test.go
T
João Salavisa 481359ddb2 feat(auth): organization units, tenancy, roles and scopes for team access (#2919)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:40:43 +00:00

628 lines
22 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/kandev/kandev/internal/task/repository"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agentruntime"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service"
ws "github.com/kandev/kandev/pkg/websocket"
)
// executorRepo backs both the executor and executor-profile handler tests.
type executorRepo struct {
// Membership is not exercised by this fake; the embedded default
// reports no membership, which is the narrower answer.
repository.UnsupportedWorkspaceMembers
mockRepository
executors []*models.Executor
byID map[string]*models.Executor
profiles []*models.ExecutorProfile
// profilesByExecutor answers ListExecutorProfiles(executorID).
profilesByExecutor map[string][]*models.ExecutorProfile
profileByID map[string]*models.ExecutorProfile
listErr error
listProfilesErr error
createErr error
updateErr error
deleteErr error
hasActiveSessions bool
running []*models.ExecutorRunning
created []*models.Executor
updated []*models.Executor
deleted []string
createdProfile []*models.ExecutorProfile
updatedProfile []*models.ExecutorProfile
deletedProfile []string
}
func (r *executorRepo) ListExecutors(context.Context) ([]*models.Executor, error) {
if r.listErr != nil {
return nil, r.listErr
}
return r.executors, nil
}
func (r *executorRepo) GetExecutor(_ context.Context, id string) (*models.Executor, error) {
if executor, ok := r.byID[id]; ok {
return executor, nil
}
return nil, fmt.Errorf("executor not found: %s", id)
}
func (r *executorRepo) CreateExecutor(_ context.Context, executor *models.Executor) error {
if r.createErr != nil {
return r.createErr
}
r.created = append(r.created, executor)
return nil
}
func (r *executorRepo) UpdateExecutor(_ context.Context, executor *models.Executor) error {
if r.updateErr != nil {
return r.updateErr
}
r.updated = append(r.updated, executor)
return nil
}
func (r *executorRepo) DeleteExecutor(_ context.Context, id string) error {
if r.deleteErr != nil {
return r.deleteErr
}
r.deleted = append(r.deleted, id)
return nil
}
func (r *executorRepo) HasActiveTaskSessionsByExecutor(context.Context, string) (bool, error) {
return r.hasActiveSessions, nil
}
func (r *executorRepo) ListExecutorsRunning(context.Context) ([]*models.ExecutorRunning, error) {
return r.running, nil
}
func (r *executorRepo) ListAllExecutorProfiles(context.Context) ([]*models.ExecutorProfile, error) {
if r.listProfilesErr != nil {
return nil, r.listProfilesErr
}
return r.profiles, nil
}
func (r *executorRepo) ListExecutorProfiles(_ context.Context, executorID string) ([]*models.ExecutorProfile, error) {
if r.listProfilesErr != nil {
return nil, r.listProfilesErr
}
return r.profilesByExecutor[executorID], nil
}
func (r *executorRepo) GetExecutorProfile(_ context.Context, id string) (*models.ExecutorProfile, error) {
if profile, ok := r.profileByID[id]; ok {
return profile, nil
}
return nil, fmt.Errorf("executor profile not found: %s", id)
}
func (r *executorRepo) CreateExecutorProfile(_ context.Context, profile *models.ExecutorProfile) error {
if r.createErr != nil {
return r.createErr
}
r.createdProfile = append(r.createdProfile, profile)
return nil
}
func (r *executorRepo) UpdateExecutorProfile(_ context.Context, profile *models.ExecutorProfile) error {
if r.updateErr != nil {
return r.updateErr
}
r.updatedProfile = append(r.updatedProfile, profile)
return nil
}
func (r *executorRepo) DeleteExecutorProfile(_ context.Context, id string) error {
if r.deleteErr != nil {
return r.deleteErr
}
r.deletedProfile = append(r.deletedProfile, id)
return nil
}
func newExecutorService(t *testing.T, repo *executorRepo) *service.Service {
t.Helper()
log := newTestLogger(t)
return service.NewService(service.Repos{
Workspaces: repo, Tasks: repo, TaskRepos: repo,
Workflows: repo, Messages: repo, Turns: repo,
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
Executors: repo, Environments: repo, TaskEnvironments: repo,
Reviews: repo,
}, nil, log, service.RepositoryDiscoveryConfig{})
}
func newExecutorHandlers(t *testing.T, repo *executorRepo) *ExecutorHandlers {
t.Helper()
return NewExecutorHandlers(newExecutorService(t, repo), newTestLogger(t))
}
// executorFixture holds two executors, one of them carrying a profile, so the
// list handler's profile-grouping has something to get wrong.
func executorFixture() *executorRepo {
docker := &models.Executor{ID: "ex-docker", Name: "Docker", Type: models.ExecutorTypeLocalDocker, IsSystem: true}
local := &models.Executor{ID: "ex-local", Name: "Local", Type: models.ExecutorTypeLocal}
profile := &models.ExecutorProfile{ID: "pr-1", ExecutorID: "ex-docker", Name: "Default"}
return &executorRepo{
executors: []*models.Executor{docker, local},
byID: map[string]*models.Executor{"ex-docker": docker, "ex-local": local},
profiles: []*models.ExecutorProfile{profile},
profilesByExecutor: map[string][]*models.ExecutorProfile{
"ex-docker": {profile},
},
profileByID: map[string]*models.ExecutorProfile{"pr-1": profile},
}
}
func decodeExecutorList(t *testing.T, body []byte) dto.ListExecutorsResponse {
t.Helper()
var resp dto.ListExecutorsResponse
require.NoError(t, json.Unmarshal(body, &resp))
return resp
}
func TestHTTPListExecutorsAttachesProfilesToTheirExecutor(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodGet, "/api/v1/executors", "")
h.httpListExecutors(c)
require.Equal(t, http.StatusOK, rec.Code)
resp := decodeExecutorList(t, rec.Body.Bytes())
require.Equal(t, 2, resp.Total)
require.Len(t, resp.Executors, 2)
require.Equal(t, "ex-docker", resp.Executors[0].ID)
require.Len(t, resp.Executors[0].Profiles, 1)
require.Equal(t, "pr-1", resp.Executors[0].Profiles[0].ID)
require.Empty(t, resp.Executors[1].Profiles, "an executor without profiles must not inherit another's")
}
func TestHTTPListExecutorsReturnsInternalErrorWhenListFails(t *testing.T) {
repo := executorFixture()
repo.listErr = errors.New("database unavailable")
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodGet, "/api/v1/executors", "")
h.httpListExecutors(c)
require.Equal(t, http.StatusInternalServerError, rec.Code)
require.JSONEq(t, `{"error":"Failed to list executors"}`, rec.Body.String())
}
// TestHTTPListExecutorsToleratesProfileLookupFailure documents that the profile
// read is best-effort: a failure there must still return the executors rather
// than blanking the settings page.
func TestHTTPListExecutorsToleratesProfileLookupFailure(t *testing.T) {
repo := executorFixture()
repo.listProfilesErr = errors.New("profiles unavailable")
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodGet, "/api/v1/executors", "")
h.httpListExecutors(c)
require.Equal(t, http.StatusOK, rec.Code)
resp := decodeExecutorList(t, rec.Body.Bytes())
require.Len(t, resp.Executors, 2)
require.Empty(t, resp.Executors[0].Profiles)
}
func TestHTTPCreateExecutorRejectsInvalidRequests(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
for name, tc := range map[string]struct {
body string
wantBody string
}{
"malformed json": {body: `{`, wantBody: `{"error":"invalid payload"}`},
"missing name": {body: `{"type":"local"}`, wantBody: `{"error":"name and type are required"}`},
"missing type": {body: `{"name":"Local"}`, wantBody: `{"error":"name and type are required"}`},
} {
t.Run(name, func(t *testing.T) {
c, rec := newWorkflowRequest(t, http.MethodPost, "/api/v1/executors", tc.body)
h.httpCreateExecutor(c)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.JSONEq(t, tc.wantBody, rec.Body.String())
})
}
require.Empty(t, repo.created)
}
// TestHTTPCreateExecutorDefaultsStatusAndResumable pins the two implicit
// defaults: an omitted status becomes "active" and an omitted resumable
// becomes true (not the Go zero value false).
func TestHTTPCreateExecutorDefaultsStatusAndResumable(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodPost, "/api/v1/executors",
`{"name":"New","type":"local"}`)
h.httpCreateExecutor(c)
require.Equal(t, http.StatusOK, rec.Code)
require.Len(t, repo.created, 1)
require.Equal(t, models.ExecutorStatusActive, repo.created[0].Status)
require.True(t, repo.created[0].Resumable)
var got dto.ExecutorDTO
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, "New", got.Name)
require.Equal(t, models.ExecutorTypeLocal, got.Type)
require.True(t, got.Resumable)
}
func TestHTTPCreateExecutorHonorsExplicitResumableFalse(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodPost, "/api/v1/executors",
`{"name":"New","type":"local","status":"inactive","resumable":false}`)
h.httpCreateExecutor(c)
require.Equal(t, http.StatusOK, rec.Code)
require.Len(t, repo.created, 1)
require.False(t, repo.created[0].Resumable)
require.Equal(t, models.ExecutorStatus("inactive"), repo.created[0].Status)
}
func TestHTTPCreateExecutorRejectsInvalidMCPPolicy(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodPost, "/api/v1/executors",
`{"name":"New","type":"local","config":{"mcp_policy":"not-json"}}`)
h.httpCreateExecutor(c)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Contains(t, rec.Body.String(), service.ErrInvalidExecutorConfig.Error())
require.Empty(t, repo.created)
}
func TestHTTPCreateExecutorSurfacesRepositoryFailure(t *testing.T) {
repo := executorFixture()
repo.createErr = errors.New("disk full")
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodPost, "/api/v1/executors",
`{"name":"New","type":"local"}`)
h.httpCreateExecutor(c)
require.Equal(t, http.StatusInternalServerError, rec.Code)
require.JSONEq(t, `{"error":"executor not created"}`, rec.Body.String())
}
func TestHTTPGetExecutorReturnsExecutorOrNotFound(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodGet, "/api/v1/executors/ex-local", "")
c.Params = gin.Params{{Key: "id", Value: "ex-local"}}
h.httpGetExecutor(c)
require.Equal(t, http.StatusOK, rec.Code)
var got dto.ExecutorDTO
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, "ex-local", got.ID)
require.Equal(t, "Local", got.Name)
missingCtx, missingRec := newWorkflowRequest(t, http.MethodGet, "/api/v1/executors/nope", "")
missingCtx.Params = gin.Params{{Key: "id", Value: "nope"}}
h.httpGetExecutor(missingCtx)
require.Equal(t, http.StatusNotFound, missingRec.Code)
require.JSONEq(t, `{"error":"executor not found"}`, missingRec.Body.String())
}
func TestHTTPUpdateExecutorAppliesProvidedFieldsOnly(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodPatch, "/api/v1/executors/ex-local",
`{"name":"Renamed","resumable":false}`)
c.Params = gin.Params{{Key: "id", Value: "ex-local"}}
h.httpUpdateExecutor(c)
require.Equal(t, http.StatusOK, rec.Code)
require.Len(t, repo.updated, 1)
require.Equal(t, "Renamed", repo.updated[0].Name)
require.False(t, repo.updated[0].Resumable)
require.Equal(t, models.ExecutorTypeLocal, repo.updated[0].Type, "an omitted field must be left alone")
}
func TestHTTPUpdateExecutorRejectsInvalidPayload(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodPatch, "/api/v1/executors/ex-local", `{`)
c.Params = gin.Params{{Key: "id", Value: "ex-local"}}
h.httpUpdateExecutor(c)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.JSONEq(t, `{"error":"invalid payload"}`, rec.Body.String())
require.Empty(t, repo.updated)
}
func TestHTTPUpdateExecutorRejectsInvalidMCPPolicy(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodPatch, "/api/v1/executors/ex-local",
`{"config":{"mcp_policy":"[1,2]"}}`)
c.Params = gin.Params{{Key: "id", Value: "ex-local"}}
h.httpUpdateExecutor(c)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Contains(t, rec.Body.String(), "mcp_policy must be a JSON object")
require.Empty(t, repo.updated)
}
func TestHTTPUpdateExecutorReturnsConflictForRetainedKubernetesInventory(t *testing.T) {
repo := kubernetesHandlerFixture()
repo.running = []*models.ExecutorRunning{{
SessionID: "session-retained", TaskID: "task-retained",
ExecutorID: "ex-kubernetes", Runtime: agentruntime.RuntimeKubernetes,
}}
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodPatch, "/api/v1/executors/ex-kubernetes", `{"type":"local"}`)
c.Params = gin.Params{{Key: "id", Value: "ex-kubernetes"}}
c.Request = c.Request.WithContext(kubernetesAdminContext())
h.httpUpdateExecutor(c)
require.Equal(t, http.StatusConflict, rec.Code)
require.JSONEq(t, `{"error":"executor is used by an active agent session"}`, rec.Body.String())
require.Empty(t, repo.updated)
}
// TestHTTPUpdateExecutorRefusesToRenameSystemExecutor covers the service-side
// system guard reaching the handler as a plain 500 rather than a 400 — the
// error is not an ErrInvalidExecutorConfig, so it takes the generic branch.
func TestHTTPUpdateExecutorRefusesToRenameSystemExecutor(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodPatch, "/api/v1/executors/ex-docker",
`{"name":"Renamed"}`)
c.Params = gin.Params{{Key: "id", Value: "ex-docker"}}
h.httpUpdateExecutor(c)
require.Equal(t, http.StatusInternalServerError, rec.Code)
require.JSONEq(t, `{"error":"executor not updated"}`, rec.Body.String())
require.Empty(t, repo.updated, "a system executor must not be modified")
}
func TestHTTPDeleteExecutorDeletesAndReportsConflicts(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodDelete, "/api/v1/executors/ex-local", "")
c.Params = gin.Params{{Key: "id", Value: "ex-local"}}
h.httpDeleteExecutor(c)
require.Equal(t, http.StatusOK, rec.Code)
require.JSONEq(t, `{"success":true}`, rec.Body.String())
require.Equal(t, []string{"ex-local"}, repo.deleted)
}
func TestHTTPDeleteExecutorReturnsConflictWhenSessionsAreActive(t *testing.T) {
repo := executorFixture()
repo.hasActiveSessions = true
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodDelete, "/api/v1/executors/ex-local", "")
c.Params = gin.Params{{Key: "id", Value: "ex-local"}}
h.httpDeleteExecutor(c)
require.Equal(t, http.StatusConflict, rec.Code)
require.JSONEq(t, `{"error":"executor is used by an active agent session"}`, rec.Body.String())
require.Empty(t, repo.deleted, "an in-use executor must survive the request")
}
func TestHTTPDeleteExecutorReturnsBadRequestForUnknownExecutor(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
c, rec := newWorkflowRequest(t, http.MethodDelete, "/api/v1/executors/nope", "")
c.Params = gin.Params{{Key: "id", Value: "nope"}}
h.httpDeleteExecutor(c)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.JSONEq(t, `{"error":"executor not deleted"}`, rec.Body.String())
}
func TestWSListExecutorsReturnsExecutorsWithProfiles(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
resp, err := h.wsListExecutors(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorList, map[string]any{}))
require.NoError(t, err)
var list dto.ListExecutorsResponse
wsWorkflowResponse(t, resp, &list)
require.Equal(t, 2, list.Total)
require.Len(t, list.Executors[0].Profiles, 1)
}
func TestWSListExecutorsSurfacesRepositoryFailure(t *testing.T) {
repo := executorFixture()
repo.listErr = errors.New("database unavailable")
h := newExecutorHandlers(t, repo)
resp, err := h.wsListExecutors(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorList, map[string]any{}))
require.NoError(t, err)
payload := wsWorkflowError(t, resp)
require.Equal(t, string(ws.ErrorCodeInternalError), payload.Code)
require.Equal(t, "Failed to list executors", payload.Message)
}
func TestWSCreateExecutorValidatesAndCreates(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
invalid, err := h.wsCreateExecutor(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorCreate, map[string]any{"name": "New"}))
require.NoError(t, err)
require.Equal(t, "name and type are required", wsWorkflowError(t, invalid).Message)
malformed, err := h.wsCreateExecutor(context.Background(), wsMalformedRequest(ws.ActionExecutorCreate))
require.NoError(t, err)
require.Equal(t, string(ws.ErrorCodeBadRequest), wsWorkflowError(t, malformed).Code)
require.Empty(t, repo.created)
resp, err := h.wsCreateExecutor(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorCreate, map[string]any{"name": "New", "type": "local"}))
require.NoError(t, err)
var created dto.ExecutorDTO
wsWorkflowResponse(t, resp, &created)
require.Equal(t, "New", created.Name)
require.True(t, created.Resumable, "resumable defaults to true over the WS surface too")
require.Len(t, repo.created, 1)
}
func TestWSCreateExecutorRejectsInvalidMCPPolicy(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
resp, err := h.wsCreateExecutor(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorCreate, map[string]any{
"name": "New", "type": "local", "config": map[string]string{"mcp_policy": "nope"},
}))
require.NoError(t, err)
payload := wsWorkflowError(t, resp)
require.Equal(t, string(ws.ErrorCodeValidation), payload.Code)
require.Equal(t, service.ErrInvalidExecutorConfig.Error(), payload.Message)
require.Empty(t, repo.created)
}
func TestWSGetExecutorReturnsExecutorOrErrors(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
resp, err := h.wsGetExecutor(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorGet, map[string]any{"id": "ex-local"}))
require.NoError(t, err)
var got dto.ExecutorDTO
wsWorkflowResponse(t, resp, &got)
require.Equal(t, "ex-local", got.ID)
missing, err := h.wsGetExecutor(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorGet, map[string]any{"id": "nope"}))
require.NoError(t, err)
require.Equal(t, string(ws.ErrorCodeNotFound), wsWorkflowError(t, missing).Code)
empty, err := h.wsGetExecutor(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorGet, map[string]any{}))
require.NoError(t, err)
require.Equal(t, "id is required", wsWorkflowError(t, empty).Message)
}
func TestWSUpdateExecutorAppliesFields(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
resp, err := h.wsUpdateExecutor(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorUpdate, map[string]any{
"id": "ex-local", "name": "Renamed", "status": "inactive",
}))
require.NoError(t, err)
var updated dto.ExecutorDTO
wsWorkflowResponse(t, resp, &updated)
require.Equal(t, "Renamed", updated.Name)
require.Equal(t, models.ExecutorStatus("inactive"), updated.Status)
require.Len(t, repo.updated, 1)
}
func TestWSUpdateExecutorRequiresID(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
resp, err := h.wsUpdateExecutor(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorUpdate, map[string]any{"name": "Renamed"}))
require.NoError(t, err)
require.Equal(t, "id is required", wsWorkflowError(t, resp).Message)
require.Empty(t, repo.updated)
}
func TestWSUpdateExecutorSurfacesRepositoryFailure(t *testing.T) {
repo := executorFixture()
repo.updateErr = errors.New("disk full")
h := newExecutorHandlers(t, repo)
resp, err := h.wsUpdateExecutor(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorUpdate, map[string]any{"id": "ex-local", "name": "Renamed"}))
require.NoError(t, err)
payload := wsWorkflowError(t, resp)
require.Equal(t, string(ws.ErrorCodeInternalError), payload.Code)
require.Equal(t, "Failed to update executor", payload.Message)
}
func TestWSUpdateExecutorReturnsValidationForRetainedKubernetesInventory(t *testing.T) {
repo := kubernetesHandlerFixture()
repo.running = []*models.ExecutorRunning{{
SessionID: "session-retained", TaskID: "task-retained",
ExecutorID: "ex-kubernetes", Runtime: agentruntime.RuntimeKubernetes,
}}
h := newExecutorHandlers(t, repo)
resp, err := h.wsUpdateExecutor(kubernetesAdminContext(),
wsWorkflowRequest(t, ws.ActionExecutorUpdate, map[string]any{
"id": "ex-kubernetes", "type": string(models.ExecutorTypeLocal),
}))
require.NoError(t, err)
payload := wsWorkflowError(t, resp)
require.Equal(t, string(ws.ErrorCodeValidation), payload.Code)
require.Equal(t, "executor is used by an active agent session", payload.Message)
require.Empty(t, repo.updated)
}
func TestWSDeleteExecutorDeletesAndReportsActiveSessions(t *testing.T) {
repo := executorFixture()
h := newExecutorHandlers(t, repo)
resp, err := h.wsDeleteExecutor(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorDelete, map[string]any{"id": "ex-local"}))
require.NoError(t, err)
var success dto.SuccessResponse
wsWorkflowResponse(t, resp, &success)
require.True(t, success.Success)
require.Equal(t, []string{"ex-local"}, repo.deleted)
busy := executorFixture()
busy.hasActiveSessions = true
busyHandlers := newExecutorHandlers(t, busy)
blocked, err := busyHandlers.wsDeleteExecutor(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorDelete, map[string]any{"id": "ex-local"}))
require.NoError(t, err)
payload := wsWorkflowError(t, blocked)
require.Equal(t, string(ws.ErrorCodeValidation), payload.Code)
require.Equal(t, "executor is used by an active agent session", payload.Message)
require.Empty(t, busy.deleted)
}