feat: add backend logic for determining tasks tab visibility (#18401)

This PR implements the backend logic for determining if the Tasks tab
should be visible in the web UI as described in [the
RFC](https://www.notion.so/coderhq/Coder-Tasks-207d579be5928053ab68c8d9a4b59eaa?source=copy_link#210d579be5928013ab5acbe69a2f548b).

The frontend component will be added in a follow-up PR once the entire
Tasks backend is implemented so as not to break the dogfood environment
until then.
This commit is contained in:
Hugo Dutka
2025-06-18 18:32:34 +02:00
committed by GitHub
parent 591f5db5f6
commit 8f6a5afa4f
24 changed files with 145 additions and 0 deletions
+3
View File
@@ -85,6 +85,9 @@ Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.
is detected. By default it instructs users to update using 'curl -L
https://coder.com/install.sh | sh'.
--hide-ai-tasks bool, $CODER_HIDE_AI_TASKS (default: false)
Hide AI tasks from the dashboard.
--ssh-config-options string-array, $CODER_SSH_CONFIG_OPTIONS
These SSH config options will override the default SSH config options.
Provide options in "key=value" or "key value" format separated by
+3
View File
@@ -520,6 +520,9 @@ client:
# 'webgl', or 'dom'.
# (default: canvas, type: string)
webTerminalRenderer: canvas
# Hide AI tasks from the dashboard.
# (default: false, type: bool)
hideAITasks: false
# Support links to display in the top right drop down menu.
# (default: <unset>, type: struct[[]codersdk.LinkConfig])
supportLinks: []
+3
View File
@@ -12483,6 +12483,9 @@ const docTemplate = `{
"healthcheck": {
"$ref": "#/definitions/codersdk.HealthcheckConfig"
},
"hide_ai_tasks": {
"type": "boolean"
},
"http_address": {
"description": "HTTPAddress is a string because it may be set to zero to disable.",
"type": "string"
+3
View File
@@ -11183,6 +11183,9 @@
"healthcheck": {
"$ref": "#/definitions/codersdk.HealthcheckConfig"
},
"hide_ai_tasks": {
"type": "boolean"
},
"http_address": {
"description": "HTTPAddress is a string because it may be set to zero to disable.",
"type": "string"
+1
View File
@@ -628,6 +628,7 @@ func New(options *Options) *API {
Entitlements: options.Entitlements,
Telemetry: options.Telemetry,
Logger: options.Logger.Named("site"),
HideAITasks: options.DeploymentValues.HideAITasks.Value(),
})
api.SiteHandler.Experiments.Store(&experiments)
+5
View File
@@ -3451,6 +3451,11 @@ func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now ti
return q.db.GetWorkspacesEligibleForTransition(ctx, now)
}
func (q *querier) HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) {
// Anyone can call HasTemplateVersionsWithAITask.
return q.db.HasTemplateVersionsWithAITask(ctx)
}
func (q *querier) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) {
return insert(q.log, q.auth,
rbac.ResourceApiKey.WithOwner(arg.UserID.String()),
+3
View File
@@ -4566,6 +4566,9 @@ func (s *MethodTestSuite) TestSystemFunctions() {
s.Run("GetProvisionerJobByIDForUpdate", s.Subtest(func(db database.Store, check *expects) {
check.Args(uuid.New()).Asserts(rbac.ResourceProvisionerJobs, policy.ActionRead).Errors(sql.ErrNoRows)
}))
s.Run("HasTemplateVersionsWithAITask", s.Subtest(func(db database.Store, check *expects) {
check.Args().Asserts()
}))
}
func (s *MethodTestSuite) TestNotifications() {
+13
View File
@@ -8495,6 +8495,19 @@ func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, no
return workspaces, nil
}
func (q *FakeQuerier) HasTemplateVersionsWithAITask(_ context.Context) (bool, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, templateVersion := range q.templateVersions {
if templateVersion.HasAITask {
return true, nil
}
}
return false, nil
}
func (q *FakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) {
if err := validateDatabaseType(arg); err != nil {
return database.APIKey{}, err
@@ -2041,6 +2041,13 @@ func (m queryMetricsStore) GetWorkspacesEligibleForTransition(ctx context.Contex
return workspaces, err
}
func (m queryMetricsStore) HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) {
start := time.Now()
r0, r1 := m.s.HasTemplateVersionsWithAITask(ctx)
m.queryLatencies.WithLabelValues("HasTemplateVersionsWithAITask").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) {
start := time.Now()
key, err := m.s.InsertAPIKey(ctx, arg)
+15
View File
@@ -4292,6 +4292,21 @@ func (mr *MockStoreMockRecorder) GetWorkspacesEligibleForTransition(ctx, now any
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesEligibleForTransition", reflect.TypeOf((*MockStore)(nil).GetWorkspacesEligibleForTransition), ctx, now)
}
// HasTemplateVersionsWithAITask mocks base method.
func (m *MockStore) HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HasTemplateVersionsWithAITask", ctx)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// HasTemplateVersionsWithAITask indicates an expected call of HasTemplateVersionsWithAITask.
func (mr *MockStoreMockRecorder) HasTemplateVersionsWithAITask(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasTemplateVersionsWithAITask", reflect.TypeOf((*MockStore)(nil).HasTemplateVersionsWithAITask), ctx)
}
// InTx mocks base method.
func (m *MockStore) InTx(arg0 func(database.Store) error, arg1 *database.TxOptions) error {
m.ctrl.T.Helper()
+2
View File
@@ -462,6 +462,8 @@ type sqlcQuerier interface {
GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]GetWorkspacesAndAgentsByOwnerIDRow, error)
GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceTable, error)
GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error)
// Determines if the template versions table has any rows with has_ai_task = TRUE.
HasTemplateVersionsWithAITask(ctx context.Context) (bool, error)
InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error)
// We use the organization_id as the id
// for simplicity since all users is
+12
View File
@@ -11806,6 +11806,18 @@ func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, create
return items, nil
}
const hasTemplateVersionsWithAITask = `-- name: HasTemplateVersionsWithAITask :one
SELECT EXISTS (SELECT 1 FROM template_versions WHERE has_ai_task = TRUE)
`
// Determines if the template versions table has any rows with has_ai_task = TRUE.
func (q *sqlQuerier) HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) {
row := q.db.QueryRowContext(ctx, hasTemplateVersionsWithAITask)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const insertTemplateVersion = `-- name: InsertTemplateVersion :exec
INSERT INTO
template_versions (
@@ -226,3 +226,7 @@ FROM
WHERE
template_versions.id IN (archived_versions.id)
RETURNING template_versions.id;
-- name: HasTemplateVersionsWithAITask :one
-- Determines if the template versions table has any rows with has_ai_task = TRUE.
SELECT EXISTS (SELECT 1 FROM template_versions WHERE has_ai_task = TRUE);
+11
View File
@@ -399,6 +399,7 @@ type DeploymentValues struct {
AdditionalCSPPolicy serpent.StringArray `json:"additional_csp_policy,omitempty" typescript:",notnull"`
WorkspaceHostnameSuffix serpent.String `json:"workspace_hostname_suffix,omitempty" typescript:",notnull"`
Prebuilds PrebuildsConfig `json:"workspace_prebuilds,omitempty" typescript:",notnull"`
HideAITasks serpent.Bool `json:"hide_ai_tasks,omitempty" typescript:",notnull"`
Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"`
@@ -3116,6 +3117,16 @@ Write out the current server config as YAML to stdout.`,
YAML: "failure_hard_limit",
Hidden: true,
},
{
Name: "Hide AI Tasks",
Description: "Hide AI tasks from the dashboard.",
Flag: "hide-ai-tasks",
Env: "CODER_HIDE_AI_TASKS",
Default: "false",
Value: &c.HideAITasks,
Group: &deploymentGroupClient,
YAML: "hideAITasks",
},
}
return opts
+1
View File
@@ -272,6 +272,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"refresh": 0,
"threshold_database": 0
},
"hide_ai_tasks": true,
"http_address": "string",
"http_cookies": {
"same_site": "string",
+3
View File
@@ -2443,6 +2443,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"refresh": 0,
"threshold_database": 0
},
"hide_ai_tasks": true,
"http_address": "string",
"http_cookies": {
"same_site": "string",
@@ -2943,6 +2944,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"refresh": 0,
"threshold_database": 0
},
"hide_ai_tasks": true,
"http_address": "string",
"http_cookies": {
"same_site": "string",
@@ -3243,6 +3245,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `external_auth` | [serpent.Struct-array_codersdk_ExternalAuthConfig](#serpentstruct-array_codersdk_externalauthconfig) | false | | |
| `external_token_encryption_keys` | array of string | false | | |
| `healthcheck` | [codersdk.HealthcheckConfig](#codersdkhealthcheckconfig) | false | | |
| `hide_ai_tasks` | boolean | false | | |
| `http_address` | string | false | | Http address is a string because it may be set to zero to disable. |
| `http_cookies` | [codersdk.HTTPCookieConfig](#codersdkhttpcookieconfig) | false | | |
| `in_memory_database` | boolean | false | | |
+11
View File
@@ -1614,3 +1614,14 @@ Enable Coder Inbox.
| Default | <code>5</code> |
The upper limit of attempts to send a notification.
### --hide-ai-tasks
| | |
|-------------|-----------------------------------|
| Type | <code>bool</code> |
| Environment | <code>$CODER_HIDE_AI_TASKS</code> |
| YAML | <code>client.hideAITasks</code> |
| Default | <code>false</code> |
Hide AI tasks from the dashboard.
+3
View File
@@ -86,6 +86,9 @@ Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.
is detected. By default it instructs users to update using 'curl -L
https://coder.com/install.sh | sh'.
--hide-ai-tasks bool, $CODER_HIDE_AI_TASKS (default: false)
Hide AI tasks from the dashboard.
--ssh-config-options string-array, $CODER_SSH_CONFIG_OPTIONS
These SSH config options will override the default SSH config options.
Provide options in "key=value" or "key value" format separated by
+1
View File
@@ -25,6 +25,7 @@
<meta property="regions" content="{{ .Regions }}" />
<meta property="docs-url" content="{{ .DocsURL }}" />
<meta property="logo-url" content="{{ .LogoURL }}" />
<meta property="tasks-tab-visible" content="{{ .TasksTabVisible }}" />
<!-- We need to set data-react-helmet to be able to override it in the workspace page -->
<link
rel="alternate icon"
+26
View File
@@ -85,6 +85,7 @@ type Options struct {
Entitlements *entitlements.Set
Telemetry telemetry.Reporter
Logger slog.Logger
HideAITasks bool
}
func New(opts *Options) *Handler {
@@ -316,6 +317,8 @@ type htmlState struct {
Experiments string
Regions string
DocsURL string
TasksTabVisible string
}
type csrfState struct {
@@ -445,6 +448,7 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht
var user database.User
var themePreference string
var terminalFont string
var tasksTabVisible bool
orgIDs := []uuid.UUID{}
eg.Go(func() error {
var err error
@@ -480,6 +484,20 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht
orgIDs = memberIDs[0].OrganizationIDs
return err
})
eg.Go(func() error {
// If HideAITasks is true, force hide the tasks tab
if h.opts.HideAITasks {
tasksTabVisible = false
return nil
}
hasAITask, err := h.opts.Database.HasTemplateVersionsWithAITask(ctx)
if err != nil {
return err
}
tasksTabVisible = hasAITask
return nil
})
err := eg.Wait()
if err == nil {
var wg sync.WaitGroup
@@ -550,6 +568,14 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
tasksTabVisible, err := json.Marshal(tasksTabVisible)
if err == nil {
state.TasksTabVisible = html.EscapeString(string(tasksTabVisible))
}
}()
wg.Wait()
}
+1
View File
@@ -736,6 +736,7 @@ export interface DeploymentValues {
readonly additional_csp_policy?: string;
readonly workspace_hostname_suffix?: string;
readonly workspace_prebuilds?: PrebuildsConfig;
readonly hide_ai_tasks?: boolean;
readonly config?: string;
readonly write_config?: boolean;
readonly address?: string;
@@ -5,6 +5,7 @@ import {
MockBuildInfo,
MockEntitlements,
MockExperiments,
MockTasksTabVisible,
MockUserAppearanceSettings,
MockUserOwner,
} from "testHelpers/entities";
@@ -41,6 +42,7 @@ const mockDataForTags = {
user: MockUserOwner,
userAppearance: MockUserAppearanceSettings,
regions: MockRegions,
tasksTabVisible: MockTasksTabVisible,
} as const satisfies Record<MetadataKey, MetadataValue>;
const emptyMetadata: RuntimeHtmlMetadata = {
@@ -72,6 +74,10 @@ const emptyMetadata: RuntimeHtmlMetadata = {
available: false,
value: undefined,
},
tasksTabVisible: {
available: false,
value: undefined,
},
};
const populatedMetadata: RuntimeHtmlMetadata = {
@@ -103,6 +109,10 @@ const populatedMetadata: RuntimeHtmlMetadata = {
available: true,
value: MockUserAppearanceSettings,
},
tasksTabVisible: {
available: true,
value: MockTasksTabVisible,
},
};
function seedInitialMetadata(metadataKey: string): () => void {
+2
View File
@@ -30,6 +30,7 @@ type AvailableMetadata = Readonly<{
entitlements: Entitlements;
regions: readonly Region[];
"build-info": BuildInfoResponse;
tasksTabVisible: boolean;
}>;
export type MetadataKey = keyof AvailableMetadata;
@@ -91,6 +92,7 @@ export class MetadataManager implements MetadataManagerApi {
experiments: this.registerValue<Experiments>("experiments"),
"build-info": this.registerValue<BuildInfoResponse>("build-info"),
regions: this.registerRegionValue(),
tasksTabVisible: this.registerValue<boolean>("tasksTabVisible"),
};
}
+2
View File
@@ -534,6 +534,8 @@ export const MockUserAppearanceSettings: TypesGen.UserAppearanceSettings = {
terminal_font: "",
};
export const MockTasksTabVisible: boolean = false;
export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = {
organization_id: MockOrganization.id,
user_id: MockUserOwner.id,