Skip to content

chore: sort AI tasks by starting/running then created date #18704

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
38 changes: 30 additions & 8 deletions coderd/database/dbmem/dbmem.go
Original file line number Diff line number Diff line change
Expand Up @@ -13637,6 +13637,10 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
return job.CompletedAt.Valid && !job.CanceledAt.Valid && !job.Error.Valid && build.Transition == database.WorkspaceTransitionStart
}

isStarted := func(build database.WorkspaceBuild, job database.ProvisionerJob) bool {
return !job.CanceledAt.Valid && !job.Error.Valid && build.Transition == database.WorkspaceTransitionStart
}

preloadedWorkspaceBuilds := map[uuid.UUID]database.WorkspaceBuild{}
preloadedProvisionerJobs := map[uuid.UUID]database.ProvisionerJob{}
preloadedUsers := map[uuid.UUID]database.User{}
Expand Down Expand Up @@ -13676,16 +13680,34 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
return false
}

// Order by: running
w1IsRunning := isRunning(preloadedWorkspaceBuilds[w1.ID], preloadedProvisionerJobs[w1.ID])
w2IsRunning := isRunning(preloadedWorkspaceBuilds[w2.ID], preloadedProvisionerJobs[w2.ID])
// For tasks, order anything starting or running first, and then by created
// date.
if arg.HasAITask.Bool {
w1IsStarted := isStarted(preloadedWorkspaceBuilds[w1.ID], preloadedProvisionerJobs[w1.ID])
w2IsStarted := isStarted(preloadedWorkspaceBuilds[w2.ID], preloadedProvisionerJobs[w2.ID])
if w1IsStarted && !w2IsStarted {
return true
}

if w1IsRunning && !w2IsRunning {
return true
}
if !w1IsStarted && w2IsStarted {
return false
}

if !w1IsRunning && w2IsRunning {
return false
if w1.CreatedAt.After(w2.CreatedAt) {
return true
}
} else {
// Order by: running
w1IsRunning := isRunning(preloadedWorkspaceBuilds[w1.ID], preloadedProvisionerJobs[w1.ID])
w2IsRunning := isRunning(preloadedWorkspaceBuilds[w2.ID], preloadedProvisionerJobs[w2.ID])

if w1IsRunning && !w2IsRunning {
return true
}

if !w1IsRunning && w2IsRunning {
return false
}
}

// Order by: usernames
Expand Down
18 changes: 14 additions & 4 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 14 additions & 4 deletions coderd/database/queries/workspaces.sql
Original file line number Diff line number Diff line change
Expand Up @@ -380,11 +380,21 @@ WHERE
ORDER BY
-- To ensure that 'favorite' workspaces show up first in the list only for their owner.
CASE WHEN owner_id = @requester_id AND favorite THEN 0 ELSE 1 END ASC,
(latest_build_completed_at IS NOT NULL AND
latest_build_canceled_at IS NULL AND
latest_build_error IS NULL AND
latest_build_transition = 'start'::workspace_transition) DESC,
-- For AI tasks, put anything running or starting first. Otherwise put
-- running only first.
CASE WHEN sqlc.narg('has_ai_task') :: boolean = true THEN
(latest_build_canceled_at IS NULL AND
latest_build_error IS NULL AND
latest_build_transition = 'start'::workspace_transition)
ELSE
(latest_build_completed_at IS NOT NULL AND
latest_build_canceled_at IS NULL AND
latest_build_error IS NULL AND
latest_build_transition = 'start'::workspace_transition)
END DESC,
LOWER(owner_username) ASC,
-- AI tasks are additionally sorted by creation date before the name.
CASE WHEN sqlc.narg('has_ai_task') :: boolean = true THEN created_at END DESC,
LOWER(name) ASC
LIMIT
CASE
Expand Down
88 changes: 60 additions & 28 deletions coderd/workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4508,17 +4508,30 @@ func TestWorkspaceFilterHasAITask(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)

// Helper function to create workspace with AI task configuration
createWorkspaceWithAIConfig := func(hasAITask sql.NullBool, jobCompleted bool, aiTaskPrompt *string) database.WorkspaceTable {
createWorkspaceWithAIConfig := func(hasAITask sql.NullBool, jobCompleted bool, aiTaskPrompt *string, conf *database.WorkspaceTable) database.WorkspaceTable {
// When a provisioner job uses these tags, no provisioner will match it.
// We do this so jobs will always be stuck in "pending", allowing us to exercise the intermediary state when
// has_ai_task is nil and we compensate by looking at pending provisioning jobs.
// See GetWorkspaces clauses.
unpickableTags := database.StringMap{"custom": "true"}

ws := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.UserID,
OrganizationID: user.OrganizationID,
TemplateID: template.ID,
OwnerID: user.UserID,
OrganizationID: user.OrganizationID,
TemplateID: template.ID,
ID: conf.ID,
CreatedAt: conf.CreatedAt,
UpdatedAt: conf.UpdatedAt,
Deleted: conf.Deleted,
Name: conf.Name,
AutostartSchedule: conf.AutostartSchedule,
Ttl: conf.Ttl,
LastUsedAt: conf.LastUsedAt,
DormantAt: conf.DormantAt,
DeletingAt: conf.DeletingAt,
AutomaticUpdates: conf.AutomaticUpdates,
Favorite: conf.Favorite,
NextStartAt: conf.NextStartAt,
})

jobConfig := database.ProvisionerJob{
Expand Down Expand Up @@ -4563,18 +4576,49 @@ func TestWorkspaceFilterHasAITask(t *testing.T) {
return ws
}

expectWorkspaces := func(workspaces []codersdk.Workspace, order []uuid.UUID) {
ids := make([]uuid.UUID, len(workspaces))
for i, ws := range workspaces {
t.Logf("Workspace %d: ID=%s, Name=%s, Status=%s", i, ws.ID, ws.Name, ws.LatestBuild.Status)
ids[i] = ws.ID
}
t.Logf("Expected IDs: %s", order)
require.Len(t, workspaces, len(order))
require.Equal(t, order, ids)
}

// Create test workspaces with different AI task configurations
wsWithAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: true, Valid: true}, true, nil)
wsWithoutAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: false, Valid: true}, false, nil)
wsWithAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: true, Valid: true}, true, nil, &database.WorkspaceTable{
Name: "alpha",
CreatedAt: time.Now().Add(-30 * time.Minute),
})
wsWithoutAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: false, Valid: true}, false, nil, &database.WorkspaceTable{
Name: "beta",
CreatedAt: time.Now().Add(-20 * time.Minute),
})

