Compare commits

...

5 Commits

Author SHA1 Message Date
Danielle Maywood 5e018d37e3 feat(provisioner): support apps, scripts, and envs for devcontainer subagents
This change allows devcontainer subagents defined in Terraform to
specify their own apps, scripts, and environment variables.

When a devcontainer has apps, scripts, or envs defined, this code
inserts a proper workspace agent record with:
- Environment variables from envs
- Apps with proper slug validation, health checks, and sharing levels
- Scripts for startup, cron, and shutdown operations

The subagent inherits properties from the parent agent where appropriate
(architecture, OS, connection timeout, troubleshooting URL) while
having its own distinct configuration.

Refactors duplicate code by extracting helper functions:
- appSharingLevelToDatabase
- appOpenInToDatabase
- appHealthFromHealthcheck
- insertDevcontainerSubagent
- insertDevcontainerSubagentApps
- insertDevcontainerSubagentScripts

Fixes coder/internal#1241
2026-01-15 10:30:52 +00:00
Danielle Maywood f14b590254 fix: create context inside subtests to avoid lint error
The linter flagged timeout context usage after t.Parallel() calls.
Each subtest now creates its own context to avoid this issue.
2026-01-14 14:46:49 +00:00
Danielle Maywood 83e0e0b5df feat(database): add subagent_id column to workspace_agent_devcontainers
Adds a database migration to store the association between a devcontainer
and its sub-agent. This enables the system to track which sub-agent ID was
pre-defined in Terraform for a given dev container.

Changes:
- Add migration 000409 to add nullable subagent_id UUID column
- Update InsertWorkspaceAgentDevcontainers query to include subagent_id
- Update provisionerdserver to extract subagent_id from proto and store it
- Add tests for database layer and provisionerdserver integration

Fixes: coder/internal#1240
2026-01-14 14:36:16 +00:00
Danielle Maywood 5e09b91cbc fix: make devcontainer ID fields optional in proto schema
- Change id and subagent_id from required to optional bytes
- Remove inline comments from proto file per code review
- Revert TypeScript type assertion workarounds (no longer needed)

The optional keyword makes these fields truly optional in proto3,
which fixes TypeScript type compatibility without requiring unsafe
type assertions.
2026-01-14 13:07:23 +00:00
Danielle Maywood ad1dddb309 feat(provisionersdk): add subagent fields to Devcontainer proto
Add new fields to the Devcontainer message in provisioner.proto:
- id: Pre-computed devcontainer ID from Terraform
- subagent_id: Pre-computed subagent ID from Terraform
- apps: Apps to attach to the subagent
- scripts: Scripts to run in the subagent
- envs: Environment variables for the subagent

The new fields enable terraform-provider-coder to pass devcontainer
metadata and configuration to the provisioner, which will be used to
create devcontainer sub-agents with the specified apps, scripts, and
environment variables.

Also fixes TypeScript type assertions in e2e test helpers to handle
the new protobuf schema changes.

