Compare commits

...

4 Commits

Author SHA1 Message Date
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 750 additions and 548 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
@@ -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()
@@ -2904,6 +2905,9 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
devcontainerNames = append(devcontainerNames, dc.Name)
devcontainerWorkspaceFolders = append(devcontainerWorkspaceFolders, dc.WorkspaceFolder)
devcontainerConfigPaths = append(devcontainerConfigPaths, dc.ConfigPath)
var subagentID uuid.UUID
copy(subagentID[:], dc.GetSubagentId())
devcontainerSubagentIDs = append(devcontainerSubagentIDs, subagentID)
// Add a log source and script for each devcontainer so we can
// track logs and timings for each devcontainer.
@@ -2932,6 +2936,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)
@@ -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[:]},
},
}},
})
@@ -3730,13 +3732,17 @@ func TestInsertWorkspaceResource(t *testing.T) {
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;
},
};