aiTaskPrompt := "Build me a web app"
wsWithAITaskParam := createWorkspaceWithAIConfig(sql.NullBool{Valid: false}, false, &aiTaskPrompt)
wsWithAITaskParam := createWorkspaceWithAIConfig(sql.NullBool{Valid: false}, false, &aiTaskPrompt, &database.WorkspaceTable{
Name: "gamma",
CreatedAt: time.Now().Add(-10 * time.Minute),
})

anotherTaskPrompt := "Another task"
wsCompletedWithAITaskParam := createWorkspaceWithAIConfig(sql.NullBool{Valid: false}, true, &anotherTaskPrompt)
wsCompletedWithAITaskParam := createWorkspaceWithAIConfig(sql.NullBool{Valid: false}, true, &anotherTaskPrompt, &database.WorkspaceTable{
Name: "delta",
CreatedAt: time.Now().Add(-5 * time.Minute),
})

emptyPrompt := ""
wsWithEmptyAITaskParam := createWorkspaceWithAIConfig(sql.NullBool{Valid: false}, false, &emptyPrompt)
wsWithEmptyAITaskParam := createWorkspaceWithAIConfig(sql.NullBool{Valid: false}, false, &emptyPrompt, &database.WorkspaceTable{
Name: "epsilon",
CreatedAt: time.Now(),
})

// Expected orders.
orderByWithAITask := []uuid.UUID{wsWithAITaskParam.ID, wsWithAITask.ID}
orderByWithoutAITask := []uuid.UUID{wsCompletedWithAITaskParam.ID, wsWithoutAITask.ID, wsWithEmptyAITaskParam.ID}
orderByAll := []uuid.UUID{wsWithAITask.ID, wsCompletedWithAITaskParam.ID, wsWithoutAITask.ID, wsWithEmptyAITaskParam.ID, wsWithAITaskParam.ID}

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
Expand All @@ -4594,14 +4638,8 @@ func TestWorkspaceFilterHasAITask(t *testing.T) {
})
require.NoError(t, err)
t.Logf("Expected 2 workspaces for has-ai-task:true, got %d", len(res.Workspaces))
t.Logf("Expected workspaces: %s, %s", wsWithAITask.ID, wsWithAITaskParam.ID)
for i, ws := range res.Workspaces {
t.Logf("AI Task True Workspace %d: ID=%s, Name=%s", i, ws.ID, ws.Name)
}
require.Len(t, res.Workspaces, 2)
workspaceIDs := []uuid.UUID{res.Workspaces[0].ID, res.Workspaces[1].ID}
require.Contains(t, workspaceIDs, wsWithAITask.ID)
require.Contains(t, workspaceIDs, wsWithAITaskParam.ID)
// Should be ordered by starting/running then created date.
expectWorkspaces(res.Workspaces, orderByWithAITask)

// Test filtering for workspaces without AI tasks
// Should include: wsWithoutAITask, wsCompletedWithAITaskParam, wsWithEmptyAITaskParam
Expand All @@ -4612,21 +4650,15 @@ func TestWorkspaceFilterHasAITask(t *testing.T) {

// Debug: print what we got
t.Logf("Expected 3 workspaces for has-ai-task:false, got %d", len(res.Workspaces))
for i, ws := range res.Workspaces {
t.Logf("Workspace %d: ID=%s, Name=%s", i, ws.ID, ws.Name)
}
t.Logf("Expected IDs: %s, %s, %s", wsWithoutAITask.ID, wsCompletedWithAITaskParam.ID, wsWithEmptyAITaskParam.ID)

require.Len(t, res.Workspaces, 3)
workspaceIDs = []uuid.UUID{res.Workspaces[0].ID, res.Workspaces[1].ID, res.Workspaces[2].ID}
require.Contains(t, workspaceIDs, wsWithoutAITask.ID)
require.Contains(t, workspaceIDs, wsCompletedWithAITaskParam.ID)
require.Contains(t, workspaceIDs, wsWithEmptyAITaskParam.ID)
// Should be ordered by running then name.
expectWorkspaces(res.Workspaces, orderByWithoutAITask)

// Test no filter returns all
res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{})
t.Logf("Expected 5 workspaces without filter, got %d", len(res.Workspaces))
require.NoError(t, err)
require.Len(t, res.Workspaces, 5)
// Should be ordered by running then name.
expectWorkspaces(res.Workspaces, orderByAll)
}

func TestWorkspaceAppUpsertRestart(t *testing.T) {
Expand Down
Loading