diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index b67e3d9390..deb1005138 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -394,6 +394,7 @@ func WorkspaceAgentDevcontainer(t testing.TB, db database.Store, orig database.W Name: []string{takeFirst(orig.Name, testutil.GetRandomName(t))}, WorkspaceFolder: []string{takeFirst(orig.WorkspaceFolder, "/workspace")}, ConfigPath: []string{takeFirst(orig.ConfigPath, "")}, + SubagentID: []uuid.UUID{orig.SubagentID.UUID}, }) require.NoError(t, err, "insert workspace agent devcontainer") return devcontainers[0] diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ab9c78970e..ef17fe96e2 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2457,7 +2457,8 @@ CREATE TABLE workspace_agent_devcontainers ( created_at timestamp with time zone DEFAULT now() NOT NULL, workspace_folder text NOT NULL, config_path text NOT NULL, - name text NOT NULL + name text NOT NULL, + subagent_id uuid ); COMMENT ON TABLE workspace_agent_devcontainers IS 'Workspace agent devcontainer configuration'; @@ -3737,6 +3738,9 @@ ALTER TABLE ONLY user_status_changes ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY workspace_agent_devcontainers + ADD CONSTRAINT workspace_agent_devcontainers_subagent_id_fkey FOREIGN KEY (subagent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 2d5f65e209..81f1fda004 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -72,6 +72,7 @@ const ( ForeignKeyUserSecretsUserID ForeignKeyConstraint = "user_secrets_user_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentDevcontainersSubagentID ForeignKeyConstraint = "workspace_agent_devcontainers_subagent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_subagent_id_fkey FOREIGN KEY (subagent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentDevcontainersWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_devcontainers_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentLogSourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_log_sources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentMemoryResourceMonitorsAgentID ForeignKeyConstraint = "workspace_agent_memory_resource_monitors_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000413_add_subagent_id_to_dev_containers.down.sql b/coderd/database/migrations/000413_add_subagent_id_to_dev_containers.down.sql new file mode 100644 index 0000000000..9f4901cc74 --- /dev/null +++ b/coderd/database/migrations/000413_add_subagent_id_to_dev_containers.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE workspace_agent_devcontainers + DROP COLUMN subagent_id; diff --git a/coderd/database/migrations/000413_add_subagent_id_to_dev_containers.up.sql b/coderd/database/migrations/000413_add_subagent_id_to_dev_containers.up.sql new file mode 100644 index 0000000000..c90adc86de --- /dev/null +++ b/coderd/database/migrations/000413_add_subagent_id_to_dev_containers.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE workspace_agent_devcontainers + ADD COLUMN subagent_id UUID REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/models.go b/coderd/database/models.go index 7a9e8962a7..3f5ada9693 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4771,7 +4771,8 @@ type WorkspaceAgentDevcontainer struct { // Path to devcontainer.json. ConfigPath string `db:"config_path" json:"config_path"` // The name of the Dev Container. - Name string `db:"name" json:"name"` + Name string `db:"name" json:"name"` + SubagentID uuid.NullUUID `db:"subagent_id" json:"subagent_id"` } type WorkspaceAgentLog struct { diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 50863395cf..8e00b5dad3 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -7,7 +7,9 @@ import ( "errors" "fmt" "net" + "slices" "sort" + "strings" "testing" "time" @@ -8482,3 +8484,103 @@ func TestGetAuthenticatedWorkspaceAgentAndBuildByAuthToken_ShutdownScripts(t *te require.ErrorIs(t, err, sql.ErrNoRows, "agent should not authenticate when latest build is not STOP") }) } + +// Our `InsertWorkspaceAgentDevcontainers` query should ideally be `[]uuid.NullUUID` but unfortunately +// sqlc infers it as `[]uuid.UUID`. To ensure we don't insert a `uuid.Nil`, the query inserts NULL when +// passed with `uuid.Nil`. This test ensures we keep this behavior without regression. +func TestInsertWorkspaceAgentDevcontainers(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + validSubagent []bool + }{ + {"BothValid", []bool{true, true}}, + {"FirstValidSecondInvalid", []bool{true, false}}, + {"FirstInvalidSecondValid", []bool{false, true}}, + {"BothInvalid", []bool{false, false}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + db, _ = dbtestutil.NewDB(t) + org = dbgen.Organization(t, db, database.Organization{}) + job = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeTemplateVersionImport, + OrganizationID: org.ID, + }) + resource = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{JobID: job.ID}) + agent = dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: resource.ID}) + ) + + ids := make([]uuid.UUID, len(tc.validSubagent)) + names := make([]string, len(tc.validSubagent)) + workspaceFolders := make([]string, len(tc.validSubagent)) + configPaths := make([]string, len(tc.validSubagent)) + subagentIDs := make([]uuid.UUID, len(tc.validSubagent)) + + for i, valid := range tc.validSubagent { + ids[i] = uuid.New() + names[i] = fmt.Sprintf("test-devcontainer-%d", i) + workspaceFolders[i] = fmt.Sprintf("/workspace%d", i) + configPaths[i] = fmt.Sprintf("/workspace%d/.devcontainer/devcontainer.json", i) + + if valid { + subagentIDs[i] = dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + ParentID: uuid.NullUUID{UUID: agent.ID, Valid: true}, + }).ID + } else { + subagentIDs[i] = uuid.Nil + } + } + + ctx := testutil.Context(t, testutil.WaitShort) + + // Given: We insert multiple devcontainer records. + devcontainers, err := db.InsertWorkspaceAgentDevcontainers(ctx, database.InsertWorkspaceAgentDevcontainersParams{ + WorkspaceAgentID: agent.ID, + CreatedAt: dbtime.Now(), + ID: ids, + Name: names, + WorkspaceFolder: workspaceFolders, + ConfigPath: configPaths, + SubagentID: subagentIDs, + }) + require.NoError(t, err) + require.Len(t, devcontainers, len(tc.validSubagent)) + + // Then: Verify each devcontainer has the correct SubagentID validity. + // - When we pass `uuid.Nil`, we get a `uuid.NullUUID{Valid: false}` + // - When we pass a valid UUID, we get a `uuid.NullUUID{Valid: true}` + for i, valid := range tc.validSubagent { + require.Equal(t, valid, devcontainers[i].SubagentID.Valid, "devcontainer %d: subagent_id validity mismatch", i) + if valid { + require.Equal(t, subagentIDs[i], devcontainers[i].SubagentID.UUID, "devcontainer %d: subagent_id UUID mismatch", i) + } + } + + // Perform the same check on data returned by + // `GetWorkspaceAgentDevcontainersByAgentID` to ensure the fix is at + // the data storage layer, instead of just at a query level. + fetched, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, agent.ID) + require.NoError(t, err) + require.Len(t, fetched, len(tc.validSubagent)) + + // Sort fetched by name to ensure consistent ordering for comparison. + slices.SortFunc(fetched, func(a, b database.WorkspaceAgentDevcontainer) int { + return strings.Compare(a.Name, b.Name) + }) + + for i, valid := range tc.validSubagent { + require.Equal(t, valid, fetched[i].SubagentID.Valid, "fetched devcontainer %d: subagent_id validity mismatch", i) + if valid { + require.Equal(t, subagentIDs[i], fetched[i].SubagentID.UUID, "fetched devcontainer %d: subagent_id UUID mismatch", i) + } + } + }) + } +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e4552a2ff1..98069f42d6 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17213,7 +17213,7 @@ func (q *sqlQuerier) ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) ( const getWorkspaceAgentDevcontainersByAgentID = `-- name: GetWorkspaceAgentDevcontainersByAgentID :many SELECT - id, workspace_agent_id, created_at, workspace_folder, config_path, name + id, workspace_agent_id, created_at, workspace_folder, config_path, name, subagent_id FROM workspace_agent_devcontainers WHERE @@ -17238,6 +17238,7 @@ func (q *sqlQuerier) GetWorkspaceAgentDevcontainersByAgentID(ctx context.Context &i.WorkspaceFolder, &i.ConfigPath, &i.Name, + &i.SubagentID, ); err != nil { return nil, err } @@ -17254,15 +17255,16 @@ func (q *sqlQuerier) GetWorkspaceAgentDevcontainersByAgentID(ctx context.Context const insertWorkspaceAgentDevcontainers = `-- name: InsertWorkspaceAgentDevcontainers :many INSERT INTO - workspace_agent_devcontainers (workspace_agent_id, created_at, id, name, workspace_folder, config_path) + workspace_agent_devcontainers (workspace_agent_id, created_at, id, name, workspace_folder, config_path, subagent_id) SELECT $1::uuid AS workspace_agent_id, $2::timestamptz AS created_at, unnest($3::uuid[]) AS id, unnest($4::text[]) AS name, unnest($5::text[]) AS workspace_folder, - unnest($6::text[]) AS config_path -RETURNING workspace_agent_devcontainers.id, workspace_agent_devcontainers.workspace_agent_id, workspace_agent_devcontainers.created_at, workspace_agent_devcontainers.workspace_folder, workspace_agent_devcontainers.config_path, workspace_agent_devcontainers.name + unnest($6::text[]) AS config_path, + NULLIF(unnest($7::uuid[]), '00000000-0000-0000-0000-000000000000')::uuid AS subagent_id +RETURNING workspace_agent_devcontainers.id, workspace_agent_devcontainers.workspace_agent_id, workspace_agent_devcontainers.created_at, workspace_agent_devcontainers.workspace_folder, workspace_agent_devcontainers.config_path, workspace_agent_devcontainers.name, workspace_agent_devcontainers.subagent_id ` type InsertWorkspaceAgentDevcontainersParams struct { @@ -17272,6 +17274,7 @@ type InsertWorkspaceAgentDevcontainersParams struct { Name []string `db:"name" json:"name"` WorkspaceFolder []string `db:"workspace_folder" json:"workspace_folder"` ConfigPath []string `db:"config_path" json:"config_path"` + SubagentID []uuid.UUID `db:"subagent_id" json:"subagent_id"` } func (q *sqlQuerier) InsertWorkspaceAgentDevcontainers(ctx context.Context, arg InsertWorkspaceAgentDevcontainersParams) ([]WorkspaceAgentDevcontainer, error) { @@ -17282,6 +17285,7 @@ func (q *sqlQuerier) InsertWorkspaceAgentDevcontainers(ctx context.Context, arg pq.Array(arg.Name), pq.Array(arg.WorkspaceFolder), pq.Array(arg.ConfigPath), + pq.Array(arg.SubagentID), ) if err != nil { return nil, err @@ -17297,6 +17301,7 @@ func (q *sqlQuerier) InsertWorkspaceAgentDevcontainers(ctx context.Context, arg &i.WorkspaceFolder, &i.ConfigPath, &i.Name, + &i.SubagentID, ); err != nil { return nil, err } diff --git a/coderd/database/queries/workspaceagentdevcontainers.sql b/coderd/database/queries/workspaceagentdevcontainers.sql index b8a4f066ce..40bcf7cf5a 100644 --- a/coderd/database/queries/workspaceagentdevcontainers.sql +++ b/coderd/database/queries/workspaceagentdevcontainers.sql @@ -1,13 +1,14 @@ -- name: InsertWorkspaceAgentDevcontainers :many INSERT INTO - workspace_agent_devcontainers (workspace_agent_id, created_at, id, name, workspace_folder, config_path) + workspace_agent_devcontainers (workspace_agent_id, created_at, id, name, workspace_folder, config_path, subagent_id) SELECT @workspace_agent_id::uuid AS workspace_agent_id, @created_at::timestamptz AS created_at, unnest(@id::uuid[]) AS id, unnest(@name::text[]) AS name, unnest(@workspace_folder::text[]) AS workspace_folder, - unnest(@config_path::text[]) AS config_path + unnest(@config_path::text[]) AS config_path, + NULLIF(unnest(@subagent_id::uuid[]), '00000000-0000-0000-0000-000000000000')::uuid AS subagent_id RETURNING workspace_agent_devcontainers.*; -- name: GetWorkspaceAgentDevcontainersByAgentID :many diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index f8947a7fb5..8ea3a708d1 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -2031,6 +2031,23 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro appIDs = append(appIDs, app.GetId()) agentIDByAppID[app.GetId()] = agentID } + + // Subagents in devcontainers can also have apps that need + // tracking for task linking, just like the parent agent's + // apps above. + for _, dc := range protoAgent.GetDevcontainers() { + dc.Id = uuid.New().String() + + if dc.GetSubagentId() != "" { + subAgentID := uuid.New() + dc.SubagentId = subAgentID.String() + + for _, app := range dc.GetApps() { + appIDs = append(appIDs, app.GetId()) + agentIDByAppID[app.GetId()] = subAgentID + } + } + } } err = InsertWorkspaceResource( @@ -2859,33 +2876,7 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. } } - logSourceIDs := make([]uuid.UUID, 0, len(prAgent.Scripts)) - logSourceDisplayNames := make([]string, 0, len(prAgent.Scripts)) - logSourceIcons := make([]string, 0, len(prAgent.Scripts)) - scriptIDs := make([]uuid.UUID, 0, len(prAgent.Scripts)) - scriptDisplayName := make([]string, 0, len(prAgent.Scripts)) - scriptLogPaths := make([]string, 0, len(prAgent.Scripts)) - scriptSources := make([]string, 0, len(prAgent.Scripts)) - scriptCron := make([]string, 0, len(prAgent.Scripts)) - scriptTimeout := make([]int32, 0, len(prAgent.Scripts)) - scriptStartBlocksLogin := make([]bool, 0, len(prAgent.Scripts)) - scriptRunOnStart := make([]bool, 0, len(prAgent.Scripts)) - scriptRunOnStop := make([]bool, 0, len(prAgent.Scripts)) - - for _, script := range prAgent.Scripts { - logSourceIDs = append(logSourceIDs, uuid.New()) - logSourceDisplayNames = append(logSourceDisplayNames, script.DisplayName) - logSourceIcons = append(logSourceIcons, script.Icon) - scriptIDs = append(scriptIDs, uuid.New()) - scriptDisplayName = append(scriptDisplayName, script.DisplayName) - scriptLogPaths = append(scriptLogPaths, script.LogPath) - scriptSources = append(scriptSources, script.Script) - scriptCron = append(scriptCron, script.Cron) - scriptTimeout = append(scriptTimeout, script.TimeoutSeconds) - scriptStartBlocksLogin = append(scriptStartBlocksLogin, script.StartBlocksLogin) - scriptRunOnStart = append(scriptRunOnStart, script.RunOnStart) - scriptRunOnStop = append(scriptRunOnStop, script.RunOnStop) - } + scriptsParams := agentScriptsFromProto(prAgent.Scripts) // Dev Containers require a script and log/source, so we do this before // the logs insert below. @@ -2895,32 +2886,46 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. devcontainerNames = make([]string, 0, len(devcontainers)) devcontainerWorkspaceFolders = make([]string, 0, len(devcontainers)) devcontainerConfigPaths = make([]string, 0, len(devcontainers)) + devcontainerSubagentIDs = make([]uuid.UUID, 0, len(devcontainers)) ) for _, dc := range devcontainers { id := uuid.New() + if opts.useAgentIDsFromProto { + id, err = uuid.Parse(dc.GetId()) + if err != nil { + return xerrors.Errorf("invalid devcontainer ID format; must be uuid: %w", err) + } + } + + subAgentID, err := insertDevcontainerSubagent(ctx, db, dc, dbAgent, resource.ID, appSlugs, snapshot, opts) + if err != nil { + return xerrors.Errorf("insert devcontainer %q subagent: %w", dc.GetName(), err) + } + devcontainerIDs = append(devcontainerIDs, id) - devcontainerNames = append(devcontainerNames, dc.Name) - devcontainerWorkspaceFolders = append(devcontainerWorkspaceFolders, dc.WorkspaceFolder) - devcontainerConfigPaths = append(devcontainerConfigPaths, dc.ConfigPath) + devcontainerNames = append(devcontainerNames, dc.GetName()) + devcontainerWorkspaceFolders = append(devcontainerWorkspaceFolders, dc.GetWorkspaceFolder()) + devcontainerConfigPaths = append(devcontainerConfigPaths, dc.GetConfigPath()) + devcontainerSubagentIDs = append(devcontainerSubagentIDs, subAgentID) // Add a log source and script for each devcontainer so we can // track logs and timings for each devcontainer. - displayName := fmt.Sprintf("Dev Container (%s)", dc.Name) - logSourceIDs = append(logSourceIDs, uuid.New()) - logSourceDisplayNames = append(logSourceDisplayNames, displayName) - logSourceIcons = append(logSourceIcons, "/emojis/1f4e6.png") // Emoji package. Or perhaps /icon/container.svg? - scriptIDs = append(scriptIDs, id) // Re-use the devcontainer ID as the script ID for identification. - scriptDisplayName = append(scriptDisplayName, displayName) - scriptLogPaths = append(scriptLogPaths, "") - scriptSources = append(scriptSources, `echo "WARNING: Dev Containers are early access. If you're seeing this message then Dev Containers haven't been enabled for your workspace yet. To enable, the agent needs to run with the environment variable CODER_AGENT_DEVCONTAINERS_ENABLE=true set."`) - scriptCron = append(scriptCron, "") - scriptTimeout = append(scriptTimeout, 0) - scriptStartBlocksLogin = append(scriptStartBlocksLogin, false) + displayName := fmt.Sprintf("Dev Container (%s)", dc.GetName()) + scriptsParams.LogSourceIDs = append(scriptsParams.LogSourceIDs, uuid.New()) + scriptsParams.LogSourceDisplayNames = append(scriptsParams.LogSourceDisplayNames, displayName) + scriptsParams.LogSourceIcons = append(scriptsParams.LogSourceIcons, "/emojis/1f4e6.png") // Emoji package. Or perhaps /icon/container.svg? + scriptsParams.ScriptIDs = append(scriptsParams.ScriptIDs, id) // Re-use the devcontainer ID as the script ID for identification. + scriptsParams.ScriptDisplayNames = append(scriptsParams.ScriptDisplayNames, displayName) + scriptsParams.ScriptLogPaths = append(scriptsParams.ScriptLogPaths, "") + scriptsParams.ScriptSources = append(scriptsParams.ScriptSources, `echo "WARNING: Dev Containers are early access. If you're seeing this message then Dev Containers haven't been enabled for your workspace yet. To enable, the agent needs to run with the environment variable CODER_AGENT_DEVCONTAINERS_ENABLE=true set."`) + scriptsParams.ScriptCron = append(scriptsParams.ScriptCron, "") + scriptsParams.ScriptTimeout = append(scriptsParams.ScriptTimeout, 0) + scriptsParams.ScriptStartBlocksLogin = append(scriptsParams.ScriptStartBlocksLogin, false) // Run on start to surface the warning message in case the // terraform resource is used, but the experiment hasn't // been enabled. - scriptRunOnStart = append(scriptRunOnStart, true) - scriptRunOnStop = append(scriptRunOnStop, false) + scriptsParams.ScriptRunOnStart = append(scriptsParams.ScriptRunOnStart, true) + scriptsParams.ScriptRunOnStop = append(scriptsParams.ScriptRunOnStop, false) } _, err = db.InsertWorkspaceAgentDevcontainers(ctx, database.InsertWorkspaceAgentDevcontainersParams{ @@ -2930,131 +2935,21 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. Name: devcontainerNames, WorkspaceFolder: devcontainerWorkspaceFolders, ConfigPath: devcontainerConfigPaths, + SubagentID: devcontainerSubagentIDs, }) if err != nil { return xerrors.Errorf("insert agent devcontainer: %w", err) } } - _, err = db.InsertWorkspaceAgentLogSources(ctx, database.InsertWorkspaceAgentLogSourcesParams{ - WorkspaceAgentID: agentID, - ID: logSourceIDs, - CreatedAt: dbtime.Now(), - DisplayName: logSourceDisplayNames, - Icon: logSourceIcons, - }) - if err != nil { - return xerrors.Errorf("insert agent log sources: %w", err) - } - - _, err = db.InsertWorkspaceAgentScripts(ctx, database.InsertWorkspaceAgentScriptsParams{ - WorkspaceAgentID: agentID, - LogSourceID: logSourceIDs, - LogPath: scriptLogPaths, - CreatedAt: dbtime.Now(), - Script: scriptSources, - Cron: scriptCron, - TimeoutSeconds: scriptTimeout, - StartBlocksLogin: scriptStartBlocksLogin, - RunOnStart: scriptRunOnStart, - RunOnStop: scriptRunOnStop, - DisplayName: scriptDisplayName, - ID: scriptIDs, - }) - if err != nil { - return xerrors.Errorf("insert agent scripts: %w", err) + if err := insertAgentScriptsAndLogSources(ctx, db, agentID, scriptsParams); err != nil { + return xerrors.Errorf("insert agent scripts and log sources: %w", err) } for _, app := range prAgent.Apps { - // Similar logic is duplicated in terraform/resources.go. - slug := app.Slug - if slug == "" { - return xerrors.Errorf("app must have a slug or name set") + if err := insertAgentApp(ctx, db, dbAgent.ID, app, appSlugs, snapshot); err != nil { + return xerrors.Errorf("insert agent app: %w", err) } - // Contrary to agent names above, app slugs were never permitted to - // contain uppercase letters or underscores. - if !provisioner.AppSlugRegex.MatchString(slug) { - return xerrors.Errorf("app slug %q does not match regex %q", slug, provisioner.AppSlugRegex.String()) - } - if _, exists := appSlugs[slug]; exists { - return xerrors.Errorf("duplicate app slug, must be unique per template: %q", slug) - } - appSlugs[slug] = struct{}{} - - health := database.WorkspaceAppHealthDisabled - if app.Healthcheck == nil { - app.Healthcheck = &sdkproto.Healthcheck{} - } - if app.Healthcheck.Url != "" { - health = database.WorkspaceAppHealthInitializing - } - - sharingLevel := database.AppSharingLevelOwner - switch app.SharingLevel { - case sdkproto.AppSharingLevel_AUTHENTICATED: - sharingLevel = database.AppSharingLevelAuthenticated - case sdkproto.AppSharingLevel_PUBLIC: - sharingLevel = database.AppSharingLevelPublic - } - - displayGroup := sql.NullString{ - Valid: app.Group != "", - String: app.Group, - } - - openIn := database.WorkspaceAppOpenInSlimWindow - switch app.OpenIn { - case sdkproto.AppOpenIn_TAB: - openIn = database.WorkspaceAppOpenInTab - case sdkproto.AppOpenIn_SLIM_WINDOW: - openIn = database.WorkspaceAppOpenInSlimWindow - } - - var appID string - if app.Id == "" || app.Id == uuid.Nil.String() { - appID = uuid.NewString() - } else { - appID = app.Id - } - id, err := uuid.Parse(appID) - if err != nil { - return xerrors.Errorf("parse app uuid: %w", err) - } - - // If workspace apps are "persistent", the ID will not be regenerated across workspace builds, so we have to upsert. - dbApp, err := db.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{ - ID: id, - CreatedAt: dbtime.Now(), - AgentID: dbAgent.ID, - Slug: slug, - DisplayName: app.DisplayName, - Icon: app.Icon, - Command: sql.NullString{ - String: app.Command, - Valid: app.Command != "", - }, - Url: sql.NullString{ - String: app.Url, - Valid: app.Url != "", - }, - External: app.External, - Subdomain: app.Subdomain, - SharingLevel: sharingLevel, - HealthcheckUrl: app.Healthcheck.Url, - HealthcheckInterval: app.Healthcheck.Interval, - HealthcheckThreshold: app.Healthcheck.Threshold, - Health: health, - // #nosec G115 - Order represents a display order value that's always small and fits in int32 - DisplayOrder: int32(app.Order), - DisplayGroup: displayGroup, - Hidden: app.Hidden, - OpenIn: openIn, - Tooltip: app.Tooltip, - }) - if err != nil { - return xerrors.Errorf("upsert app: %w", err) - } - snapshot.WorkspaceApps = append(snapshot.WorkspaceApps, telemetry.ConvertWorkspaceApp(dbApp)) } } @@ -3360,3 +3255,285 @@ func convertDisplayApps(apps *sdkproto.DisplayApps) []database.DisplayApp { } return dapps } + +// insertDevcontainerSubagent creates a workspace agent for a devcontainer's +// subagent if one is defined. It returns the subagent ID (zero UUID if no +// subagent is defined). +func insertDevcontainerSubagent( + ctx context.Context, + db database.Store, + dc *sdkproto.Devcontainer, + parentAgent database.WorkspaceAgent, + resourceID uuid.UUID, + appSlugs map[string]struct{}, + snapshot *telemetry.Snapshot, + opts *insertWorkspaceResourceOptions, +) (uuid.UUID, error) { + // If there are no attached resources, we don't need to pre-create the + // subagent. This preserves backwards compatibility where devcontainers + // without resources can have their agents recreated dynamically. + if len(dc.GetApps()) == 0 && len(dc.GetScripts()) == 0 && len(dc.GetEnvs()) == 0 { + return uuid.UUID{}, nil + } + + subAgentID := uuid.New() + if opts.useAgentIDsFromProto { + var err error + subAgentID, err = uuid.Parse(dc.GetSubagentId()) + if err != nil { + return uuid.UUID{}, xerrors.Errorf("parse subagent id: %w", err) + } + } + + envJSON, err := encodeSubagentEnvs(dc.GetEnvs()) + if err != nil { + return uuid.UUID{}, err + } + + _, err = db.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{ + ID: subAgentID, + ParentID: uuid.NullUUID{Valid: true, UUID: parentAgent.ID}, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + ResourceID: resourceID, + Name: dc.GetName(), + AuthToken: uuid.New(), + AuthInstanceID: parentAgent.AuthInstanceID, + Architecture: parentAgent.Architecture, + EnvironmentVariables: envJSON, + Directory: dc.GetWorkspaceFolder(), + InstanceMetadata: pqtype.NullRawMessage{}, + ResourceMetadata: pqtype.NullRawMessage{}, + OperatingSystem: parentAgent.OperatingSystem, + ConnectionTimeoutSeconds: parentAgent.ConnectionTimeoutSeconds, + TroubleshootingURL: parentAgent.TroubleshootingURL, + MOTDFile: "", + DisplayApps: []database.DisplayApp{}, + DisplayOrder: 0, + APIKeyScope: parentAgent.APIKeyScope, + }) + if err != nil { + return uuid.UUID{}, xerrors.Errorf("insert subagent: %w", err) + } + + for _, app := range dc.GetApps() { + if err := insertAgentApp(ctx, db, subAgentID, app, appSlugs, snapshot); err != nil { + return uuid.UUID{}, xerrors.Errorf("insert agent app: %w", err) + } + } + + if err := insertAgentScriptsAndLogSources(ctx, db, subAgentID, agentScriptsFromProto(dc.GetScripts())); err != nil { + return uuid.UUID{}, xerrors.Errorf("insert agent scripts and log sources: %w", err) + } + + return subAgentID, nil +} + +func encodeSubagentEnvs(envs []*sdkproto.Env) (pqtype.NullRawMessage, error) { + if len(envs) == 0 { + return pqtype.NullRawMessage{}, nil + } + + subAgentEnvs := make(map[string]string, len(envs)) + for _, env := range envs { + subAgentEnvs[env.GetName()] = env.GetValue() + } + + data, err := json.Marshal(subAgentEnvs) + if err != nil { + return pqtype.NullRawMessage{}, xerrors.Errorf("marshal env: %w", err) + } + return pqtype.NullRawMessage{Valid: true, RawMessage: data}, nil +} + +// agentScriptsParams holds the parameters for inserting agent scripts and +// their associated log sources. +type agentScriptsParams struct { + LogSourceIDs []uuid.UUID + LogSourceDisplayNames []string + LogSourceIcons []string + + ScriptIDs []uuid.UUID + ScriptDisplayNames []string + ScriptLogPaths []string + ScriptSources []string + ScriptCron []string + ScriptTimeout []int32 + ScriptStartBlocksLogin []bool + ScriptRunOnStart []bool + ScriptRunOnStop []bool +} + +// agentScriptsFromProto converts a slice of proto scripts into the +// agentScriptsParams struct needed for database insertion. +func agentScriptsFromProto(scripts []*sdkproto.Script) agentScriptsParams { + params := agentScriptsParams{ + LogSourceIDs: make([]uuid.UUID, 0, len(scripts)), + LogSourceDisplayNames: make([]string, 0, len(scripts)), + LogSourceIcons: make([]string, 0, len(scripts)), + + ScriptIDs: make([]uuid.UUID, 0, len(scripts)), + ScriptDisplayNames: make([]string, 0, len(scripts)), + ScriptLogPaths: make([]string, 0, len(scripts)), + ScriptSources: make([]string, 0, len(scripts)), + ScriptCron: make([]string, 0, len(scripts)), + ScriptTimeout: make([]int32, 0, len(scripts)), + ScriptStartBlocksLogin: make([]bool, 0, len(scripts)), + ScriptRunOnStart: make([]bool, 0, len(scripts)), + ScriptRunOnStop: make([]bool, 0, len(scripts)), + } + + for _, script := range scripts { + params.LogSourceIDs = append(params.LogSourceIDs, uuid.New()) + params.LogSourceDisplayNames = append(params.LogSourceDisplayNames, script.GetDisplayName()) + params.LogSourceIcons = append(params.LogSourceIcons, script.GetIcon()) + + params.ScriptIDs = append(params.ScriptIDs, uuid.New()) + params.ScriptDisplayNames = append(params.ScriptDisplayNames, script.GetDisplayName()) + params.ScriptLogPaths = append(params.ScriptLogPaths, script.GetLogPath()) + params.ScriptSources = append(params.ScriptSources, script.GetScript()) + params.ScriptCron = append(params.ScriptCron, script.GetCron()) + params.ScriptTimeout = append(params.ScriptTimeout, script.GetTimeoutSeconds()) + params.ScriptStartBlocksLogin = append(params.ScriptStartBlocksLogin, script.GetStartBlocksLogin()) + params.ScriptRunOnStart = append(params.ScriptRunOnStart, script.GetRunOnStart()) + params.ScriptRunOnStop = append(params.ScriptRunOnStop, script.GetRunOnStop()) + } + + return params +} + +// insertAgentScriptsAndLogSources inserts log sources and scripts for an agent (or +// subagent). It expects the caller to have built the agentScriptsParams, +// allowing for additional entries to be appended before insertion (e.g. for +// devcontainers). Returns nil if there are no log sources to insert. +func insertAgentScriptsAndLogSources(ctx context.Context, db database.Store, agentID uuid.UUID, params agentScriptsParams) error { + if len(params.LogSourceIDs) == 0 { + return nil + } + + _, err := db.InsertWorkspaceAgentLogSources(ctx, database.InsertWorkspaceAgentLogSourcesParams{ + WorkspaceAgentID: agentID, + ID: params.LogSourceIDs, + CreatedAt: dbtime.Now(), + DisplayName: params.LogSourceDisplayNames, + Icon: params.LogSourceIcons, + }) + if err != nil { + return xerrors.Errorf("insert log sources: %w", err) + } + + _, err = db.InsertWorkspaceAgentScripts(ctx, database.InsertWorkspaceAgentScriptsParams{ + WorkspaceAgentID: agentID, + LogSourceID: params.LogSourceIDs, + ID: params.ScriptIDs, + LogPath: params.ScriptLogPaths, + CreatedAt: dbtime.Now(), + Script: params.ScriptSources, + Cron: params.ScriptCron, + TimeoutSeconds: params.ScriptTimeout, + StartBlocksLogin: params.ScriptStartBlocksLogin, + RunOnStart: params.ScriptRunOnStart, + RunOnStop: params.ScriptRunOnStop, + DisplayName: params.ScriptDisplayNames, + }) + if err != nil { + return xerrors.Errorf("insert scripts: %w", err) + } + + return nil +} + +func insertAgentApp(ctx context.Context, db database.Store, agentID uuid.UUID, app *sdkproto.App, appSlugs map[string]struct{}, snapshot *telemetry.Snapshot) error { + // Similar logic is duplicated in terraform/resources.go. + slug := app.Slug + if slug == "" { + return xerrors.Errorf("app must have a slug or name set") + } + // Unlike agent names, app slugs were never permitted to contain uppercase + // letters or underscores. + if !provisioner.AppSlugRegex.MatchString(slug) { + return xerrors.Errorf("app slug %q does not match regex %q", slug, provisioner.AppSlugRegex.String()) + } + if _, exists := appSlugs[slug]; exists { + return xerrors.Errorf("duplicate app slug, must be unique per template: %q", slug) + } + appSlugs[slug] = struct{}{} + + health := database.WorkspaceAppHealthDisabled + if app.Healthcheck == nil { + app.Healthcheck = &sdkproto.Healthcheck{} + } + if app.Healthcheck.Url != "" { + health = database.WorkspaceAppHealthInitializing + } + + sharingLevel := database.AppSharingLevelOwner + switch app.SharingLevel { + case sdkproto.AppSharingLevel_AUTHENTICATED: + sharingLevel = database.AppSharingLevelAuthenticated + case sdkproto.AppSharingLevel_PUBLIC: + sharingLevel = database.AppSharingLevelPublic + } + + displayGroup := sql.NullString{ + Valid: app.Group != "", + String: app.Group, + } + + openIn := database.WorkspaceAppOpenInSlimWindow + switch app.OpenIn { + case sdkproto.AppOpenIn_TAB: + openIn = database.WorkspaceAppOpenInTab + case sdkproto.AppOpenIn_SLIM_WINDOW: + openIn = database.WorkspaceAppOpenInSlimWindow + } + + var appID string + if app.Id == "" || app.Id == uuid.Nil.String() { + appID = uuid.NewString() + } else { + appID = app.Id + } + id, err := uuid.Parse(appID) + if err != nil { + return xerrors.Errorf("parse app uuid: %w", err) + } + + // If workspace apps are "persistent", the ID will not be regenerated across workspace builds, so we have to upsert. + dbApp, err := db.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{ + ID: id, + CreatedAt: dbtime.Now(), + AgentID: agentID, + Slug: slug, + DisplayName: app.DisplayName, + Icon: app.Icon, + Command: sql.NullString{ + String: app.Command, + Valid: app.Command != "", + }, + Url: sql.NullString{ + String: app.Url, + Valid: app.Url != "", + }, + External: app.External, + Subdomain: app.Subdomain, + SharingLevel: sharingLevel, + HealthcheckUrl: app.Healthcheck.Url, + HealthcheckInterval: app.Healthcheck.Interval, + HealthcheckThreshold: app.Healthcheck.Threshold, + Health: health, + // #nosec G115 - Order represents a display order value that's always small and fits in int32 + DisplayOrder: int32(app.Order), + DisplayGroup: displayGroup, + Hidden: app.Hidden, + OpenIn: openIn, + Tooltip: app.Tooltip, + }) + if err != nil { + return xerrors.Errorf("upsert app: %w", err) + } + + snapshot.WorkspaceApps = append(snapshot.WorkspaceApps, telemetry.ConvertWorkspaceApp(dbApp)) + + return nil +} diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index f508ba654e..3ffeb8da57 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -2981,6 +2981,46 @@ func TestCompleteJob(t *testing.T) { expectHasAiTask: true, expectUsageEvent: true, }, + { + name: "ai task linked to subagent app in devcontainer", + transition: database.WorkspaceTransitionStart, + input: &proto.CompletedJob_WorkspaceBuild{ + AiTasks: []*sdkproto.AITask{ + { + Id: uuid.NewString(), + AppId: sidebarAppID.String(), + }, + }, + Resources: []*sdkproto.Resource{ + { + Agents: []*sdkproto.Agent{ + { + Id: uuid.NewString(), + Name: "parent-agent", + Devcontainers: []*sdkproto.Devcontainer{ + { + Name: "dev", + WorkspaceFolder: "/workspace", + SubagentId: uuid.NewString(), + Apps: []*sdkproto.App{ + { + Id: sidebarAppID.String(), + Slug: "subagent-app", + }, + }, + }, + }, + }, + }, + }, + }, + }, + isTask: true, + expectTaskStatus: database.TaskStatusInitializing, + expectAppID: uuid.NullUUID{UUID: sidebarAppID, Valid: true}, + expectHasAiTask: true, + expectUsageEvent: true, + }, // Checks regression for https://github.com/coder/coder/issues/18776 { name: "non-existing app", @@ -3386,6 +3426,9 @@ func TestInsertWorkspaceResource(t *testing.T) { insert := func(db database.Store, jobID uuid.UUID, resource *sdkproto.Resource) error { return provisionerdserver.InsertWorkspaceResource(ctx, db, jobID, database.WorkspaceTransitionStart, resource, &telemetry.Snapshot{}) } + insertWithProtoIDs := func(db database.Store, jobID uuid.UUID, resource *sdkproto.Resource) error { + return provisionerdserver.InsertWorkspaceResource(ctx, db, jobID, database.WorkspaceTransitionStart, resource, &telemetry.Snapshot{}, provisionerdserver.InsertWorkspaceResourceWithAgentIDsFromProto()) + } t.Run("NoAgents", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -3722,39 +3765,400 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("Devcontainers", func(t *testing.T) { t.Parallel() - db, _ := dbtestutil.NewDB(t) - job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) - err := insert(db, job.ID, &sdkproto.Resource{ - Name: "something", - Type: "aws_instance", - Agents: []*sdkproto.Agent{{ - Name: "dev", - Devcontainers: []*sdkproto.Devcontainer{ - {Name: "foo", WorkspaceFolder: "/workspace1"}, - {Name: "bar", WorkspaceFolder: "/workspace2", ConfigPath: "/workspace2/.devcontainer/devcontainer.json"}, + + agentID := uuid.New() + subAgentID := uuid.New() + devcontainerID := uuid.New() + devcontainerID2 := uuid.New() + + tests := []struct { + name string + resource *sdkproto.Resource + wantErr string + protoIDsOnly bool // when true, only run with insertWithProtoIDs (e.g., for UUID parsing error tests) + expectSubAgentCount int + check func(t *testing.T, db database.Store, parentAgent database.WorkspaceAgent, subAgents []database.WorkspaceAgent, useProtoIDs bool) + }{ + { + name: "OK", + resource: &sdkproto.Resource{ + Name: "something", + Type: "aws_instance", + Agents: []*sdkproto.Agent{{ + Id: agentID.String(), + Name: "dev", + Devcontainers: []*sdkproto.Devcontainer{ + {Id: devcontainerID.String(), Name: "foo", WorkspaceFolder: "/workspace1"}, + {Id: devcontainerID2.String(), Name: "bar", WorkspaceFolder: "/workspace2", ConfigPath: "/workspace2/.devcontainer/devcontainer.json"}, + }, + }}, }, - }}, - }) - require.NoError(t, err) - resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID) - require.NoError(t, err) - require.Len(t, resources, 1) - agents, err := db.GetWorkspaceAgentsByResourceIDs(ctx, []uuid.UUID{resources[0].ID}) - require.NoError(t, err) - require.Len(t, agents, 1) - agent := agents[0] - devcontainers, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, agent.ID) - sort.Slice(devcontainers, func(i, j int) bool { - return devcontainers[i].Name > devcontainers[j].Name - }) - require.NoError(t, err) - require.Len(t, devcontainers, 2) - require.Equal(t, "foo", devcontainers[0].Name) - require.Equal(t, "/workspace1", devcontainers[0].WorkspaceFolder) - require.Equal(t, "", devcontainers[0].ConfigPath) - require.Equal(t, "bar", devcontainers[1].Name) - require.Equal(t, "/workspace2", devcontainers[1].WorkspaceFolder) - require.Equal(t, "/workspace2/.devcontainer/devcontainer.json", devcontainers[1].ConfigPath) + expectSubAgentCount: 0, + check: func(t *testing.T, db database.Store, parentAgent database.WorkspaceAgent, _ []database.WorkspaceAgent, useProtoIDs bool) { + require.Equal(t, "dev", parentAgent.Name) + + devcontainers, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, parentAgent.ID) + require.NoError(t, err) + sort.Slice(devcontainers, func(i, j int) bool { + return devcontainers[i].Name > devcontainers[j].Name + }) + require.Len(t, devcontainers, 2) + if useProtoIDs { + assert.Equal(t, devcontainerID, devcontainers[0].ID) + assert.Equal(t, devcontainerID2, devcontainers[1].ID) + } else { + assert.NotEqual(t, uuid.Nil, devcontainers[0].ID) + assert.NotEqual(t, uuid.Nil, devcontainers[1].ID) + } + assert.Equal(t, "foo", devcontainers[0].Name) + assert.Equal(t, "/workspace1", devcontainers[0].WorkspaceFolder) + assert.Equal(t, "", devcontainers[0].ConfigPath) + assert.False(t, devcontainers[0].SubagentID.Valid) + assert.Equal(t, "bar", devcontainers[1].Name) + assert.Equal(t, "/workspace2", devcontainers[1].WorkspaceFolder) + assert.Equal(t, "/workspace2/.devcontainer/devcontainer.json", devcontainers[1].ConfigPath) + assert.False(t, devcontainers[1].SubagentID.Valid) + }, + }, + { + name: "SubAgentWithAllResources", + resource: &sdkproto.Resource{ + Name: "something", + Type: "aws_instance", + Agents: []*sdkproto.Agent{{ + Id: agentID.String(), + Name: "dev", + Architecture: "amd64", + OperatingSystem: "linux", + Devcontainers: []*sdkproto.Devcontainer{{ + Id: devcontainerID.String(), + Name: "full-subagent", + WorkspaceFolder: "/workspace", + SubagentId: subAgentID.String(), + Apps: []*sdkproto.App{ + {Slug: "code-server", DisplayName: "VS Code", Url: "http://localhost:8080"}, + }, + Scripts: []*sdkproto.Script{ + {DisplayName: "Startup", Script: "echo start", RunOnStart: true}, + }, + Envs: []*sdkproto.Env{ + {Name: "EDITOR", Value: "vim"}, + }, + }}, + }}, + }, + expectSubAgentCount: 1, + check: func(t *testing.T, db database.Store, parentAgent database.WorkspaceAgent, subAgents []database.WorkspaceAgent, useProtoIDs bool) { + require.Len(t, subAgents, 1) + subAgent := subAgents[0] + if useProtoIDs { + require.Equal(t, subAgentID, subAgent.ID) + } else { + require.NotEqual(t, uuid.Nil, subAgent.ID) + } + + assert.Equal(t, parentAgent.ID, subAgent.ParentID.UUID) + assert.Equal(t, parentAgent.Architecture, subAgent.Architecture) + assert.Equal(t, parentAgent.OperatingSystem, subAgent.OperatingSystem) + + apps, err := db.GetWorkspaceAppsByAgentID(ctx, subAgent.ID) + require.NoError(t, err) + require.Len(t, apps, 1) + assert.Equal(t, "code-server", apps[0].Slug) + + scripts, err := db.GetWorkspaceAgentScriptsByAgentIDs(ctx, []uuid.UUID{subAgent.ID}) + require.NoError(t, err) + require.Len(t, scripts, 1) + assert.Equal(t, "Startup", scripts[0].DisplayName) + + var envVars map[string]string + err = json.Unmarshal(subAgent.EnvironmentVariables.RawMessage, &envVars) + require.NoError(t, err) + assert.Equal(t, "vim", envVars["EDITOR"]) + + devcontainers, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, parentAgent.ID) + require.NoError(t, err) + require.Len(t, devcontainers, 1) + assert.True(t, devcontainers[0].SubagentID.Valid) + if useProtoIDs { + assert.Equal(t, subAgentID, devcontainers[0].SubagentID.UUID) + } else { + assert.Equal(t, subAgent.ID, devcontainers[0].SubagentID.UUID) + } + }, + }, + { + name: "MultipleDevcontainersWithSubagents", + resource: &sdkproto.Resource{ + Name: "something", + Type: "aws_instance", + Agents: []*sdkproto.Agent{{ + Id: agentID.String(), + Name: "dev", + Devcontainers: []*sdkproto.Devcontainer{ + { + Id: devcontainerID.String(), + Name: "frontend", + WorkspaceFolder: "/workspace/frontend", + SubagentId: subAgentID.String(), + Apps: []*sdkproto.App{ + {Slug: "frontend-app", DisplayName: "Frontend"}, + }, + }, + { + Id: devcontainerID2.String(), + Name: "backend", + WorkspaceFolder: "/workspace/backend", + SubagentId: uuid.New().String(), + Apps: []*sdkproto.App{ + {Slug: "backend-app", DisplayName: "Backend"}, + }, + }, + }, + }}, + }, + expectSubAgentCount: 2, + check: func(t *testing.T, db database.Store, parentAgent database.WorkspaceAgent, subAgents []database.WorkspaceAgent, _ bool) { + for _, subAgent := range subAgents { + apps, err := db.GetWorkspaceAppsByAgentID(ctx, subAgent.ID) + require.NoError(t, err) + require.Len(t, apps, 1, "each subagent should have exactly one app") + } + + devcontainers, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, parentAgent.ID) + require.NoError(t, err) + require.Len(t, devcontainers, 2) + for _, dc := range devcontainers { + assert.True(t, dc.SubagentID.Valid, "devcontainer %s should have subagent", dc.Name) + } + }, + }, + { + name: "SubAgentDuplicateAppSlugs", + wantErr: `duplicate app slug, must be unique per template: "my-app"`, + resource: &sdkproto.Resource{ + Name: "something", + Type: "aws_instance", + Agents: []*sdkproto.Agent{{ + Id: agentID.String(), + Name: "dev", + Devcontainers: []*sdkproto.Devcontainer{{ + Id: devcontainerID.String(), + Name: "with-dup-apps", + WorkspaceFolder: "/workspace", + SubagentId: subAgentID.String(), + Apps: []*sdkproto.App{ + {Slug: "my-app", DisplayName: "App 1"}, + {Slug: "my-app", DisplayName: "App 2"}, + }, + }}, + }}, + }, + }, + { + name: "SubAgentInvalidAppSlug", + wantErr: `app slug "Invalid_Slug" does not match regex`, + resource: &sdkproto.Resource{ + Name: "something", + Type: "aws_instance", + Agents: []*sdkproto.Agent{{ + Id: agentID.String(), + Name: "dev", + Devcontainers: []*sdkproto.Devcontainer{{ + Id: devcontainerID.String(), + Name: "with-invalid-app", + WorkspaceFolder: "/workspace", + SubagentId: subAgentID.String(), + Apps: []*sdkproto.App{ + {Slug: "Invalid_Slug", DisplayName: "Bad App"}, + }, + }}, + }}, + }, + }, + { + name: "SubAgentAppSlugConflictsWithParentAgent", + wantErr: `duplicate app slug, must be unique per template: "shared-app"`, + resource: &sdkproto.Resource{ + Name: "something", + Type: "aws_instance", + Agents: []*sdkproto.Agent{{ + Id: agentID.String(), + Name: "dev", + Apps: []*sdkproto.App{ + {Slug: "shared-app", DisplayName: "Parent App"}, + }, + Devcontainers: []*sdkproto.Devcontainer{{ + Id: devcontainerID.String(), + Name: "dc", + WorkspaceFolder: "/workspace", + SubagentId: subAgentID.String(), + Apps: []*sdkproto.App{ + {Slug: "shared-app", DisplayName: "Child App"}, + }, + }}, + }}, + }, + }, + { + name: "SubAgentAppSlugConflictsBetweenSubagents", + wantErr: `duplicate app slug, must be unique per template: "conflicting-app"`, + resource: &sdkproto.Resource{ + Name: "something", + Type: "aws_instance", + Agents: []*sdkproto.Agent{{ + Id: agentID.String(), + Name: "dev", + Devcontainers: []*sdkproto.Devcontainer{ + { + Id: devcontainerID.String(), + Name: "dc1", + WorkspaceFolder: "/workspace1", + SubagentId: subAgentID.String(), + Apps: []*sdkproto.App{ + {Slug: "conflicting-app", DisplayName: "App in DC1"}, + }, + }, + { + Id: devcontainerID2.String(), + Name: "dc2", + WorkspaceFolder: "/workspace2", + SubagentId: uuid.New().String(), + Apps: []*sdkproto.App{ + {Slug: "conflicting-app", DisplayName: "App in DC2"}, + }, + }, + }, + }}, + }, + }, + { + name: "SubAgentInvalidSubagentID", + wantErr: "parse subagent id", + protoIDsOnly: true, // UUID parsing errors only occur with proto IDs + resource: &sdkproto.Resource{ + Name: "something", + Type: "aws_instance", + Agents: []*sdkproto.Agent{{ + Id: agentID.String(), + Name: "dev", + Devcontainers: []*sdkproto.Devcontainer{{ + Id: devcontainerID.String(), + Name: "invalid-subagent", + WorkspaceFolder: "/workspace", + SubagentId: "not-a-valid-uuid", + Apps: []*sdkproto.App{{Slug: "app", DisplayName: "App"}}, + }}, + }}, + }, + }, + { + name: "SubAgentInvalidAppID", + wantErr: "parse app uuid", + protoIDsOnly: true, // UUID parsing errors only occur with proto IDs + resource: &sdkproto.Resource{ + Name: "something", + Type: "aws_instance", + Agents: []*sdkproto.Agent{{ + Id: agentID.String(), + Name: "dev", + Devcontainers: []*sdkproto.Devcontainer{{ + Id: devcontainerID.String(), + Name: "with-invalid-app-id", + WorkspaceFolder: "/workspace", + SubagentId: subAgentID.String(), + Apps: []*sdkproto.App{{Id: "not-a-uuid", Slug: "my-app", DisplayName: "App"}}, + }}, + }}, + }, + }, + { + // This test verifies the backward-compatibility behavior where a + // devcontainer with a SubagentId but no apps, scripts, or envs does + // NOT create a subagent. + name: "SubAgentBackwardCompatNoResources", + resource: &sdkproto.Resource{ + Name: "something", + Type: "aws_instance", + Agents: []*sdkproto.Agent{{ + Id: agentID.String(), + Name: "dev", + Devcontainers: []*sdkproto.Devcontainer{{ + Id: devcontainerID.String(), + Name: "no-resources", + WorkspaceFolder: "/workspace", + SubagentId: subAgentID.String(), + // Intentionally no Apps, Scripts, or Envs. + }}, + }}, + }, + expectSubAgentCount: 0, + check: func(t *testing.T, db database.Store, parentAgent database.WorkspaceAgent, _ []database.WorkspaceAgent, _ bool) { + devcontainers, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, parentAgent.ID) + require.NoError(t, err) + require.Len(t, devcontainers, 1) + assert.Equal(t, "no-resources", devcontainers[0].Name) + assert.False(t, devcontainers[0].SubagentID.Valid, + "devcontainer with SubagentId but no apps/scripts/envs should not have a subagent (backward compatibility)") + }, + }, + } + + for _, tt := range tests { + for _, useProtoIDs := range []bool{false, true} { + if tt.protoIDsOnly && !useProtoIDs { + continue + } + + name := tt.name + if useProtoIDs { + name += "/WithProtoIDs" + } else { + name += "/WithoutProtoIDs" + } + + t.Run(name, func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) + + var err error + if useProtoIDs { + err = insertWithProtoIDs(db, job.ID, tt.resource) + } else { + err = insert(db, job.ID, tt.resource) + } + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + + resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID) + require.NoError(t, err) + require.Len(t, resources, 1) + + agents, err := db.GetWorkspaceAgentsByResourceIDs(ctx, []uuid.UUID{resources[0].ID}) + require.NoError(t, err) + + var parentAgent database.WorkspaceAgent + var subAgents []database.WorkspaceAgent + for _, agent := range agents { + if agent.ParentID.Valid { + subAgents = append(subAgents, agent) + } else { + parentAgent = agent + } + } + require.NotEqual(t, uuid.Nil, parentAgent.ID) + require.Len(t, subAgents, tt.expectSubAgentCount, "expected %d subagents", tt.expectSubAgentCount) + + tt.check(t, db, parentAgent, subAgents, useProtoIDs) + }) + } + } }) }