From 2871a023524c88925f889ac324a4b3d07ae39ebb Mon Sep 17 00:00:00 2001 From: 35C4n0r <70096901+35C4n0r@users.noreply.github.com> Date: Thu, 14 May 2026 21:49:05 +0530 Subject: [PATCH] fix: use actual ai_task instances for HasAITasks (#25197) Previously, `hasAITaskResources()` scanned the Terraform graph for `coder_ai_task` node labels. The graph includes resource definitions regardless of `count`, so templates with `count = 0` were incorrectly marked as `HasAITasks = true`, causing them to appear on the `/tasks` page when no AI task resources would be created. Replace the graph-based check with `len(aiTasks) > 0`. The `aiTasks` slice is populated from state modules where Terraform has already evaluated `count`, so it correctly reflects actual resource instances. ref: https://linear.app/codercom/issue/ECO-39/make-coder-tasks-respect-count > Generated with [Coder Agents](https://coder.com/agents) --------- Signed-off-by: 35C4n0r Signed-off-by: Jay Kumar --- provisioner/terraform/resources.go | 23 +-- provisioner/terraform/resources_test.go | 26 ++++ .../ai-tasks-disabled.tfplan.dot | 20 +++ .../ai-tasks-disabled.tfplan.json | 139 ++++++++++++++++++ .../converted_state.plan.golden | 9 ++ .../resources/ai-tasks-disabled/main.tf | 17 +++ provisionersdk/proto/provisioner.pb.go | 7 +- provisionersdk/proto/provisioner.proto | 7 +- site/e2e/provisionerGenerated.ts | 7 +- 9 files changed, 218 insertions(+), 37 deletions(-) create mode 100644 provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.dot create mode 100644 provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json create mode 100644 provisioner/terraform/testdata/resources/ai-tasks-disabled/converted_state.plan.golden create mode 100644 provisioner/terraform/testdata/resources/ai-tasks-disabled/main.tf diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index d4c19038af..9edd68aa86 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -173,25 +173,6 @@ type State struct { var ErrInvalidTerraformAddr = xerrors.New("invalid terraform address") -// hasAITaskResources is used to determine if a template has *any* `coder_ai_task` resources defined. During template -// import, it's possible that none of these have `count=1` since count may be dependent on the value of a `coder_parameter` -// or something else. -// We need to know at template import if these resources exist to inform the frontend of their existence. -func hasAITaskResources(graph *gographviz.Graph) bool { - for _, node := range graph.Nodes.Lookup { - // Check if this node is a coder_ai_task resource - if label, exists := node.Attrs["label"]; exists { - labelValue := strings.Trim(label, `"`) - // The first condition is for the case where the resource is in the root module. - // The second condition is for the case where the resource is in a child module. - if strings.HasPrefix(labelValue, "coder_ai_task.") || strings.Contains(labelValue, ".coder_ai_task.") { - return true - } - } - } - return false -} - func hasExternalAgentResources(graph *gographviz.Graph) bool { for _, node := range graph.Nodes.Lookup { if label, exists := node.Attrs["label"]; exists { @@ -1083,14 +1064,12 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s slices.SortFunc(externalAuthProviders, func(a, b *proto.ExternalAuthProviderResource) int { return cmp.Compare(a.Id, b.Id) }) - hasAITasks := hasAITaskResources(graph) - return &State{ Resources: resources, Parameters: parameters, Presets: presets, ExternalAuthProviders: externalAuthProviders, - HasAITasks: hasAITasks, + HasAITasks: len(aiTasks) > 0, AITasks: aiTasks, HasExternalAgents: hasExternalAgentResources(graph), }, nil diff --git a/provisioner/terraform/resources_test.go b/provisioner/terraform/resources_test.go index 5ddaaeeef2..a2dbe10859 100644 --- a/provisioner/terraform/resources_test.go +++ b/provisioner/terraform/resources_test.go @@ -1764,6 +1764,32 @@ func TestAITasks(t *testing.T) { require.Equal(t, "5ece4674-dd35-4f16-88c8-82e40e72e2fd", sidebarApp.GetId()) require.Equal(t, "5ece4674-dd35-4f16-88c8-82e40e72e2fd", state.AITasks[0].AppId) }) + + t.Run("Disabled with count zero", func(t *testing.T) { + t.Parallel() + + // nolint:dogsled + _, filename, _, _ := runtime.Caller(0) + + // This fixture has coder_ai_task.a in the graph (resource is defined + // in the .tf file) but NOT in PlannedValues (count = 0). The old + // graph-based check returned true here; the new len(aiTasks) > 0 + // check should return false. + dir := filepath.Join(filepath.Dir(filename), "testdata", "resources", "ai-tasks-disabled") + tfPlanRaw, err := os.ReadFile(filepath.Join(dir, "ai-tasks-disabled.tfplan.json")) + require.NoError(t, err) + var tfPlan tfjson.Plan + err = json.Unmarshal(tfPlanRaw, &tfPlan) + require.NoError(t, err) + tfPlanGraph, err := os.ReadFile(filepath.Join(dir, "ai-tasks-disabled.tfplan.dot")) + require.NoError(t, err) + + state, err := terraform.ConvertState(ctx, []*tfjson.StateModule{tfPlan.PlannedValues.RootModule, tfPlan.PriorState.Values.RootModule}, string(tfPlanGraph), logger) + require.NotNil(t, state) + require.NoError(t, err) + require.False(t, state.HasAITasks) + require.Empty(t, state.AITasks) + }) } func TestExternalAgents(t *testing.T) { diff --git a/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.dot b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.dot new file mode 100644 index 0000000000..c36ff53236 --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.dot @@ -0,0 +1,20 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_ai_task.a (expand)" [label = "coder_ai_task.a", shape = "box"] + "[root] data.coder_provisioner.me (expand)" [label = "data.coder_provisioner.me", shape = "box"] + "[root] data.coder_workspace.me (expand)" [label = "data.coder_workspace.me", shape = "box"] + "[root] data.coder_workspace_owner.me (expand)" [label = "data.coder_workspace_owner.me", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] coder_ai_task.a (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_provisioner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_owner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_ai_task.a (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_provisioner.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_owner.me (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json new file mode 100644 index 0000000000..455f32871c --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json @@ -0,0 +1,139 @@ +{ + "format_version": "1.2", + "terraform_version": "1.15.2", + "planned_values": { + "root_module": {} + }, + "prior_state": { + "format_version": "1.0", + "terraform_version": "1.15.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "arch": "amd64", + "id": "4bd4900e-85ab-4f4e-9378-153f9630d2aa", + "os": "linux" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "access_port": 443, + "access_url": "https://dev.coder.com/", + "id": "f8c4851f-dcbd-48bc-9a14-3fd506f8f015", + "is_prebuild": false, + "is_prebuild_claim": false, + "name": "ai-task-plan-check", + "prebuild_count": 0, + "start_count": 1, + "template_id": "", + "template_name": "", + "template_version": "", + "transition": "start" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 0, + "values": { + "email": "default@example.com", + "full_name": "default", + "groups": [], + "id": "33769beb-1777-4d16-8774-2da632ca9611", + "login_type": null, + "name": "default", + "oidc_access_token": "", + "rbac_roles": [], + "session_token": "", + "ssh_private_key": "", + "ssh_public_key": "" + }, + "sensitive_values": { + "groups": [], + "oidc_access_token": true, + "rbac_roles": [], + "session_token": true, + "ssh_private_key": true + } + } + ] + } + } + }, + "configuration": { + "provider_config": { + "coder": { + "name": "coder", + "full_name": "registry.terraform.io/coder/coder", + "version_constraint": ">= 2.0.0" + } + }, + "root_module": { + "resources": [ + { + "address": "coder_ai_task.a", + "mode": "managed", + "type": "coder_ai_task", + "name": "a", + "provider_config_key": "coder", + "expressions": { + "app_id": { + "constant_value": "5ece4674-dd35-4f16-88c8-82e40e72e2fd" + } + }, + "schema_version": 1, + "count_expression": { + "constant_value": 0 + } + }, + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_config_key": "coder", + "schema_version": 1 + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_config_key": "coder", + "schema_version": 1 + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_config_key": "coder", + "schema_version": 0 + } + ] + } + }, + "timestamp": "2026-05-13T11:32:56Z", + "applyable": false, + "complete": true, + "errored": false +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-disabled/converted_state.plan.golden b/provisioner/terraform/testdata/resources/ai-tasks-disabled/converted_state.plan.golden new file mode 100644 index 0000000000..546cb9a6e0 --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-disabled/converted_state.plan.golden @@ -0,0 +1,9 @@ +{ + "Resources": [], + "Parameters": [], + "Presets": [], + "ExternalAuthProviders": [], + "AITasks": [], + "HasAITasks": false, + "HasExternalAgents": false +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-disabled/main.tf b/provisioner/terraform/testdata/resources/ai-tasks-disabled/main.tf new file mode 100644 index 0000000000..c82b29307a --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-disabled/main.tf @@ -0,0 +1,17 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.0.0" + } + } +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_ai_task" "a" { + count = 0 + app_id = "5ece4674-dd35-4f16-88c8-82e40e72e2fd" +} diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index f29118942c..4075fadc4e 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -4030,11 +4030,8 @@ type GraphComplete struct { Parameters []*RichParameter `protobuf:"bytes,4,rep,name=parameters,proto3" json:"parameters,omitempty"` ExternalAuthProviders []*ExternalAuthProviderResource `protobuf:"bytes,5,rep,name=external_auth_providers,json=externalAuthProviders,proto3" json:"external_auth_providers,omitempty"` Presets []*Preset `protobuf:"bytes,6,rep,name=presets,proto3" json:"presets,omitempty"` - // Whether a template has any `coder_ai_task` resources defined, even if not planned for creation. - // During a template import, a plan is run which may not yield in any `coder_ai_task` resources, but nonetheless we - // still need to know that such resources are defined. - // - // See `hasAITaskResources` in provisioner/terraform/resources.go for more details. + // Whether actual `coder_ai_task` resource instances exist. + // Resources defined with count = 0 do not set this flag. HasAiTasks bool `protobuf:"varint,7,opt,name=has_ai_tasks,json=hasAiTasks,proto3" json:"has_ai_tasks,omitempty"` AiTasks []*AITask `protobuf:"bytes,8,rep,name=ai_tasks,json=aiTasks,proto3" json:"ai_tasks,omitempty"` HasExternalAgents bool `protobuf:"varint,9,opt,name=has_external_agents,json=hasExternalAgents,proto3" json:"has_external_agents,omitempty"` diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index 36b8a75cc7..89f9f9bf92 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -504,11 +504,8 @@ message GraphComplete { repeated RichParameter parameters = 4; repeated ExternalAuthProviderResource external_auth_providers = 5; repeated Preset presets = 6; - // Whether a template has any `coder_ai_task` resources defined, even if not planned for creation. - // During a template import, a plan is run which may not yield in any `coder_ai_task` resources, but nonetheless we - // still need to know that such resources are defined. - // - // See `hasAITaskResources` in provisioner/terraform/resources.go for more details. + // Whether actual `coder_ai_task` resource instances exist. + // Resources defined with count = 0 do not set this flag. bool has_ai_tasks = 7; repeated provisioner.AITask ai_tasks = 8; bool has_external_agents = 9; diff --git a/site/e2e/provisionerGenerated.ts b/site/e2e/provisionerGenerated.ts index 3057abbe8d..838d92c3f5 100644 --- a/site/e2e/provisionerGenerated.ts +++ b/site/e2e/provisionerGenerated.ts @@ -562,11 +562,8 @@ export interface GraphComplete { externalAuthProviders: ExternalAuthProviderResource[]; presets: Preset[]; /** - * Whether a template has any `coder_ai_task` resources defined, even if not planned for creation. - * During a template import, a plan is run which may not yield in any `coder_ai_task` resources, but nonetheless we - * still need to know that such resources are defined. - * - * See `hasAITaskResources` in provisioner/terraform/resources.go for more details. + * Whether actual `coder_ai_task` resource instances exist. + * Resources defined with count = 0 do not set this flag. */ hasAiTasks: boolean; aiTasks: AITask[];