From eb55f0ab198bc09ec13b8712c446b25427efbe55 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 26 Sep 2025 10:14:37 +0100 Subject: [PATCH] fix(coderd)!: only show task status for current build (#19966) This changes the task get endpoint to omit app statuses for previous 'lifetimes' of a workspace. It also introduces a [breaking change](https://github.com/coder/coder/blob/release/2.26/codersdk/aitasks.go#L83) to bring `TaskStateComplete` in line with `WorkspaceAppStatusStateComplete`. I can alternatively revert this change and add a conversion function between the two SDK types. --- cli/exp_task_status_test.go | 12 +++---- coderd/aitasks.go | 15 ++++++--- coderd/aitasks_test.go | 59 ++++++++++++++++++++++++++++------ codersdk/aitasks.go | 8 ++--- site/src/api/typesGenerated.ts | 4 +-- 5 files changed, 71 insertions(+), 27 deletions(-) diff --git a/cli/exp_task_status_test.go b/cli/exp_task_status_test.go index b520d27288..89935940d0 100644 --- a/cli/exp_task_status_test.go +++ b/cli/exp_task_status_test.go @@ -96,9 +96,9 @@ func Test_TaskStatus(t *testing.T) { STATE CHANGED STATUS STATE MESSAGE 4s ago running 3s ago running working Reticulating splines... -2s ago running completed Splines reticulated successfully! -2s ago stopping completed Splines reticulated successfully! -2s ago stopped completed Splines reticulated successfully!`, +2s ago running complete Splines reticulated successfully! +2s ago stopping complete Splines reticulated successfully! +2s ago stopped complete Splines reticulated successfully!`, hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) { var calls atomic.Int64 return func(w http.ResponseWriter, r *http.Request) { @@ -143,7 +143,7 @@ STATE CHANGED STATUS STATE MESSAGE CreatedAt: now.Add(-5 * time.Second), UpdatedAt: now.Add(-4 * time.Second), CurrentState: &codersdk.TaskStateEntry{ - State: codersdk.TaskStateCompleted, + State: codersdk.TaskStateComplete, Timestamp: now.Add(-2 * time.Second), Message: "Splines reticulated successfully!", }, @@ -155,7 +155,7 @@ STATE CHANGED STATUS STATE MESSAGE CreatedAt: now.Add(-5 * time.Second), UpdatedAt: now.Add(-1 * time.Second), CurrentState: &codersdk.TaskStateEntry{ - State: codersdk.TaskStateCompleted, + State: codersdk.TaskStateComplete, Timestamp: now.Add(-2 * time.Second), Message: "Splines reticulated successfully!", }, @@ -167,7 +167,7 @@ STATE CHANGED STATUS STATE MESSAGE CreatedAt: now.Add(-5 * time.Second), UpdatedAt: now, CurrentState: &codersdk.TaskStateEntry{ - State: codersdk.TaskStateCompleted, + State: codersdk.TaskStateComplete, Timestamp: now.Add(-2 * time.Second), Message: "Splines reticulated successfully!", }, diff --git a/coderd/aitasks.go b/coderd/aitasks.go index f56e8de9b4..92087942d5 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -245,13 +245,18 @@ func taskFromWorkspace(ws codersdk.Workspace, initialPrompt string) codersdk.Tas } } + // Ignore 'latest app status' if it is older than the latest build and the latest build is a 'start' transition. + // This ensures that you don't show a stale app status from a previous build. + // For stop transitions, there is still value in showing the latest app status. var currentState *codersdk.TaskStateEntry if ws.LatestAppStatus != nil { - currentState = &codersdk.TaskStateEntry{ - Timestamp: ws.LatestAppStatus.CreatedAt, - State: codersdk.TaskState(ws.LatestAppStatus.State), - Message: ws.LatestAppStatus.Message, - URI: ws.LatestAppStatus.URI, + if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) { + currentState = &codersdk.TaskStateEntry{ + Timestamp: ws.LatestAppStatus.CreatedAt, + State: codersdk.TaskState(ws.LatestAppStatus.State), + Message: ws.LatestAppStatus.Message, + URI: ws.LatestAppStatus.URI, + } } } diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 1e83c7fb35..1ff213c376 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" @@ -269,19 +270,39 @@ func TestTasks(t *testing.T) { t.Run("Get", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + ctx = testutil.Context(t, testutil.WaitLong) + user = coderdtest.CreateFirstUser(t, client) + template = createAITemplate(t, client, user) + // Create a workspace (task) with a specific prompt. + wantPrompt = "review my code" + workspace = coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { + req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: codersdk.AITaskPromptParameterName, Value: wantPrompt}, + } + }) + ) - template := createAITemplate(t, client, user) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + ws := coderdtest.MustWorkspace(t, client, workspace.ID) + // Assert invariant: the workspace has exactly one resource with one agent with one app. + require.Len(t, ws.LatestBuild.Resources, 1) + require.Len(t, ws.LatestBuild.Resources[0].Agents, 1) + agentID := ws.LatestBuild.Resources[0].Agents[0].ID + taskAppID := ws.LatestBuild.Resources[0].Agents[0].Apps[0].ID - // Create a workspace (task) with a specific prompt. - wantPrompt := "review my code" - workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: wantPrompt}, - } + // Insert an app status for the workspace + _, err := db.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{ + ID: uuid.New(), + WorkspaceID: workspace.ID, + CreatedAt: dbtime.Now(), + AgentID: agentID, + AppID: taskAppID, + State: database.WorkspaceAppStatusStateComplete, + Message: "all done", }) + require.NoError(t, err) // Fetch the task by ID via experimental API and verify fields. exp := codersdk.NewExperimentalClient(client) @@ -293,6 +314,24 @@ func TestTasks(t *testing.T) { assert.Equal(t, wantPrompt, task.InitialPrompt, "task prompt should match the AI Prompt parameter") assert.Equal(t, workspace.ID, task.WorkspaceID.UUID, "workspace id should match") assert.NotEmpty(t, task.Status, "task status should not be empty") + + // Stop the workspace + coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + + // Verify that the previous status still remains + updated, err := exp.TaskByID(ctx, workspace.ID) + require.NoError(t, err) + assert.NotNil(t, updated.CurrentState, "current state should not be nil") + assert.Equal(t, "all done", updated.CurrentState.Message) + assert.Equal(t, codersdk.TaskStateComplete, updated.CurrentState.State) + + // Start the workspace again + coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart) + + // Verify that the status from the previous build is no longer present + updated, err = exp.TaskByID(ctx, workspace.ID) + require.NoError(t, err) + assert.Nil(t, updated.CurrentState, "current state should be nil") }) t.Run("Delete", func(t *testing.T) { diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index bb410e1309..53c0c06de3 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -96,10 +96,10 @@ type TaskState string // TaskState enums. const ( - TaskStateWorking TaskState = "working" - TaskStateIdle TaskState = "idle" - TaskStateCompleted TaskState = "completed" - TaskStateFailed TaskState = "failed" + TaskStateWorking TaskState = "working" + TaskStateIdle TaskState = "idle" + TaskStateComplete TaskState = "complete" + TaskStateFailed TaskState = "failed" ) // Task represents a task. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f7bac87970..d8f80586fe 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2907,7 +2907,7 @@ export interface TaskSendRequest { } // From codersdk/aitasks.go -export type TaskState = "completed" | "failed" | "idle" | "working"; +export type TaskState = "complete" | "failed" | "idle" | "working"; // From codersdk/aitasks.go export interface TaskStateEntry { @@ -2918,7 +2918,7 @@ export interface TaskStateEntry { } export const TaskStates: TaskState[] = [ - "completed", + "complete", "failed", "idle", "working",