diff --git a/coderd/aitasks.go b/coderd/aitasks.go index d5cca9e4f0b3f..ccd8b2dfac5b7 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -13,6 +13,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -23,6 +24,7 @@ import ( "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/taskname" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" @@ -270,15 +272,21 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) codersdk.Task { var taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle var taskAgentHealth *codersdk.WorkspaceAgentHealth + var taskAppHealth *codersdk.WorkspaceAppHealth + + if dbTask.WorkspaceAgentLifecycleState.Valid { + taskAgentLifecycle = ptr.Ref(codersdk.WorkspaceAgentLifecycle(dbTask.WorkspaceAgentLifecycleState.WorkspaceAgentLifecycleState)) + } + if dbTask.WorkspaceAppHealth.Valid { + taskAppHealth = ptr.Ref(codersdk.WorkspaceAppHealth(dbTask.WorkspaceAppHealth.WorkspaceAppHealth)) + } - // If we have an agent ID from the task, find the agent details in the - // workspace. + // If we have an agent ID from the task, find the agent health info if dbTask.WorkspaceAgentID.Valid { findTaskAgentLoop: for _, resource := range ws.LatestBuild.Resources { for _, agent := range resource.Agents { if agent.ID == dbTask.WorkspaceAgentID.UUID { - taskAgentLifecycle = &agent.LifecycleState taskAgentHealth = &agent.Health break findTaskAgentLoop } @@ -286,21 +294,7 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod } } - // 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 { - 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, - } - } - } + currentState := deriveTaskCurrentState(dbTask, ws, taskAgentLifecycle, taskAppHealth) return codersdk.Task{ ID: dbTask.ID, @@ -330,6 +324,73 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod } } +// deriveTaskCurrentState determines the current state of a task based on the +// workspace's latest app status and initialization phase. +// Returns nil if no valid state can be determined. +func deriveTaskCurrentState( + dbTask database.Task, + ws codersdk.Workspace, + taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle, + taskAppHealth *codersdk.WorkspaceAppHealth, +) *codersdk.TaskStateEntry { + var currentState *codersdk.TaskStateEntry + + // 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. + if ws.LatestAppStatus != nil { + 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, + } + } + } + + // If no valid agent state was found for the current build and the task is initializing, + // provide a descriptive initialization message. + if currentState == nil && dbTask.Status == database.TaskStatusInitializing { + message := "Initializing workspace" + + switch { + case ws.LatestBuild.Status == codersdk.WorkspaceStatusPending || + ws.LatestBuild.Status == codersdk.WorkspaceStatusStarting: + message = fmt.Sprintf("Workspace is %s", ws.LatestBuild.Status) + case taskAgentLifecycle != nil: + switch { + case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleCreated: + message = "Agent is connecting" + case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleStarting: + message = "Agent is starting" + case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleReady: + if taskAppHealth != nil && *taskAppHealth == codersdk.WorkspaceAppHealthInitializing { + message = "App is initializing" + } else { + // In case the workspace app is not initializing, + // the overall task status should be updated accordingly + message = "Initializing workspace applications" + } + default: + // In case the workspace agent is not initializing, + // the overall task status should be updated accordingly + message = "Initializing workspace agent" + } + } + + currentState = &codersdk.TaskStateEntry{ + Timestamp: ws.LatestBuild.CreatedAt, + State: codersdk.TaskStateWorking, + Message: message, + URI: "", + } + } + + return currentState +} + // @Summary List AI tasks // @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable. // @ID list-tasks diff --git a/coderd/aitasks_internal_test.go b/coderd/aitasks_internal_test.go new file mode 100644 index 0000000000000..0c087c653befd --- /dev/null +++ b/coderd/aitasks_internal_test.go @@ -0,0 +1,223 @@ +package coderd + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" +) + +func TestDeriveTaskCurrentState_Unit(t *testing.T) { + t.Parallel() + + now := time.Now() + tests := []struct { + name string + task database.Task + agentLifecycle *codersdk.WorkspaceAgentLifecycle + appHealth *codersdk.WorkspaceAppHealth + latestAppStatus *codersdk.WorkspaceAppStatus + latestBuild codersdk.WorkspaceBuild + expectCurrentState bool + expectedTimestamp time.Time + expectedState codersdk.TaskState + expectedMessage string + }{ + { + name: "NoAppStatus", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusActive, + }, + agentLifecycle: nil, + appHealth: nil, + latestAppStatus: nil, + latestBuild: codersdk.WorkspaceBuild{ + Transition: codersdk.WorkspaceTransitionStart, + CreatedAt: now, + }, + expectCurrentState: false, + }, + { + name: "BuildStartTransition_AppStatus_NewerThanBuild", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusActive, + }, + agentLifecycle: nil, + appHealth: nil, + latestAppStatus: &codersdk.WorkspaceAppStatus{ + State: codersdk.WorkspaceAppStatusStateWorking, + Message: "Task is working", + CreatedAt: now.Add(1 * time.Minute), + }, + latestBuild: codersdk.WorkspaceBuild{ + Transition: codersdk.WorkspaceTransitionStart, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now.Add(1 * time.Minute), + expectedState: codersdk.TaskState(codersdk.WorkspaceAppStatusStateWorking), + expectedMessage: "Task is working", + }, + { + name: "BuildStartTransition_StaleAppStatus_OlderThanBuild", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusActive, + }, + agentLifecycle: nil, + appHealth: nil, + latestAppStatus: &codersdk.WorkspaceAppStatus{ + State: codersdk.WorkspaceAppStatusStateComplete, + Message: "Previous task completed", + CreatedAt: now.Add(-1 * time.Minute), + }, + latestBuild: codersdk.WorkspaceBuild{ + Transition: codersdk.WorkspaceTransitionStart, + CreatedAt: now, + }, + expectCurrentState: false, + }, + { + name: "BuildStopTransition", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusActive, + }, + agentLifecycle: nil, + appHealth: nil, + latestAppStatus: &codersdk.WorkspaceAppStatus{ + State: codersdk.WorkspaceAppStatusStateComplete, + Message: "Task completed before stop", + CreatedAt: now.Add(-1 * time.Minute), + }, + latestBuild: codersdk.WorkspaceBuild{ + Transition: codersdk.WorkspaceTransitionStop, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now.Add(-1 * time.Minute), + expectedState: codersdk.TaskState(codersdk.WorkspaceAppStatusStateComplete), + expectedMessage: "Task completed before stop", + }, + { + name: "TaskInitializing_WorkspacePending", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusInitializing, + }, + agentLifecycle: nil, + appHealth: nil, + latestAppStatus: nil, + latestBuild: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusPending, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now, + expectedState: codersdk.TaskStateWorking, + expectedMessage: "Workspace is pending", + }, + { + name: "TaskInitializing_WorkspaceStarting", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusInitializing, + }, + agentLifecycle: nil, + appHealth: nil, + latestAppStatus: nil, + latestBuild: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusStarting, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now, + expectedState: codersdk.TaskStateWorking, + expectedMessage: "Workspace is starting", + }, + { + name: "TaskInitializing_AgentConnecting", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusInitializing, + }, + agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleCreated), + appHealth: nil, + latestAppStatus: nil, + latestBuild: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now, + expectedState: codersdk.TaskStateWorking, + expectedMessage: "Agent is connecting", + }, + { + name: "TaskInitializing_AgentStarting", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusInitializing, + }, + agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleStarting), + appHealth: nil, + latestAppStatus: nil, + latestBuild: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now, + expectedState: codersdk.TaskStateWorking, + expectedMessage: "Agent is starting", + }, + { + name: "TaskInitializing_AppInitializing", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusInitializing, + }, + agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady), + appHealth: ptr.Ref(codersdk.WorkspaceAppHealthInitializing), + latestAppStatus: nil, + latestBuild: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now, + expectedState: codersdk.TaskStateWorking, + expectedMessage: "App is initializing", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ws := codersdk.Workspace{ + LatestBuild: tt.latestBuild, + LatestAppStatus: tt.latestAppStatus, + } + + currentState := deriveTaskCurrentState(tt.task, ws, tt.agentLifecycle, tt.appHealth) + + if tt.expectCurrentState { + require.NotNil(t, currentState) + assert.Equal(t, tt.expectedTimestamp.UTC(), currentState.Timestamp.UTC()) + assert.Equal(t, tt.expectedState, currentState.State) + assert.Equal(t, tt.expectedMessage, currentState.Message) + } else { + assert.Nil(t, currentState) + } + }) + } +} diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index eaac973b159e6..8582765c01174 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -240,14 +240,18 @@ func TestTasks(t *testing.T) { 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) + previousCurrentState := updated.CurrentState // Start the workspace again coderdtest.MustTransitionWorkspace(t, client, task.WorkspaceID.UUID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart) - // Verify that the status from the previous build is no longer present + // Verify that the status from the previous build has been cleared + // and replaced by the agent initialization status. updated, err = exp.TaskByID(ctx, task.ID) require.NoError(t, err) - assert.Nil(t, updated.CurrentState, "current state should be nil") + assert.NotEqual(t, previousCurrentState, updated.CurrentState) + assert.Equal(t, codersdk.TaskStateWorking, updated.CurrentState.State) + assert.NotEqual(t, "all done", updated.CurrentState.Message) }) t.Run("Delete", func(t *testing.T) { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index ba430cd57e7ca..a682ec838ffed 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -361,12 +361,20 @@ func (b WorkspaceBuildBuilder) doInTX() WorkspaceResponse { require.Fail(b.t, "task app not configured but workspace is a task workspace") } - app := mustWorkspaceAppByWorkspaceAndBuildAndAppID(ownerCtx, b.t, b.db, resp.Workspace.ID, resp.Build.BuildNumber, b.taskAppID) + workspaceAgentID := uuid.NullUUID{} + workspaceAppID := uuid.NullUUID{} + // Workspace agent and app are only properly set upon job completion + if b.jobStatus != database.ProvisionerJobStatusPending && b.jobStatus != database.ProvisionerJobStatusRunning { + app := mustWorkspaceAppByWorkspaceAndBuildAndAppID(ownerCtx, b.t, b.db, resp.Workspace.ID, resp.Build.BuildNumber, b.taskAppID) + workspaceAgentID = uuid.NullUUID{UUID: app.AgentID, Valid: true} + workspaceAppID = uuid.NullUUID{UUID: app.ID, Valid: true} + } + _, err = b.db.UpsertTaskWorkspaceApp(ownerCtx, database.UpsertTaskWorkspaceAppParams{ TaskID: task.ID, WorkspaceBuildNumber: resp.Build.BuildNumber, - WorkspaceAgentID: uuid.NullUUID{UUID: app.AgentID, Valid: true}, - WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + WorkspaceAgentID: workspaceAgentID, + WorkspaceAppID: workspaceAppID, }) require.NoError(b.t, err, "upsert task workspace app") b.logger.Debug(context.Background(), "linked task to workspace build",