Related to #1238
2026-01-14 12:04:20 +00:00
13 changed files with 1025 additions and 570 deletions
+1
View File
@@ -402,6 +402,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{takeFirst(orig.SubagentID, uuid.NullUUID{}).UUID},
})
require.NoError(t, err, "insert workspace agent devcontainer")
return devcontainers[0]
+2 -1
View File
@@ -2505,7 +2505,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';
@@ -0,0 +1,2 @@
ALTER TABLE workspace_agent_devcontainers
DROP COLUMN subagent_id;
@@ -0,0 +1,2 @@
ALTER TABLE workspace_agent_devcontainers
ADD COLUMN subagent_id UUID;
+2 -1
View File
@@ -4743,7 +4743,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 {
+96
View File
@@ -7989,3 +7989,99 @@ func TestDeleteExpiredAPIKeys(t *testing.T) {
require.NoError(t, err)
require.Len(t, remaining, len(unexpiredTimes))
}
func TestWorkspaceAgentDevcontainersSubagentID(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
// Setup: create workspace agent
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
tpl := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
})
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true},
CreatedBy: user.ID,
})
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: user.ID,
TemplateID: tpl.ID,
})
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
})
build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: ws.ID,
JobID: job.ID,
TemplateVersionID: tv.ID,
})
res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
JobID: build.JobID,
})
agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ResourceID: res.ID,
})
// Create a subagent that will be referenced
subagent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ResourceID: res.ID,
ParentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
})
t.Run("InsertWithSubagentID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
devcontainers, err := db.InsertWorkspaceAgentDevcontainers(ctx, database.InsertWorkspaceAgentDevcontainersParams{
WorkspaceAgentID: agent.ID,
CreatedAt: dbtime.Now(),
ID: []uuid.UUID{uuid.New()},
Name: []string{"test-devcontainer"},
WorkspaceFolder: []string{"/workspace"},
ConfigPath: []string{"/workspace/.devcontainer/devcontainer.json"},
SubagentID: []uuid.UUID{subagent.ID},
})
require.NoError(t, err)
require.Len(t, devcontainers, 1)
require.True(t, devcontainers[0].SubagentID.Valid)
require.Equal(t, subagent.ID, devcontainers[0].SubagentID.UUID)
// Verify retrieval
retrieved, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, agent.ID)
require.NoError(t, err)
require.Len(t, retrieved, 1)
require.True(t, retrieved[0].SubagentID.Valid)
require.Equal(t, subagent.ID, retrieved[0].SubagentID.UUID)
})
t.Run("InsertWithNilSubagentID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
// Create a separate agent for this subtest
agent2 := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ResourceID: res.ID,
})
// When uuid.Nil is passed, it stores the zero UUID (not NULL).
// This matches the provisionerdserver behavior.
devcontainers, err := db.InsertWorkspaceAgentDevcontainers(ctx, database.InsertWorkspaceAgentDevcontainersParams{
WorkspaceAgentID: agent2.ID,
CreatedAt: dbtime.Now(),
ID: []uuid.UUID{uuid.New()},
Name: []string{"no-subagent"},
WorkspaceFolder: []string{"/workspace"},
ConfigPath: []string{""},
SubagentID: []uuid.UUID{uuid.Nil},
})
require.NoError(t, err)
require.Len(t, devcontainers, 1)
// uuid.Nil is stored as a zero UUID, not NULL.
require.Equal(t, uuid.Nil, devcontainers[0].SubagentID.UUID)
})
}
+9 -4
View File
@@ -17336,7 +17336,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
@@ -17361,6 +17361,7 @@ func (q *sqlQuerier) GetWorkspaceAgentDevcontainersByAgentID(ctx context.Context
&i.WorkspaceFolder,
&i.ConfigPath,
&i.Name,
&i.SubagentID,
); err != nil {
return nil, err
}
@@ -17377,15 +17378,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,
unnest($7::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 {
@@ -17395,6 +17397,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) {
@@ -17405,6 +17408,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
@@ -17420,6 +17424,7 @@ func (q *sqlQuerier) InsertWorkspaceAgentDevcontainers(ctx context.Context, arg
&i.WorkspaceFolder,
&i.ConfigPath,
&i.Name,
&i.SubagentID,
); err != nil {
return nil, err
}
@@ -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,
unnest(@subagent_id::uuid[]) AS subagent_id
RETURNING workspace_agent_devcontainers.*;
-- name: GetWorkspaceAgentDevcontainersByAgentID :many
+269 -20
View File
@@ -2897,6 +2897,7 @@ 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()
@@ -2905,6 +2906,22 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
devcontainerWorkspaceFolders = append(devcontainerWorkspaceFolders, dc.WorkspaceFolder)
devcontainerConfigPaths = append(devcontainerConfigPaths, dc.ConfigPath)
var subagentID uuid.UUID
hasSubagentID := len(dc.SubagentId) > 0
if hasSubagentID {
subagentID, err = uuid.FromBytes(dc.SubagentId)
if err != nil {
return xerrors.Errorf("parse devcontainer %q subagent_id: %w", dc.Name, err)
}
}
if hasSubagentID && (len(dc.Apps) > 0 || len(dc.Scripts) > 0 || len(dc.Envs) > 0) {
subagentID, err = insertDevcontainerSubagent(ctx, db, subagentID, dc, prAgent, agentID, resource.ID, snapshot)
if err != nil {
return err
}
}
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)
@@ -2932,6 +2949,7 @@ 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)
@@ -2983,35 +3001,18 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
}
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
}
health := appHealthFromHealthcheck(app.Healthcheck)
sharingLevel := appSharingLevelToDatabase(app.SharingLevel)
openIn := appOpenInToDatabase(app.OpenIn)
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()
@@ -3362,3 +3363,251 @@ func convertDisplayApps(apps *sdkproto.DisplayApps) []database.DisplayApp {
}
return dapps
}
// appSharingLevelToDatabase converts a proto app sharing level to a database
// app sharing level.
func appSharingLevelToDatabase(level sdkproto.AppSharingLevel) database.AppSharingLevel {
switch level {
case sdkproto.AppSharingLevel_AUTHENTICATED:
return database.AppSharingLevelAuthenticated
case sdkproto.AppSharingLevel_PUBLIC:
return database.AppSharingLevelPublic
default:
return database.AppSharingLevelOwner
}
}
// appOpenInToDatabase converts a proto app open_in setting to a database
// workspace app open_in setting.
func appOpenInToDatabase(openIn sdkproto.AppOpenIn) database.WorkspaceAppOpenIn {
switch openIn {
case sdkproto.AppOpenIn_TAB:
return database.WorkspaceAppOpenInTab
default:
return database.WorkspaceAppOpenInSlimWindow
}
}
// appHealthFromHealthcheck returns the initial health status for an app based
// on whether it has a healthcheck URL configured.
func appHealthFromHealthcheck(hc *sdkproto.Healthcheck) database.WorkspaceAppHealth {
if hc != nil && hc.Url != "" {
return database.WorkspaceAppHealthInitializing
}
return database.WorkspaceAppHealthDisabled
}
// insertDevcontainerSubagent creates a subagent for a devcontainer with its apps, scripts, and envs.
// If subagentID is uuid.Nil, a new UUID will be generated.
func insertDevcontainerSubagent(
ctx context.Context,
db database.Store,
subagentID uuid.UUID,
dc *sdkproto.Devcontainer,
parentAgent *sdkproto.Agent,
parentAgentID uuid.UUID,
resourceID uuid.UUID,
snapshot *telemetry.Snapshot,
) (uuid.UUID, error) {
if subagentID == uuid.Nil {
subagentID = uuid.New()
}
subAgentEnvs := make(map[string]string, len(dc.Envs))
for _, env := range dc.Envs {
subAgentEnvs[env.Name] = env.Value
}
var subAgentEnvsJSON pqtype.NullRawMessage
if len(subAgentEnvs) > 0 {
envJSON, err := json.Marshal(subAgentEnvs)
if err != nil {
return uuid.Nil, xerrors.Errorf("marshal devcontainer %q envs: %w", dc.Name, err)
}
subAgentEnvsJSON = pqtype.NullRawMessage{RawMessage: envJSON, Valid: true}
}
_, err := db.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{
ID: subagentID,
ParentID: uuid.NullUUID{Valid: true, UUID: parentAgentID},
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
ResourceID: resourceID,
Name: dc.Name,
AuthToken: uuid.New(),
AuthInstanceID: sql.NullString{},
Architecture: parentAgent.Architecture,
EnvironmentVariables: subAgentEnvsJSON,
Directory: dc.WorkspaceFolder,
OperatingSystem: parentAgent.OperatingSystem,
ConnectionTimeoutSeconds: parentAgent.GetConnectionTimeoutSeconds(),
TroubleshootingURL: parentAgent.GetTroubleshootingUrl(),
MOTDFile: "",
DisplayApps: []database.DisplayApp{},
InstanceMetadata: pqtype.NullRawMessage{},
ResourceMetadata: pqtype.NullRawMessage{},
DisplayOrder: 0,
APIKeyScope: database.AgentKeyScopeEnumAll,
})
if err != nil {
return uuid.Nil, xerrors.Errorf("insert devcontainer %q subagent: %w", dc.Name, err)
}
if err := insertDevcontainerSubagentApps(ctx, db, dc, subagentID, snapshot); err != nil {
return uuid.Nil, err
}
if err := insertDevcontainerSubagentScripts(ctx, db, dc, subagentID); err != nil {
return uuid.Nil, err
}
return subagentID, nil
}
// insertDevcontainerSubagentApps inserts workspace apps for a devcontainer subagent.
func insertDevcontainerSubagentApps(
ctx context.Context,
db database.Store,
dc *sdkproto.Devcontainer,
subagentID uuid.UUID,
snapshot *telemetry.Snapshot,
) error {
for _, app := range dc.Apps {
slug := app.Slug
if slug == "" {
return xerrors.Errorf("devcontainer %q app must have a slug set", dc.Name)
}
if !provisioner.AppSlugRegex.MatchString(slug) {
return xerrors.Errorf("devcontainer %q app slug %q does not match regex %q", dc.Name, slug, provisioner.AppSlugRegex.String())
}
if app.Healthcheck == nil {
app.Healthcheck = &sdkproto.Healthcheck{}
}
health := appHealthFromHealthcheck(app.Healthcheck)
sharingLevel := appSharingLevelToDatabase(app.SharingLevel)
openIn := appOpenInToDatabase(app.OpenIn)
displayGroup := sql.NullString{
Valid: app.Group != "",
String: app.Group,
}
appID := uuid.New()
if app.Id != "" && app.Id != uuid.Nil.String() {
var err error
appID, err = uuid.Parse(app.Id)
if err != nil {
return xerrors.Errorf("parse devcontainer %q app uuid: %w", dc.Name, err)
}
}
dbApp, err := db.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{
ID: appID,
CreatedAt: dbtime.Now(),
AgentID: subagentID,
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 devcontainer %q app: %w", dc.Name, err)
}
snapshot.WorkspaceApps = append(snapshot.WorkspaceApps, telemetry.ConvertWorkspaceApp(dbApp))
}
return nil
}
// insertDevcontainerSubagentScripts inserts scripts and log sources for a devcontainer subagent.
func insertDevcontainerSubagentScripts(
ctx context.Context,
db database.Store,
dc *sdkproto.Devcontainer,
subagentID uuid.UUID,
) error {
if len(dc.Scripts) == 0 {
return nil
}
var (
logSourceIDs = make([]uuid.UUID, 0, len(dc.Scripts))
logSourceNames = make([]string, 0, len(dc.Scripts))
logSourceIcons = make([]string, 0, len(dc.Scripts))
scriptIDs = make([]uuid.UUID, 0, len(dc.Scripts))
scriptLogPaths = make([]string, 0, len(dc.Scripts))
scriptSources = make([]string, 0, len(dc.Scripts))
scriptCron = make([]string, 0, len(dc.Scripts))
scriptTimeout = make([]int32, 0, len(dc.Scripts))
scriptStartBlock = make([]bool, 0, len(dc.Scripts))
scriptRunOnStart = make([]bool, 0, len(dc.Scripts))
scriptRunOnStop = make([]bool, 0, len(dc.Scripts))
scriptDisplayNames = make([]string, 0, len(dc.Scripts))
)
for _, script := range dc.Scripts {
logSourceID := uuid.New()
logSourceIDs = append(logSourceIDs, logSourceID)
logSourceNames = append(logSourceNames, script.DisplayName)
logSourceIcons = append(logSourceIcons, script.Icon)
scriptIDs = append(scriptIDs, uuid.New())
scriptLogPaths = append(scriptLogPaths, script.LogPath)
scriptSources = append(scriptSources, script.Script)
scriptCron = append(scriptCron, script.Cron)
scriptTimeout = append(scriptTimeout, script.TimeoutSeconds)
scriptStartBlock = append(scriptStartBlock, script.StartBlocksLogin)
scriptRunOnStart = append(scriptRunOnStart, script.RunOnStart)
scriptRunOnStop = append(scriptRunOnStop, script.RunOnStop)
scriptDisplayNames = append(scriptDisplayNames, script.DisplayName)
}
_, err := db.InsertWorkspaceAgentLogSources(ctx, database.InsertWorkspaceAgentLogSourcesParams{
WorkspaceAgentID: subagentID,
ID: logSourceIDs,
CreatedAt: dbtime.Now(),
DisplayName: logSourceNames,
Icon: logSourceIcons,
})
if err != nil {
return xerrors.Errorf("insert devcontainer %q subagent log sources: %w", dc.Name, err)
}
_, err = db.InsertWorkspaceAgentScripts(ctx, database.InsertWorkspaceAgentScriptsParams{
WorkspaceAgentID: subagentID,
LogSourceID: logSourceIDs,
LogPath: scriptLogPaths,
CreatedAt: dbtime.Now(),
Script: scriptSources,
Cron: scriptCron,
TimeoutSeconds: scriptTimeout,
StartBlocksLogin: scriptStartBlock,
RunOnStart: scriptRunOnStart,
RunOnStop: scriptRunOnStop,
DisplayName: scriptDisplayNames,
ID: scriptIDs,
})
if err != nil {
return xerrors.Errorf("insert devcontainer %q subagent scripts: %w", dc.Name, err)
}
return nil
}
@@ -3706,6 +3706,7 @@ func TestInsertWorkspaceResource(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{})
subagentID := uuid.New()
err := insert(db, job.ID, &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
@@ -3714,6 +3715,7 @@ func TestInsertWorkspaceResource(t *testing.T) {
Devcontainers: []*sdkproto.Devcontainer{
{Name: "foo", WorkspaceFolder: "/workspace1"},
{Name: "bar", WorkspaceFolder: "/workspace2", ConfigPath: "/workspace2/.devcontainer/devcontainer.json"},
{Name: "baz", WorkspaceFolder: "/workspace3", SubagentId: subagentID[:]},
},
}},
})
@@ -3723,20 +3725,33 @@ func TestInsertWorkspaceResource(t *testing.T) {
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]
// Expect 2 agents: the parent agent "dev" and the subagent "baz".
require.Len(t, agents, 2)
// Find the parent agent (no parent ID).
var agent database.WorkspaceAgent
for _, a := range agents {
if !a.ParentID.Valid {
agent = a
break
}
}
require.Equal(t, "dev", agent.Name)
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.Len(t, devcontainers, 3)
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)
require.Equal(t, uuid.Nil, devcontainers[0].SubagentID.UUID)
require.Equal(t, "baz", devcontainers[1].Name)
require.Equal(t, "/workspace3", devcontainers[1].WorkspaceFolder)
require.Equal(t, subagentID, devcontainers[1].SubagentID.UUID)
require.Equal(t, "bar", devcontainers[2].Name)
require.Equal(t, "/workspace2", devcontainers[2].WorkspaceFolder)
require.Equal(t, "/workspace2/.devcontainer/devcontainer.json", devcontainers[2].ConfigPath)
})
}
+593 -536
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -244,6 +244,11 @@ message Devcontainer {
string workspace_folder = 1;
string config_path = 2;
string name = 3;
optional bytes id = 4;
optional bytes subagent_id = 5;
repeated App apps = 6;
repeated Script scripts = 7;
repeated Env envs = 8;
}
enum AppOpenIn {
+20
View File
@@ -306,6 +306,11 @@ export interface Devcontainer {
workspaceFolder: string;
configPath: string;
name: string;
id?: Uint8Array | undefined;
subagentId?: Uint8Array | undefined;
apps: App[];
scripts: Script[];
envs: Env[];
}
/** App represents a dev-accessible application on the workspace. */
@@ -1095,6 +1100,21 @@ export const Devcontainer = {
if (message.name !== "") {
writer.uint32(26).string(message.name);
}
if (message.id !== undefined) {
writer.uint32(34).bytes(message.id);
}
if (message.subagentId !== undefined) {
writer.uint32(42).bytes(message.subagentId);
}
for (const v of message.apps) {
App.encode(v!, writer.uint32(50).fork()).ldelim();
}
for (const v of message.scripts) {
Script.encode(v!, writer.uint32(58).fork()).ldelim();
}
for (const v of message.envs) {
Env.encode(v!, writer.uint32(66).fork()).ldelim();
}
return writer;
},
};