diff --git a/coderd/agentapi/apps.go b/coderd/agentapi/apps.go index 759fb26e5c3cb..aa480ff7faa54 100644 --- a/coderd/agentapi/apps.go +++ b/coderd/agentapi/apps.go @@ -216,7 +216,7 @@ func (a *AppsAPI) UpdateAppStatus(ctx context.Context, req *agentproto.UpdateApp // We pass time.Time{} for nextAutostart since we don't have access to // TemplateScheduleStore here. The activity bump logic handles this by // defaulting to the template's activity_bump duration (typically 1 hour). - workspacestats.ActivityBumpWorkspace(ctx, a.Log, a.Database, ws.ID, time.Time{}, workspacestats.ActivityBumpReasonAppActivity) + workspacestats.ActivityBumpWorkspace(ctx, a.Log, a.Database, ws.ID, time.Time{}, workspacestats.ActivityBumpReasonApp(app.Slug)) } // just return a blank response because it doesn't contain any settable fields at present. return new(agentproto.UpdateAppStatusResponse), nil diff --git a/coderd/agentapi/apps_test.go b/coderd/agentapi/apps_test.go index 528226e2e6b97..706c4200353e8 100644 --- a/coderd/agentapi/apps_test.go +++ b/coderd/agentapi/apps_test.go @@ -340,6 +340,59 @@ func TestWorkspaceAgentAppStatus(t *testing.T) { require.Len(t, sent, 1) }) + t.Run("BumpsActivityWithAppSlugAsSource", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + mDB := dbmock.NewMockStore(ctrl) + agent := database.WorkspaceAgent{ + ID: uuid.UUID{2}, + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + } + + // No TaskID, so enqueueAITaskStateNotification short-circuits and + // no extra notification-related mocks are required. + workspace := database.Workspace{ + ID: uuid.UUID{9}, + } + cachedWs := &agentapi.CachedWorkspaceFields{} + cachedWs.UpdateValues(workspace) + + api := &agentapi.AppsAPI{ + AgentID: agent.ID, + AgentFn: func(context.Context) (database.WorkspaceAgent, error) { + return agent, nil + }, + Database: mDB, + Log: testutil.Logger(t), + Workspace: cachedWs, + } + + app := database.WorkspaceApp{ + ID: uuid.UUID{8}, + Slug: "my-custom-app", + } + mDB.EXPECT().GetWorkspaceAppByAgentIDAndSlug(gomock.Any(), database.GetWorkspaceAppByAgentIDAndSlugParams{ + AgentID: agent.ID, + Slug: "my-custom-app", + }).Times(1).Return(app, nil) + // Zero-value previous status: ID == uuid.Nil, so shouldBump only + // triggers via the new state being Working. + mDB.EXPECT().GetLatestWorkspaceAppStatusByAppID(gomock.Any(), app.ID).Times(1).Return(database.WorkspaceAppStatus{}, nil) + mDB.EXPECT().InsertWorkspaceAppStatus(gomock.Any(), gomock.Any()).Times(1).Return(database.WorkspaceAppStatus{}, nil) + mDB.EXPECT().ActivityBumpWorkspace(gomock.Any(), gomock.Cond(func(arg database.ActivityBumpWorkspaceParams) bool { + return arg.WorkspaceID == workspace.ID && arg.Source == "app:my-custom-app" + })).Times(1).Return(nil) + + _, err := api.UpdateAppStatus(ctx, &agentproto.UpdateAppStatusRequest{ + Slug: "my-custom-app", + Message: "testing", + State: agentproto.UpdateAppStatusRequest_WORKING, + }) + require.NoError(t, err) + }) + t.Run("FailUnknownApp", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) diff --git a/coderd/agentapi/stats_test.go b/coderd/agentapi/stats_test.go index bf6c41e550c54..2470e6cabd791 100644 --- a/coderd/agentapi/stats_test.go +++ b/coderd/agentapi/stats_test.go @@ -151,6 +151,7 @@ func TestUpdateStats(t *testing.T) { dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{ WorkspaceID: workspace.ID, NextAutostart: time.Time{}.UTC(), + Source: "ssh", }).Return(nil) // Workspace last used at gets bumped. @@ -377,6 +378,7 @@ func TestUpdateStats(t *testing.T) { dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{ WorkspaceID: workspace.ID, NextAutostart: nextAutostart, + Source: "ssh", }).Return(nil) // Workspace last used at gets bumped. @@ -489,6 +491,7 @@ func TestUpdateStats(t *testing.T) { dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{ WorkspaceID: workspace.ID, NextAutostart: time.Time{}.UTC(), + Source: "ssh", }).Return(nil) // Workspace last used at gets bumped. @@ -624,6 +627,7 @@ func TestUpdateStats(t *testing.T) { dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{ WorkspaceID: workspace.ID, NextAutostart: time.Time{}.UTC(), + Source: "ssh", }).Return(nil) // Workspace last used at gets bumped. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 526fe4b48fad7..6cf8ba9ed0666 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -27174,6 +27174,15 @@ const docTemplate = `{ "description": "IsPrebuild indicates whether the workspace is a prebuilt workspace.\nPrebuilt workspaces are owned by the prebuilds system user and have specific behavior,\nsuch as being managed differently from regular workspaces.\nOnce a prebuilt workspace is claimed by a user, it transitions to a regular workspace,\nand IsPrebuild returns false.", "type": "boolean" }, + "last_activity_at": { + "description": "LastActivityAt is the time of the last activity that bumped the\nworkspace's autostop deadline. Distinct from LastUsedAt, which is\nupdated by a broader set of app/port-forward traffic unrelated to\ndeadline bumps.", + "type": "string", + "format": "date-time" + }, + "last_activity_source": { + "description": "LastActivitySource identifies what kind of activity (ssh, vscode,\njetbrains, reconnecting_pty, an app:\u003cslug\u003e, or chat_heartbeat) most\nrecently bumped the workspace's autostop deadline. Nil if the\nworkspace has never had its deadline bumped by activity.", + "type": "string" + }, "last_used_at": { "type": "string", "format": "date-time" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a009b6e708658..19e4afcc4a2fd 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -25012,6 +25012,15 @@ "description": "IsPrebuild indicates whether the workspace is a prebuilt workspace.\nPrebuilt workspaces are owned by the prebuilds system user and have specific behavior,\nsuch as being managed differently from regular workspaces.\nOnce a prebuilt workspace is claimed by a user, it transitions to a regular workspace,\nand IsPrebuild returns false.", "type": "boolean" }, + "last_activity_at": { + "description": "LastActivityAt is the time of the last activity that bumped the\nworkspace's autostop deadline. Distinct from LastUsedAt, which is\nupdated by a broader set of app/port-forward traffic unrelated to\ndeadline bumps.", + "type": "string", + "format": "date-time" + }, + "last_activity_source": { + "description": "LastActivitySource identifies what kind of activity (ssh, vscode,\njetbrains, reconnecting_pty, an app:\u003cslug\u003e, or chat_heartbeat) most\nrecently bumped the workspace's autostop deadline. Nil if the\nworkspace has never had its deadline bumped by activity.", + "type": "string" + }, "last_used_at": { "type": "string", "format": "date-time" diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 79e987cc084e9..720fd0e22ee41 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -3108,12 +3108,18 @@ CREATE TABLE workspaces ( next_start_at timestamp with time zone, group_acl jsonb DEFAULT '{}'::jsonb NOT NULL, user_acl jsonb DEFAULT '{}'::jsonb NOT NULL, + last_activity_source text, + last_activity_at timestamp with time zone, CONSTRAINT group_acl_is_object CHECK ((jsonb_typeof(group_acl) = 'object'::text)), CONSTRAINT user_acl_is_object CHECK ((jsonb_typeof(user_acl) = 'object'::text)) ); COMMENT ON COLUMN workspaces.favorite IS 'Favorite is true if the workspace owner has favorited the workspace.'; +COMMENT ON COLUMN workspaces.last_activity_source IS 'Source of the last activity that bumped the workspace deadline (e.g. ssh, vscode, jetbrains, reconnecting_pty, app:, chat_heartbeat). NULL if the workspace has never had its deadline bumped by activity.'; + +COMMENT ON COLUMN workspaces.last_activity_at IS 'Timestamp of the last activity that bumped the workspace deadline. Distinct from last_used_at, which is updated by a broader, unrelated app/port-forward-traffic code path.'; + CREATE VIEW tasks_with_status AS SELECT tasks.id, tasks.organization_id, @@ -4224,7 +4230,9 @@ CREATE VIEW workspaces_expanded AS LEFT JOIN groups g ON ((g.id = (acl.key)::uuid)))), '{}'::jsonb) AS group_acl_display_info, COALESCE(( SELECT jsonb_object_agg(acl.key, jsonb_build_object('name', COALESCE(vu.name, ''::text), 'avatar_url', COALESCE(vu.avatar_url, ''::text))) AS jsonb_object_agg FROM (jsonb_each(workspaces.user_acl) acl(key, value) - LEFT JOIN visible_users vu ON ((vu.id = (acl.key)::uuid)))), '{}'::jsonb) AS user_acl_display_info + LEFT JOIN visible_users vu ON ((vu.id = (acl.key)::uuid)))), '{}'::jsonb) AS user_acl_display_info, + workspaces.last_activity_source, + workspaces.last_activity_at FROM ((((workspaces JOIN visible_users ON ((workspaces.owner_id = visible_users.id))) JOIN organizations ON ((workspaces.organization_id = organizations.id))) diff --git a/coderd/database/migrations/000567_workspace_last_activity.down.sql b/coderd/database/migrations/000567_workspace_last_activity.down.sql new file mode 100644 index 0000000000000..259e81a1eaffb --- /dev/null +++ b/coderd/database/migrations/000567_workspace_last_activity.down.sql @@ -0,0 +1,49 @@ +DROP VIEW workspaces_expanded; + +CREATE VIEW workspaces_expanded AS + SELECT workspaces.id, + workspaces.created_at, + workspaces.updated_at, + workspaces.owner_id, + workspaces.organization_id, + workspaces.template_id, + workspaces.deleted, + workspaces.name, + workspaces.autostart_schedule, + workspaces.ttl, + workspaces.last_used_at, + workspaces.dormant_at, + workspaces.deleting_at, + workspaces.automatic_updates, + workspaces.favorite, + workspaces.next_start_at, + workspaces.group_acl, + workspaces.user_acl, + visible_users.avatar_url AS owner_avatar_url, + visible_users.username AS owner_username, + visible_users.name AS owner_name, + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + organizations.icon AS organization_icon, + organizations.description AS organization_description, + templates.name AS template_name, + templates.display_name AS template_display_name, + templates.icon AS template_icon, + templates.description AS template_description, + tasks.id AS task_id, + COALESCE(( SELECT jsonb_object_agg(acl.key, jsonb_build_object('name', COALESCE(g.name, ''::text), 'avatar_url', COALESCE(g.avatar_url, ''::text))) AS jsonb_object_agg + FROM (jsonb_each(workspaces.group_acl) acl(key, value) + LEFT JOIN groups g ON ((g.id = (acl.key)::uuid)))), '{}'::jsonb) AS group_acl_display_info, + COALESCE(( SELECT jsonb_object_agg(acl.key, jsonb_build_object('name', COALESCE(vu.name, ''::text), 'avatar_url', COALESCE(vu.avatar_url, ''::text))) AS jsonb_object_agg + FROM (jsonb_each(workspaces.user_acl) acl(key, value) + LEFT JOIN visible_users vu ON ((vu.id = (acl.key)::uuid)))), '{}'::jsonb) AS user_acl_display_info + FROM ((((workspaces + JOIN visible_users ON ((workspaces.owner_id = visible_users.id))) + JOIN organizations ON ((workspaces.organization_id = organizations.id))) + JOIN templates ON ((workspaces.template_id = templates.id))) + LEFT JOIN tasks ON ((workspaces.id = tasks.workspace_id))); + +COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.'; + +ALTER TABLE ONLY workspaces DROP COLUMN IF EXISTS last_activity_at; +ALTER TABLE ONLY workspaces DROP COLUMN IF EXISTS last_activity_source; diff --git a/coderd/database/migrations/000567_workspace_last_activity.up.sql b/coderd/database/migrations/000567_workspace_last_activity.up.sql new file mode 100644 index 0000000000000..e2f8794ebcc7c --- /dev/null +++ b/coderd/database/migrations/000567_workspace_last_activity.up.sql @@ -0,0 +1,54 @@ +ALTER TABLE ONLY workspaces ADD COLUMN IF NOT EXISTS last_activity_source text; +ALTER TABLE ONLY workspaces ADD COLUMN IF NOT EXISTS last_activity_at timestamp with time zone; + +COMMENT ON COLUMN workspaces.last_activity_source IS 'Source of the last activity that bumped the workspace deadline (e.g. ssh, vscode, jetbrains, reconnecting_pty, app:, chat_heartbeat). NULL if the workspace has never had its deadline bumped by activity.'; +COMMENT ON COLUMN workspaces.last_activity_at IS 'Timestamp of the last activity that bumped the workspace deadline. Distinct from last_used_at, which is updated by a broader, unrelated app/port-forward-traffic code path.'; + +DROP VIEW workspaces_expanded; + +CREATE VIEW workspaces_expanded AS + SELECT workspaces.id, + workspaces.created_at, + workspaces.updated_at, + workspaces.owner_id, + workspaces.organization_id, + workspaces.template_id, + workspaces.deleted, + workspaces.name, + workspaces.autostart_schedule, + workspaces.ttl, + workspaces.last_used_at, + workspaces.dormant_at, + workspaces.deleting_at, + workspaces.automatic_updates, + workspaces.favorite, + workspaces.next_start_at, + workspaces.group_acl, + workspaces.user_acl, + visible_users.avatar_url AS owner_avatar_url, + visible_users.username AS owner_username, + visible_users.name AS owner_name, + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + organizations.icon AS organization_icon, + organizations.description AS organization_description, + templates.name AS template_name, + templates.display_name AS template_display_name, + templates.icon AS template_icon, + templates.description AS template_description, + tasks.id AS task_id, + COALESCE(( SELECT jsonb_object_agg(acl.key, jsonb_build_object('name', COALESCE(g.name, ''::text), 'avatar_url', COALESCE(g.avatar_url, ''::text))) AS jsonb_object_agg + FROM (jsonb_each(workspaces.group_acl) acl(key, value) + LEFT JOIN groups g ON ((g.id = (acl.key)::uuid)))), '{}'::jsonb) AS group_acl_display_info, + COALESCE(( SELECT jsonb_object_agg(acl.key, jsonb_build_object('name', COALESCE(vu.name, ''::text), 'avatar_url', COALESCE(vu.avatar_url, ''::text))) AS jsonb_object_agg + FROM (jsonb_each(workspaces.user_acl) acl(key, value) + LEFT JOIN visible_users vu ON ((vu.id = (acl.key)::uuid)))), '{}'::jsonb) AS user_acl_display_info, + workspaces.last_activity_source, + workspaces.last_activity_at + FROM ((((workspaces + JOIN visible_users ON ((workspaces.owner_id = visible_users.id))) + JOIN organizations ON ((workspaces.organization_id = organizations.id))) + JOIN templates ON ((workspaces.template_id = templates.id))) + LEFT JOIN tasks ON ((workspaces.id = tasks.workspace_id))); + +COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.'; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 70940611ccee4..8f7d44afb7b66 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -329,6 +329,8 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.TaskID, &i.GroupACLDisplayInfo, &i.UserACLDisplayInfo, + &i.LastActivitySource, + &i.LastActivityAt, &i.TemplateVersionID, &i.TemplateVersionName, &i.LatestBuildCompletedAt, diff --git a/coderd/database/models.go b/coderd/database/models.go index a68b7e54bc924..9f3a11fc9dc12 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -6328,6 +6328,8 @@ type Workspace struct { TaskID uuid.NullUUID `db:"task_id" json:"task_id"` GroupACLDisplayInfo WorkspaceACLDisplayInfo `db:"group_acl_display_info" json:"group_acl_display_info"` UserACLDisplayInfo WorkspaceACLDisplayInfo `db:"user_acl_display_info" json:"user_acl_display_info"` + LastActivitySource sql.NullString `db:"last_activity_source" json:"last_activity_source"` + LastActivityAt sql.NullTime `db:"last_activity_at" json:"last_activity_at"` } type WorkspaceAgent struct { @@ -6806,4 +6808,8 @@ type WorkspaceTable struct { NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` GroupACL WorkspaceACL `db:"group_acl" json:"group_acl"` UserACL WorkspaceACL `db:"user_acl" json:"user_acl"` + // Source of the last activity that bumped the workspace deadline (e.g. ssh, vscode, jetbrains, reconnecting_pty, app:, chat_heartbeat). NULL if the workspace has never had its deadline bumped by activity. + LastActivitySource sql.NullString `db:"last_activity_source" json:"last_activity_source"` + // Timestamp of the last activity that bumped the workspace deadline. Distinct from last_used_at, which is updated by a broader, unrelated app/port-forward-traffic code path. + LastActivityAt sql.NullTime `db:"last_activity_at" json:"last_activity_at"` } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 7e052926c4069..6aadbdd541bff 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -45,10 +45,11 @@ type sqlcQuerier interface { // // Max deadline is respected, and the deadline will never be bumped past it. // The deadline will never decrease. - // We only bump if the template has an activity bump duration set. - // We only bump if the raw interval is positive and non-zero. - // We only bump if workspace shutdown is manual. - // We only bump when 5% of the deadline has elapsed. + // Record the source and time of the activity that caused the bump, but + // only when the bump above actually happened. This piggybacks on the + // guard conditions above instead of writing on every call (which would + // happen far more often, since callers invoke this on every stats + // report/heartbeat, not only when the deadline is actually extended). ActivityBumpWorkspace(ctx context.Context, arg ActivityBumpWorkspaceParams) error // AllUserIDs returns all UserIDs regardless of user status or deletion. AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UUID, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4697e3499a145..0fe97334aaf61 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -35,16 +35,16 @@ WITH latest AS ( -- Sadly we can't define 'activity_bump_interval' above since -- it won't be available for this CASE statement, so we have to -- copy the cast twice. - WHEN NOW() + (templates.activity_bump / 1000 / 1000 / 1000 || ' seconds')::interval > $1 :: timestamptz + WHEN NOW() + (templates.activity_bump / 1000 / 1000 / 1000 || ' seconds')::interval > $2 :: timestamptz -- If the autostart is behind now(), then the -- autostart schedule is either the 0 time and not provided, -- or it was the autostart in the past, which is no longer -- relevant. If autostart is > 0 and in the past, then -- that is a mistake by the caller. - AND $1 > NOW() + AND $2 > NOW() THEN -- Extend to the autostart, then add the activity bump - (($1 :: timestamptz) - NOW()) + CASE + (($2 :: timestamptz) - NOW()) + CASE WHEN templates.allow_user_autostop THEN (workspaces.ttl / 1000 / 1000 / 1000 || ' seconds')::interval ELSE (templates.default_ttl / 1000 / 1000 / 1000 || ' seconds')::interval @@ -63,34 +63,47 @@ WITH latest AS ( JOIN templates ON templates.id = workspaces.template_id WHERE - workspace_builds.workspace_id = $2::uuid + workspace_builds.workspace_id = $3::uuid -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) -- are managed by the reconciliation loop and not subject to activity bumping AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ORDER BY workspace_builds.build_number DESC LIMIT 1 +), bumped AS ( + UPDATE + workspace_builds wb + SET + updated_at = NOW(), + deadline = CASE + WHEN l.build_max_deadline = '0001-01-01 00:00:00+00' + -- Never reduce the deadline from activity. + THEN GREATEST(wb.deadline, NOW() + l.ttl_interval) + ELSE LEAST(GREATEST(wb.deadline, NOW() + l.ttl_interval), l.build_max_deadline) + END + FROM latest l + WHERE wb.id = l.build_id + AND l.job_completed_at IS NOT NULL + -- We only bump if the template has an activity bump duration set. + AND l.activity_bump > 0 + AND l.build_transition = 'start' + -- We only bump if the raw interval is positive and non-zero. + AND l.ttl_interval > '0 seconds'::interval + -- We only bump if workspace shutdown is manual. + AND l.build_deadline != '0001-01-01 00:00:00+00' + -- We only bump when 5% of the deadline has elapsed. + AND l.build_deadline - (l.ttl_interval * 0.95) < NOW() + RETURNING wb.workspace_id ) -UPDATE - workspace_builds wb +UPDATE workspaces SET - updated_at = NOW(), - deadline = CASE - WHEN l.build_max_deadline = '0001-01-01 00:00:00+00' - -- Never reduce the deadline from activity. - THEN GREATEST(wb.deadline, NOW() + l.ttl_interval) - ELSE LEAST(GREATEST(wb.deadline, NOW() + l.ttl_interval), l.build_max_deadline) - END -FROM latest l -WHERE wb.id = l.build_id -AND l.job_completed_at IS NOT NULL -AND l.activity_bump > 0 -AND l.build_transition = 'start' -AND l.ttl_interval > '0 seconds'::interval -AND l.build_deadline != '0001-01-01 00:00:00+00' -AND l.build_deadline - (l.ttl_interval * 0.95) < NOW() + last_activity_source = $1 :: text, + last_activity_at = NOW() +FROM bumped +WHERE workspaces.id = bumped.workspace_id ` type ActivityBumpWorkspaceParams struct { + Source string `db:"source" json:"source"` NextAutostart time.Time `db:"next_autostart" json:"next_autostart"` WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` } @@ -102,12 +115,13 @@ type ActivityBumpWorkspaceParams struct { // // Max deadline is respected, and the deadline will never be bumped past it. // The deadline will never decrease. -// We only bump if the template has an activity bump duration set. -// We only bump if the raw interval is positive and non-zero. -// We only bump if workspace shutdown is manual. -// We only bump when 5% of the deadline has elapsed. +// Record the source and time of the activity that caused the bump, but +// only when the bump above actually happened. This piggybacks on the +// guard conditions above instead of writing on every call (which would +// happen far more often, since callers invoke this on every stats +// report/heartbeat, not only when the deadline is actually extended). func (q *sqlQuerier) ActivityBumpWorkspace(ctx context.Context, arg ActivityBumpWorkspaceParams) error { - _, err := q.db.ExecContext(ctx, activityBumpWorkspace, arg.NextAutostart, arg.WorkspaceID) + _, err := q.db.ExecContext(ctx, activityBumpWorkspace, arg.Source, arg.NextAutostart, arg.WorkspaceID) return err } @@ -32816,7 +32830,7 @@ func (q *sqlQuerier) DeleteWorkspaceSubAgentByID(ctx context.Context, id uuid.UU const getAuthenticatedWorkspaceAgentAndBuildByAuthToken = `-- name: GetAuthenticatedWorkspaceAgentAndBuildByAuthToken :one SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, workspaces.last_activity_source, workspaces.last_activity_at, workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope, workspace_agents.deleted, workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.template_version_preset_id, workspace_build_with_user.has_ai_task, workspace_build_with_user.has_external_agent, workspace_build_with_user.notified_autostop_deadline, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username, workspace_build_with_user.initiator_by_name, tasks.id AS task_id @@ -32914,6 +32928,8 @@ func (q *sqlQuerier) GetAuthenticatedWorkspaceAgentAndBuildByAuthToken(ctx conte &i.WorkspaceTable.NextStartAt, &i.WorkspaceTable.GroupACL, &i.WorkspaceTable.UserACL, + &i.WorkspaceTable.LastActivitySource, + &i.WorkspaceTable.LastActivityAt, &i.WorkspaceAgent.ID, &i.WorkspaceAgent.CreatedAt, &i.WorkspaceAgent.UpdatedAt, @@ -33072,7 +33088,7 @@ func (q *sqlQuerier) GetExternalAgentTokensByTemplateID(ctx context.Context, arg const getWorkspaceAgentAndWorkspaceByID = `-- name: GetWorkspaceAgentAndWorkspaceByID :one SELECT workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope, workspace_agents.deleted, - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, workspaces.last_activity_source, workspaces.last_activity_at, users.username as owner_username FROM workspace_agents @@ -33157,6 +33173,8 @@ func (q *sqlQuerier) GetWorkspaceAgentAndWorkspaceByID(ctx context.Context, id u &i.WorkspaceTable.NextStartAt, &i.WorkspaceTable.GroupACL, &i.WorkspaceTable.UserACL, + &i.WorkspaceTable.LastActivitySource, + &i.WorkspaceTable.LastActivityAt, &i.OwnerUsername, ) return i, err @@ -34055,7 +34073,7 @@ const getWorkspaceBuildAgentsByInstanceID = `-- name: GetWorkspaceBuildAgentsByI SELECT workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope, workspace_agents.deleted, workspace_builds.id AS workspace_build_id, - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, workspaces.last_activity_source, workspaces.last_activity_at FROM workspace_agents JOIN @@ -34153,6 +34171,8 @@ func (q *sqlQuerier) GetWorkspaceBuildAgentsByInstanceID(ctx context.Context, au &i.WorkspaceTable.NextStartAt, &i.WorkspaceTable.GroupACL, &i.WorkspaceTable.UserACL, + &i.WorkspaceTable.LastActivitySource, + &i.WorkspaceTable.LastActivityAt, ); err != nil { return nil, err } @@ -36748,7 +36768,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w const getLatestWorkspaceBuildWithStatusByWorkspaceID = `-- name: GetLatestWorkspaceBuildWithStatusByWorkspaceID :one SELECT workspace_builds.transition, workspace_builds.build_number, provisioner_jobs.job_status, - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl -- Used for dbauthz fetch() checks + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, workspaces.last_activity_source, workspaces.last_activity_at -- Used for dbauthz fetch() checks FROM workspace_builds INNER JOIN @@ -36796,6 +36816,8 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildWithStatusByWorkspaceID(ctx context. &i.WorkspaceTable.NextStartAt, &i.WorkspaceTable.GroupACL, &i.WorkspaceTable.UserACL, + &i.WorkspaceTable.LastActivitySource, + &i.WorkspaceTable.LastActivityAt, ) return i, err } @@ -38300,7 +38322,7 @@ func (q *sqlQuerier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (Get const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description, task_id, group_acl_display_info, user_acl_display_info + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description, task_id, group_acl_display_info, user_acl_display_info, last_activity_source, last_activity_at FROM workspaces_expanded as workspaces WHERE @@ -38364,13 +38386,15 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI &i.TaskID, &i.GroupACLDisplayInfo, &i.UserACLDisplayInfo, + &i.LastActivitySource, + &i.LastActivityAt, ) return i, err } const getWorkspaceByID = `-- name: GetWorkspaceByID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description, task_id, group_acl_display_info, user_acl_display_info + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description, task_id, group_acl_display_info, user_acl_display_info, last_activity_source, last_activity_at FROM workspaces_expanded WHERE @@ -38415,13 +38439,15 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp &i.TaskID, &i.GroupACLDisplayInfo, &i.UserACLDisplayInfo, + &i.LastActivitySource, + &i.LastActivityAt, ) return i, err } const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description, task_id, group_acl_display_info, user_acl_display_info + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description, task_id, group_acl_display_info, user_acl_display_info, last_activity_source, last_activity_at FROM workspaces_expanded as workspaces WHERE @@ -38473,13 +38499,15 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo &i.TaskID, &i.GroupACLDisplayInfo, &i.UserACLDisplayInfo, + &i.LastActivitySource, + &i.LastActivityAt, ) return i, err } const getWorkspaceByResourceID = `-- name: GetWorkspaceByResourceID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description, task_id, group_acl_display_info, user_acl_display_info + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description, task_id, group_acl_display_info, user_acl_display_info, last_activity_source, last_activity_at FROM workspaces_expanded as workspaces WHERE @@ -38538,13 +38566,15 @@ func (q *sqlQuerier) GetWorkspaceByResourceID(ctx context.Context, resourceID uu &i.TaskID, &i.GroupACLDisplayInfo, &i.UserACLDisplayInfo, + &i.LastActivitySource, + &i.LastActivityAt, ) return i, err } const getWorkspaceByWorkspaceAppID = `-- name: GetWorkspaceByWorkspaceAppID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description, task_id, group_acl_display_info, user_acl_display_info + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description, task_id, group_acl_display_info, user_acl_display_info, last_activity_source, last_activity_at FROM workspaces_expanded as workspaces WHERE @@ -38615,6 +38645,8 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace &i.TaskID, &i.GroupACLDisplayInfo, &i.UserACLDisplayInfo, + &i.LastActivitySource, + &i.LastActivityAt, ) return i, err } @@ -38664,7 +38696,7 @@ SELECT ), filtered_workspaces AS ( SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, workspaces.owner_avatar_url, workspaces.owner_username, workspaces.owner_name, workspaces.organization_name, workspaces.organization_display_name, workspaces.organization_icon, workspaces.organization_description, workspaces.template_name, workspaces.template_display_name, workspaces.template_icon, workspaces.template_description, workspaces.task_id, workspaces.group_acl_display_info, workspaces.user_acl_display_info, + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, workspaces.owner_avatar_url, workspaces.owner_username, workspaces.owner_name, workspaces.organization_name, workspaces.organization_display_name, workspaces.organization_icon, workspaces.organization_description, workspaces.template_name, workspaces.template_display_name, workspaces.template_icon, workspaces.template_description, workspaces.task_id, workspaces.group_acl_display_info, workspaces.user_acl_display_info, workspaces.last_activity_source, workspaces.last_activity_at, latest_build.template_version_id, latest_build.template_version_name, latest_build.completed_at as latest_build_completed_at, @@ -38950,7 +38982,7 @@ WHERE -- @authorize_filter ), filtered_workspaces_order AS ( SELECT - fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.group_acl, fw.user_acl, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.task_id, fw.group_acl_display_info, fw.user_acl_display_info, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_external_agent, fw.latest_build_provisioner_job_id + fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.group_acl, fw.user_acl, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.task_id, fw.group_acl_display_info, fw.user_acl_display_info, fw.last_activity_source, fw.last_activity_at, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_external_agent, fw.latest_build_provisioner_job_id FROM filtered_workspaces fw ORDER BY @@ -38971,7 +39003,7 @@ WHERE $26 ), filtered_workspaces_order_with_summary AS ( SELECT - fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.task_id, fwo.group_acl_display_info, fwo.user_acl_display_info, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_external_agent, fwo.latest_build_provisioner_job_id + fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.task_id, fwo.group_acl_display_info, fwo.user_acl_display_info, fwo.last_activity_source, fwo.last_activity_at, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_external_agent, fwo.latest_build_provisioner_job_id FROM filtered_workspaces_order fwo -- Return a technical summary row with total count of workspaces. @@ -39010,6 +39042,8 @@ WHERE '00000000-0000-0000-0000-000000000000'::uuid, -- task_id '{}'::jsonb, -- group_acl_display_info '{}'::jsonb, -- user_acl_display_info + NULL::text, -- last_activity_source + NULL::timestamptz, -- last_activity_at -- Extra columns added to ` + "`" + `filtered_workspaces` + "`" + ` '00000000-0000-0000-0000-000000000000'::uuid, -- template_version_id '', -- template_version_name @@ -39029,7 +39063,7 @@ WHERE filtered_workspaces ) SELECT - fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.group_acl, fwos.user_acl, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.task_id, fwos.group_acl_display_info, fwos.user_acl_display_info, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_external_agent, fwos.latest_build_provisioner_job_id, + fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.group_acl, fwos.user_acl, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.task_id, fwos.group_acl_display_info, fwos.user_acl_display_info, fwos.last_activity_source, fwos.last_activity_at, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_external_agent, fwos.latest_build_provisioner_job_id, -- agent_metadata expands the response with the requested agent -- metadata keys for the latest build's agents. The CASE keeps the -- subquery unevaluated for every caller that does not opt in, and @@ -39154,6 +39188,8 @@ type GetWorkspacesRow struct { TaskID uuid.NullUUID `db:"task_id" json:"task_id"` GroupACLDisplayInfo interface{} `db:"group_acl_display_info" json:"group_acl_display_info"` UserACLDisplayInfo interface{} `db:"user_acl_display_info" json:"user_acl_display_info"` + LastActivitySource sql.NullString `db:"last_activity_source" json:"last_activity_source"` + LastActivityAt sql.NullTime `db:"last_activity_at" json:"last_activity_at"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` LatestBuildCompletedAt sql.NullTime `db:"latest_build_completed_at" json:"latest_build_completed_at"` @@ -39241,6 +39277,8 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.TaskID, &i.GroupACLDisplayInfo, &i.UserACLDisplayInfo, + &i.LastActivitySource, + &i.LastActivityAt, &i.TemplateVersionID, &i.TemplateVersionName, &i.LatestBuildCompletedAt, @@ -39346,7 +39384,7 @@ func (q *sqlQuerier) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerI } const getWorkspacesByTemplateID = `-- name: GetWorkspacesByTemplateID :many -SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl FROM workspaces WHERE template_id = $1 AND deleted = false +SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, last_activity_source, last_activity_at FROM workspaces WHERE template_id = $1 AND deleted = false ` func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceTable, error) { @@ -39377,6 +39415,8 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, templateID u &i.NextStartAt, &i.GroupACL, &i.UserACL, + &i.LastActivitySource, + &i.LastActivityAt, ); err != nil { return nil, err } @@ -39682,7 +39722,7 @@ INSERT INTO next_start_at ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, last_activity_source, last_activity_at ` type InsertWorkspaceParams struct { @@ -39735,6 +39775,8 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar &i.NextStartAt, &i.GroupACL, &i.UserACL, + &i.LastActivitySource, + &i.LastActivityAt, ) return i, err } @@ -39774,7 +39816,7 @@ SET WHERE id = $1 AND deleted = false -RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl +RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, last_activity_source, last_activity_at ` type UpdateWorkspaceParams struct { @@ -39804,6 +39846,8 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar &i.NextStartAt, &i.GroupACL, &i.UserACL, + &i.LastActivitySource, + &i.LastActivityAt, ) return i, err } @@ -39921,7 +39965,7 @@ WHERE -- dormant_at and deleting_at AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID RETURNING - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, workspaces.last_activity_source, workspaces.last_activity_at ` type UpdateWorkspaceDormantDeletingAtParams struct { @@ -39951,6 +39995,8 @@ func (q *sqlQuerier) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg U &i.NextStartAt, &i.GroupACL, &i.UserACL, + &i.LastActivitySource, + &i.LastActivityAt, ) return i, err } @@ -40037,7 +40083,7 @@ WHERE -- should not have their dormant or deleting at set, as these are handled by the -- prebuilds reconciliation loop. AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID -RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl +RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, last_activity_source, last_activity_at ` type UpdateWorkspacesDormantDeletingAtByTemplateIDParams struct { @@ -40074,6 +40120,8 @@ func (q *sqlQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.C &i.NextStartAt, &i.GroupACL, &i.UserACL, + &i.LastActivitySource, + &i.LastActivityAt, ); err != nil { return nil, err } diff --git a/coderd/database/queries/activitybump.sql b/coderd/database/queries/activitybump.sql index e367a93abf778..ffd01f7d4de10 100644 --- a/coderd/database/queries/activitybump.sql +++ b/coderd/database/queries/activitybump.sql @@ -59,27 +59,40 @@ WITH latest AS ( AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ORDER BY workspace_builds.build_number DESC LIMIT 1 +), bumped AS ( + UPDATE + workspace_builds wb + SET + updated_at = NOW(), + deadline = CASE + WHEN l.build_max_deadline = '0001-01-01 00:00:00+00' + -- Never reduce the deadline from activity. + THEN GREATEST(wb.deadline, NOW() + l.ttl_interval) + ELSE LEAST(GREATEST(wb.deadline, NOW() + l.ttl_interval), l.build_max_deadline) + END + FROM latest l + WHERE wb.id = l.build_id + AND l.job_completed_at IS NOT NULL + -- We only bump if the template has an activity bump duration set. + AND l.activity_bump > 0 + AND l.build_transition = 'start' + -- We only bump if the raw interval is positive and non-zero. + AND l.ttl_interval > '0 seconds'::interval + -- We only bump if workspace shutdown is manual. + AND l.build_deadline != '0001-01-01 00:00:00+00' + -- We only bump when 5% of the deadline has elapsed. + AND l.build_deadline - (l.ttl_interval * 0.95) < NOW() + RETURNING wb.workspace_id ) -UPDATE - workspace_builds wb +-- Record the source and time of the activity that caused the bump, but +-- only when the bump above actually happened. This piggybacks on the +-- guard conditions above instead of writing on every call (which would +-- happen far more often, since callers invoke this on every stats +-- report/heartbeat, not only when the deadline is actually extended). +UPDATE workspaces SET - updated_at = NOW(), - deadline = CASE - WHEN l.build_max_deadline = '0001-01-01 00:00:00+00' - -- Never reduce the deadline from activity. - THEN GREATEST(wb.deadline, NOW() + l.ttl_interval) - ELSE LEAST(GREATEST(wb.deadline, NOW() + l.ttl_interval), l.build_max_deadline) - END -FROM latest l -WHERE wb.id = l.build_id -AND l.job_completed_at IS NOT NULL --- We only bump if the template has an activity bump duration set. -AND l.activity_bump > 0 -AND l.build_transition = 'start' --- We only bump if the raw interval is positive and non-zero. -AND l.ttl_interval > '0 seconds'::interval --- We only bump if workspace shutdown is manual. -AND l.build_deadline != '0001-01-01 00:00:00+00' --- We only bump when 5% of the deadline has elapsed. -AND l.build_deadline - (l.ttl_interval * 0.95) < NOW() + last_activity_source = @source :: text, + last_activity_at = NOW() +FROM bumped +WHERE workspaces.id = bumped.workspace_id ; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 15251b56ca0a8..c8edd4d7d115c 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -455,6 +455,8 @@ WHERE '00000000-0000-0000-0000-000000000000'::uuid, -- task_id '{}'::jsonb, -- group_acl_display_info '{}'::jsonb, -- user_acl_display_info + NULL::text, -- last_activity_source + NULL::timestamptz, -- last_activity_at -- Extra columns added to `filtered_workspaces` '00000000-0000-0000-0000-000000000000'::uuid, -- template_version_id '', -- template_version_name diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 21d11d88b7636..72e6e3a3c64a2 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2889,6 +2889,16 @@ func convertWorkspace( nextStartAt = &workspace.NextStartAt.Time } + var lastActivitySource *string + if workspace.LastActivitySource.Valid { + lastActivitySource = &workspace.LastActivitySource.String + } + + var lastActivityAt *time.Time + if workspace.LastActivityAt.Valid { + lastActivityAt = &workspace.LastActivityAt.Time + } + failingAgents := []uuid.UUID{} for _, resource := range workspaceBuild.Resources { for _, agent := range resource.Agents { @@ -2944,6 +2954,8 @@ func convertWorkspace( AutostartSchedule: autostartSchedule, TTLMillis: ttlMillis, LastUsedAt: workspace.LastUsedAt, + LastActivitySource: lastActivitySource, + LastActivityAt: lastActivityAt, DeletingAt: deletingAt, DormantAt: dormantAt, Health: codersdk.WorkspaceHealth{ diff --git a/coderd/workspacestats/activitybump.go b/coderd/workspacestats/activitybump.go index 0f6014805af13..cc106c1798525 100644 --- a/coderd/workspacestats/activitybump.go +++ b/coderd/workspacestats/activitybump.go @@ -8,24 +8,60 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog/v3" + agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/database" ) -// ActivityBumpReason represents the reason for an activity bump. +// ActivityBumpReason represents the source of activity that triggered a +// workspace deadline bump. It is persisted to workspaces.last_activity_source +// so operators can see why a workspace's autostop deadline keeps extending. type ActivityBumpReason string const ( - // ActivityBumpReasonWorkspaceStats indicates the bump was triggered - // by SSH or terminal activity reported via workspace stats. - ActivityBumpReasonWorkspaceStats ActivityBumpReason = "workspace_stats" + // ActivityBumpReasonSSH indicates the bump was triggered by an SSH session. + ActivityBumpReasonSSH ActivityBumpReason = "ssh" + // ActivityBumpReasonVSCode indicates the bump was triggered by a VS Code session. + ActivityBumpReasonVSCode ActivityBumpReason = "vscode" + // ActivityBumpReasonJetBrains indicates the bump was triggered by a JetBrains session. + ActivityBumpReasonJetBrains ActivityBumpReason = "jetbrains" + // ActivityBumpReasonReconnectingPTY indicates the bump was triggered by + // a web terminal (reconnecting PTY) session. + ActivityBumpReasonReconnectingPTY ActivityBumpReason = "reconnecting_pty" // ActivityBumpReasonChatHeartbeat indicates the bump was triggered // by an AI chat heartbeat. ActivityBumpReasonChatHeartbeat ActivityBumpReason = "chat_heartbeat" - // ActivityBumpReasonAppActivity indicates the bump was triggered - // by app or port-forward activity. + // ActivityBumpReasonAppActivity indicates the bump was triggered by + // app activity, when the specific app slug is unavailable. ActivityBumpReasonAppActivity ActivityBumpReason = "app_activity" ) +// ActivityBumpReasonApp returns the source recorded for activity from a +// specific workspace app, identified by its slug. +func ActivityBumpReasonApp(slug string) ActivityBumpReason { + return ActivityBumpReason("app:" + slug) +} + +// ActivityBumpReasonFromStats derives the source to record when a bump is +// triggered by agent-reported session stats. Priority order when multiple +// session types are simultaneously active: SSH > VS Code > JetBrains > web +// terminal. Only one source is recorded per bump. +func ActivityBumpReasonFromStats(stats *agentproto.Stats) ActivityBumpReason { + switch { + case stats.SessionCountSsh > 0: + return ActivityBumpReasonSSH + case stats.SessionCountVscode > 0: + return ActivityBumpReasonVSCode + case stats.SessionCountJetbrains > 0: + return ActivityBumpReasonJetBrains + case stats.SessionCountReconnectingPty > 0: + return ActivityBumpReasonReconnectingPTY + default: + // Legacy stats (ConnectionCount > 0) with no per-session + // breakdown available. + return ActivityBumpReasonSSH + } +} + // ActivityBumpWorkspace automatically bumps the workspace's auto-off timer // if it is set to expire soon. The deadline will be bumped by 1 hour*. // If the bump crosses over an autostart time, the workspace will be @@ -59,6 +95,7 @@ func ActivityBumpWorkspace(ctx context.Context, log slog.Logger, db database.Sto err := db.ActivityBumpWorkspace(ctx, database.ActivityBumpWorkspaceParams{ NextAutostart: nextAutostart.UTC(), WorkspaceID: workspaceID, + Source: string(reason), }) if err != nil { if !xerrors.Is(err, context.Canceled) && !database.IsQueryCanceledError(err) { diff --git a/coderd/workspacestats/activitybump_test.go b/coderd/workspacestats/activitybump_test.go index 8838ed658395e..72587b4a7b6c5 100644 --- a/coderd/workspacestats/activitybump_test.go +++ b/coderd/workspacestats/activitybump_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" @@ -268,7 +269,7 @@ func Test_ActivityBumpWorkspace(t *testing.T) { // Bump duration is measured from the time of the bump, so we measure from here. start := dbtime.Now() - workspacestats.ActivityBumpWorkspace(ctx, log, db, bld.WorkspaceID, nextAutostart(start), workspacestats.ActivityBumpReasonWorkspaceStats) + workspacestats.ActivityBumpWorkspace(ctx, log, db, bld.WorkspaceID, nextAutostart(start), workspacestats.ActivityBumpReasonSSH) end := dbtime.Now() // Validate our state after bump @@ -276,12 +277,24 @@ func Test_ActivityBumpWorkspace(t *testing.T) { require.NoError(t, err, "unexpected error getting latest workspace build") require.Equal(t, bld.MaxDeadline.UTC(), updatedBuild.MaxDeadline.UTC(), "max_deadline should not have changed") + updatedWorkspace, err := db.GetWorkspaceByID(ctx, bld.WorkspaceID) + require.NoError(t, err, "unexpected error getting workspace") + if tt.expectedBump == 0 { assert.Equal(t, bld.UpdatedAt.UTC(), updatedBuild.UpdatedAt.UTC(), "should not have bumped updated_at") assert.Equal(t, bld.Deadline.UTC(), updatedBuild.Deadline.UTC(), "should not have bumped deadline") + assert.False(t, updatedWorkspace.LastActivitySource.Valid, "last_activity_source should not be set when the deadline was not bumped") + assert.False(t, updatedWorkspace.LastActivityAt.Valid, "last_activity_at should not be set when the deadline was not bumped") return } assert.NotEqual(t, bld.UpdatedAt.UTC(), updatedBuild.UpdatedAt.UTC(), "should have bumped updated_at") + assert.Equal(t, sql.NullString{String: string(workspacestats.ActivityBumpReasonSSH), Valid: true}, updatedWorkspace.LastActivitySource, "last_activity_source should be recorded when the deadline was bumped") + require.True(t, updatedWorkspace.LastActivityAt.Valid, "last_activity_at should be recorded when the deadline was bumped") + // 1min buffer on either side to tolerate clock skew between + // the test process and the database server, matching the + // deadline assertions below. + assert.GreaterOrEqual(t, updatedWorkspace.LastActivityAt.Time, start.Add(-time.Minute), "last_activity_at should be at or after the start of the bump") + assert.LessOrEqual(t, updatedWorkspace.LastActivityAt.Time, end.Add(time.Minute), "last_activity_at should be at or before the end of the bump") if tt.maxDeadlineOffset != nil { assert.Equal(t, bld.MaxDeadline.UTC(), updatedBuild.MaxDeadline.UTC(), "new deadline must equal original max deadline") return @@ -297,6 +310,158 @@ func Test_ActivityBumpWorkspace(t *testing.T) { } } +// Test_ActivityBumpWorkspace_SourceOverwrites verifies that +// last_activity_source/last_activity_at are overwritten in place on each +// bump rather than accumulating any history, per the feature's design +// (see coder/coder#17320). +func Test_ActivityBumpWorkspace_SourceOverwrites(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitLong) + log = testutil.Logger(t) + db, _ = dbtestutil.NewDB(t) + org = dbgen.Organization(t, db, database.Organization{}) + user = dbgen.User(t, db, database.User{ + Status: database.UserStatusActive, + }) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + template = dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + ActiveVersionID: templateVersion.ID, + CreatedBy: user.ID, + }) + ws = dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: template.ID, + Ttl: sql.NullInt64{Valid: true, Int64: int64(8 * time.Hour)}, + }) + job = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + CompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)}, + }) + ) + + require.NoError(t, db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: dbtime.Now(), + AllowUserAutostop: true, + DefaultTTL: int64(8 * time.Hour), + ActivityBump: int64(1 * time.Hour), + })) + + buildID := uuid.New() + require.NoError(t, db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + ID: buildID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + BuildNumber: 1, + InitiatorID: user.ID, + Reason: database.BuildReasonInitiator, + WorkspaceID: ws.ID, + JobID: job.ID, + TemplateVersionID: templateVersion.ID, + Transition: database.WorkspaceTransitionStart, + // A deadline already in the past satisfies the "5% of the + // deadline has elapsed" guard, so the bump fires immediately. + Deadline: dbtime.Now().Add(-30 * time.Minute), + })) + + workspacestats.ActivityBumpWorkspace(ctx, log, db, ws.ID, time.Time{}, workspacestats.ActivityBumpReasonSSH) + afterSSH, err := db.GetWorkspaceByID(ctx, ws.ID) + require.NoError(t, err) + require.Equal(t, sql.NullString{String: "ssh", Valid: true}, afterSSH.LastActivitySource) + + // Move the deadline back into bump range again and bump with a + // different source. It should overwrite, not append. + require.NoError(t, db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ + ID: buildID, + UpdatedAt: dbtime.Now(), + Deadline: dbtime.Now().Add(-30 * time.Minute), + MaxDeadline: time.Time{}, + })) + workspacestats.ActivityBumpWorkspace(ctx, log, db, ws.ID, time.Time{}, workspacestats.ActivityBumpReasonVSCode) + afterVSCode, err := db.GetWorkspaceByID(ctx, ws.ID) + require.NoError(t, err) + assert.Equal(t, sql.NullString{String: "vscode", Valid: true}, afterVSCode.LastActivitySource, "source should overwrite to the latest value, not accumulate history") +} + +func TestActivityBumpReasonFromStats(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + stats *agentproto.Stats + expected workspacestats.ActivityBumpReason + }{ + { + name: "SSH", + stats: &agentproto.Stats{SessionCountSsh: 1}, + expected: workspacestats.ActivityBumpReasonSSH, + }, + { + name: "VSCode", + stats: &agentproto.Stats{SessionCountVscode: 1}, + expected: workspacestats.ActivityBumpReasonVSCode, + }, + { + name: "JetBrains", + stats: &agentproto.Stats{SessionCountJetbrains: 1}, + expected: workspacestats.ActivityBumpReasonJetBrains, + }, + { + name: "ReconnectingPTY", + stats: &agentproto.Stats{SessionCountReconnectingPty: 1}, + expected: workspacestats.ActivityBumpReasonReconnectingPTY, + }, + { + name: "SSHTakesPriorityOverAll", + stats: &agentproto.Stats{ + SessionCountSsh: 1, + SessionCountVscode: 1, + SessionCountJetbrains: 1, + SessionCountReconnectingPty: 1, + }, + expected: workspacestats.ActivityBumpReasonSSH, + }, + { + name: "VSCodeTakesPriorityOverJetBrainsAndPTY", + stats: &agentproto.Stats{ + SessionCountVscode: 1, + SessionCountJetbrains: 1, + SessionCountReconnectingPty: 1, + }, + expected: workspacestats.ActivityBumpReasonVSCode, + }, + { + name: "JetBrainsTakesPriorityOverPTY", + stats: &agentproto.Stats{ + SessionCountJetbrains: 1, + SessionCountReconnectingPty: 1, + }, + expected: workspacestats.ActivityBumpReasonJetBrains, + }, + { + name: "NoSessionCountsFallsBackToSSH", + stats: &agentproto.Stats{ConnectionCount: 1}, + expected: workspacestats.ActivityBumpReasonSSH, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, workspacestats.ActivityBumpReasonFromStats(tt.stats)) + }) + } +} + func insertPrevWorkspaceBuild(t *testing.T, db database.Store, orgID, tvID, workspaceID uuid.UUID, transition database.WorkspaceTransition, buildNumber int32) { t.Helper() diff --git a/coderd/workspacestats/reporter.go b/coderd/workspacestats/reporter.go index c5b8f9f70adf6..b2a0336ac0989 100644 --- a/coderd/workspacestats/reporter.go +++ b/coderd/workspacestats/reporter.go @@ -194,7 +194,7 @@ func (r *Reporter) ReportAgentStats(ctx context.Context, now time.Time, workspac } // bump workspace activity - ActivityBumpWorkspace(ctx, r.opts.Logger.Named("activity_bump"), r.opts.Database, workspace.ID, nextAutostart, ActivityBumpReasonWorkspaceStats) + ActivityBumpWorkspace(ctx, r.opts.Logger.Named("activity_bump"), r.opts.Database, workspace.ID, nextAutostart, ActivityBumpReasonFromStats(stats)) } // bump workspace last_used_at diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 6a78ecd7b364d..51105de5c2954 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -53,6 +53,16 @@ type Workspace struct { AutostartSchedule *string `json:"autostart_schedule,omitempty"` TTLMillis *int64 `json:"ttl_ms,omitempty"` LastUsedAt time.Time `json:"last_used_at" format:"date-time"` + // LastActivitySource identifies what kind of activity (ssh, vscode, + // jetbrains, reconnecting_pty, an app:, or chat_heartbeat) most + // recently bumped the workspace's autostop deadline. Nil if the + // workspace has never had its deadline bumped by activity. + LastActivitySource *string `json:"last_activity_source,omitempty"` + // LastActivityAt is the time of the last activity that bumped the + // workspace's autostop deadline. Distinct from LastUsedAt, which is + // updated by a broader set of app/port-forward traffic unrelated to + // deadline bumps. + LastActivityAt *time.Time `json:"last_activity_at,omitempty" format:"date-time"` // DeletingAt indicates the time at which the workspace will be permanently deleted. // A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) // and a value has been specified for time_til_dormant_autodelete on its template. diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 136dcb50fc803..ec8fa7478619b 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -48,7 +48,7 @@ We track the following resources: | UserSkill
create, write, delete | |
FieldTracked
contenttrue
created_atfalse
descriptiontrue
idtrue
nametrue
updated_atfalse
user_idtrue
| | WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
notified_autostop_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| -| WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| +| WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_activity_atfalse
last_activity_sourcefalse
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 1e6dbe6e0eff7..e83a5ca6ad92d 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -15186,6 +15186,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_prebuild": true, + "last_activity_at": "2019-08-24T14:15:22Z", + "last_activity_source": "string", "last_used_at": "2019-08-24T14:15:22Z", "latest_app_status": { "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", @@ -15475,6 +15477,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. | | `id` | string | false | | | | `is_prebuild` | boolean | false | | Is prebuild indicates whether the workspace is a prebuilt workspace. Prebuilt workspaces are owned by the prebuilds system user and have specific behavior, such as being managed differently from regular workspaces. Once a prebuilt workspace is claimed by a user, it transitions to a regular workspace, and IsPrebuild returns false. | +| `last_activity_at` | string | false | | Last activity at is the time of the last activity that bumped the workspace's autostop deadline. Distinct from LastUsedAt, which is updated by a broader set of app/port-forward traffic unrelated to deadline bumps. | +| `last_activity_source` | string | false | | Last activity source identifies what kind of activity (ssh, vscode, jetbrains, reconnecting_pty, an app:, or chat_heartbeat) most recently bumped the workspace's autostop deadline. Nil if the workspace has never had its deadline bumped by activity. | | `last_used_at` | string | false | | | | `latest_app_status` | [codersdk.WorkspaceAppStatus](#codersdkworkspaceappstatus) | false | | | | `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | | @@ -17406,6 +17410,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_prebuild": true, + "last_activity_at": "2019-08-24T14:15:22Z", + "last_activity_source": "string", "last_used_at": "2019-08-24T14:15:22Z", "latest_app_status": { "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 7a22c111e5e5f..76a9c00fbc9aa 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -73,6 +73,8 @@ of the template will be used. }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_prebuild": true, + "last_activity_at": "2019-08-24T14:15:22Z", + "last_activity_source": "string", "last_used_at": "2019-08-24T14:15:22Z", "latest_app_status": { "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", @@ -456,6 +458,8 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_prebuild": true, + "last_activity_at": "2019-08-24T14:15:22Z", + "last_activity_source": "string", "last_used_at": "2019-08-24T14:15:22Z", "latest_app_status": { "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", @@ -806,6 +810,8 @@ of the template will be used. }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_prebuild": true, + "last_activity_at": "2019-08-24T14:15:22Z", + "last_activity_source": "string", "last_used_at": "2019-08-24T14:15:22Z", "latest_app_status": { "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", @@ -1134,6 +1140,8 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_prebuild": true, + "last_activity_at": "2019-08-24T14:15:22Z", + "last_activity_source": "string", "last_used_at": "2019-08-24T14:15:22Z", "latest_app_status": { "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", @@ -1432,6 +1440,8 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_prebuild": true, + "last_activity_at": "2019-08-24T14:15:22Z", + "last_activity_source": "string", "last_used_at": "2019-08-24T14:15:22Z", "latest_app_status": { "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", @@ -2068,6 +2078,8 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "is_prebuild": true, + "last_activity_at": "2019-08-24T14:15:22Z", + "last_activity_source": "string", "last_used_at": "2019-08-24T14:15:22Z", "latest_app_status": { "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index a58d523d7db76..a085ad9e4121d 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -176,24 +176,26 @@ var auditableResourcesTypes = map[any]map[string]Action{ "chat_spend_limit_micros": ActionTrack, }, &database.WorkspaceTable{}: { - "id": ActionTrack, - "created_at": ActionIgnore, // Never changes. - "updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff. - "owner_id": ActionTrack, - "organization_id": ActionIgnore, // Never changes. - "template_id": ActionTrack, - "deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired. - "name": ActionTrack, - "autostart_schedule": ActionTrack, - "ttl": ActionTrack, - "last_used_at": ActionIgnore, - "dormant_at": ActionTrack, - "deleting_at": ActionTrack, - "automatic_updates": ActionTrack, - "favorite": ActionTrack, - "next_start_at": ActionTrack, - "group_acl": ActionTrack, - "user_acl": ActionTrack, + "id": ActionTrack, + "created_at": ActionIgnore, // Never changes. + "updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff. + "owner_id": ActionTrack, + "organization_id": ActionIgnore, // Never changes. + "template_id": ActionTrack, + "deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired. + "name": ActionTrack, + "autostart_schedule": ActionTrack, + "ttl": ActionTrack, + "last_used_at": ActionIgnore, + "last_activity_source": ActionIgnore, + "last_activity_at": ActionIgnore, + "dormant_at": ActionTrack, + "deleting_at": ActionTrack, + "automatic_updates": ActionTrack, + "favorite": ActionTrack, + "next_start_at": ActionTrack, + "group_acl": ActionTrack, + "user_acl": ActionTrack, }, &database.WorkspaceBuild{}: { "id": ActionIgnore, diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index ea764ae2174f0..7eabd82d2cc76 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -3056,7 +3056,7 @@ func TestPrebuildActivityBump(t *testing.T) { require.Zero(t, prebuild.LatestBuild.MaxDeadline) // When: activity bump is applied to an unclaimed prebuild - workspacestats.ActivityBumpWorkspace(ctx, log, db, prebuild.ID, clock.Now().Add(10*time.Hour), workspacestats.ActivityBumpReasonWorkspaceStats) + workspacestats.ActivityBumpWorkspace(ctx, log, db, prebuild.ID, clock.Now().Add(10*time.Hour), workspacestats.ActivityBumpReasonSSH) // Then: prebuild Deadline/MaxDeadline remain unchanged prebuild = coderdtest.MustWorkspace(t, client, wb.Workspace.ID) @@ -3089,7 +3089,7 @@ func TestPrebuildActivityBump(t *testing.T) { workspace = coderdtest.MustWorkspace(t, client, claimedWorkspace.ID) // When: activity bump is applied to a claimed prebuild - workspacestats.ActivityBumpWorkspace(ctx, log, db, workspace.ID, clock.Now().Add(10*time.Hour), workspacestats.ActivityBumpReasonWorkspaceStats) + workspacestats.ActivityBumpWorkspace(ctx, log, db, workspace.ID, clock.Now().Add(10*time.Hour), workspacestats.ActivityBumpReasonSSH) // Then: Deadline is extended by the activity bump, MaxDeadline remains unset workspace = coderdtest.MustWorkspace(t, client, claimedWorkspace.ID) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0222095336d24..31b11be7e30d5 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -10622,6 +10622,20 @@ export interface Workspace { readonly autostart_schedule?: string; readonly ttl_ms?: number; readonly last_used_at: string; + /** + * LastActivitySource identifies what kind of activity (ssh, vscode, + * jetbrains, reconnecting_pty, an app:, or chat_heartbeat) most + * recently bumped the workspace's autostop deadline. Nil if the + * workspace has never had its deadline bumped by activity. + */ + readonly last_activity_source?: string; + /** + * LastActivityAt is the time of the last activity that bumped the + * workspace's autostop deadline. Distinct from LastUsedAt, which is + * updated by a broader set of app/port-forward traffic unrelated to + * deadline bumps. + */ + readonly last_activity_at?: string; /** * DeletingAt indicates the time at which the workspace will be permanently deleted. * A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) diff --git a/site/src/utils/schedule.test.ts b/site/src/utils/schedule.test.ts index 8a00283025ee3..39ad9b40dd364 100644 --- a/site/src/utils/schedule.test.ts +++ b/site/src/utils/schedule.test.ts @@ -1,8 +1,11 @@ import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; +import { renderToStaticMarkup } from "react-dom/server"; import type { Workspace } from "#/api/typesGenerated"; import * as Mocks from "#/testHelpers/entities"; import { + activitySourceLabel, + autostopDisplay, deadlineExtensionMax, deadlineExtensionMin, extractTimezone, @@ -77,6 +80,56 @@ describe("util/schedule", () => { }); }); + describe("activitySourceLabel", () => { + it.each<[string, string]>([ + ["ssh", "SSH"], + ["vscode", "VS Code"], + ["jetbrains", "JetBrains"], + ["reconnecting_pty", "the web terminal"], + ["chat_heartbeat", "AI chat"], + ["app:my-custom-app", "the my-custom-app app"], + ["some_unrecognized_source", "some_unrecognized_source"], + ])("activitySourceLabel(%p) returns %p", (input, expected) => { + expect(activitySourceLabel(input)).toBe(expected); + }); + }); + + describe("autostopDisplay", () => { + // MockTemplate already has allow_user_autostop and + // autostop_requirement set, which is what selects the "Autostop + // schedule" tooltip branch that the activity line is appended to. + const template = Mocks.MockTemplate; + + const baseWorkspace: Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: dayjs().add(3, "hour").utc().format(), + status: "running", + }, + }; + + it("includes an activity line when last_activity_source and last_activity_at are present", () => { + const workspace: Workspace = { + ...baseWorkspace, + last_activity_source: "ssh", + last_activity_at: dayjs().subtract(15, "minute").utc().format(), + }; + + const { tooltip } = autostopDisplay(workspace, "inactive", template); + const html = renderToStaticMarkup(tooltip); + + expect(html).toContain("Activity detected from SSH"); + }); + + it("omits the activity line when last_activity_source and last_activity_at are absent", () => { + const { tooltip } = autostopDisplay(baseWorkspace, "inactive", template); + const html = renderToStaticMarkup(tooltip); + + expect(html).not.toContain("Activity detected from"); + }); + }); + describe("quietHoursDisplay", () => { it("midnight in Poland", () => { const quietHoursStart = quietHoursDisplay( diff --git a/site/src/utils/schedule.tsx b/site/src/utils/schedule.tsx index 8c58c4859e12a..d17dde3336e04 100644 --- a/site/src/utils/schedule.tsx +++ b/site/src/utils/schedule.tsx @@ -11,6 +11,7 @@ import type { Template, Workspace } from "#/api/typesGenerated"; import { HelpPopoverTitle } from "#/components/HelpPopover/HelpPopover"; import { Link } from "#/components/Link/Link"; import type { WorkspaceActivityStatus } from "#/modules/workspaces/activity"; +import { relativeTime as relativeTimeAgo } from "#/utils/time"; import { isWorkspaceOn } from "./workspace"; // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're @@ -68,6 +69,26 @@ export const autostartDisplay = (schedule: string | undefined): string => { return "Manual"; }; +const ACTIVITY_SOURCE_LABELS: Record = { + ssh: "SSH", + vscode: "VS Code", + jetbrains: "JetBrains", + reconnecting_pty: "the web terminal", + chat_heartbeat: "AI chat", +}; + +/** + * activitySourceLabel converts a workspace's last_activity_source value + * into a human-readable label. App-triggered activity is recorded as + * "app:"; unrecognized sources fall back to the raw value. + */ +export const activitySourceLabel = (source: string): string => { + if (source.startsWith("app:")) { + return `the ${source.slice("app:".length)} app`; + } + return ACTIVITY_SOURCE_LABELS[source] ?? source; +}; + const isShuttingDown = (workspace: Workspace, deadline?: Dayjs): boolean => { if (!deadline) { if (!workspace.latest_build.deadline) { @@ -147,6 +168,18 @@ export const autostopDisplay = ( ); } + + let activityLine: ReactNode = null; + if (workspace.last_activity_source && workspace.last_activity_at) { + activityLine = ( +
+ Activity detected from{" "} + {activitySourceLabel(workspace.last_activity_source)}{" "} + {relativeTimeAgo(workspace.last_activity_at)}. +
+ ); + } + return { message: `Stop ${deadline.fromNow()}`, tooltip: ( @@ -155,6 +188,7 @@ export const autostopDisplay = ( This workspace will be stopped on{" "} {deadline.format("MMMM D [at] h:mm A")} {reason} + {activityLine} ), danger: isShutdownSoon(workspace),