feat(agent): populate subagent ID for terraform-defined devcontainers (#21942)

Completes the final piece of the puzzle. Support the pre-creation flow
from the agent side.
This commit is contained in:
Danielle Maywood
2026-02-06 15:52:54 +00:00
committed by GitHub
parent a5bc0eb37d
commit 6ccd20d45f
18 changed files with 869 additions and 37 deletions
+71 -2
View File
@@ -1,9 +1,9 @@
// Code generated by MockGen. DO NOT EDIT. // Code generated by MockGen. DO NOT EDIT.
// Source: .. (interfaces: ContainerCLI,DevcontainerCLI) // Source: .. (interfaces: ContainerCLI,DevcontainerCLI,SubAgentClient)
// //
// Generated by this command: // Generated by this command:
// //
// mockgen -destination ./acmock.go -package acmock .. ContainerCLI,DevcontainerCLI // mockgen -destination ./acmock.go -package acmock .. ContainerCLI,DevcontainerCLI,SubAgentClient
// //
// Package acmock is a generated GoMock package. // Package acmock is a generated GoMock package.
@@ -15,6 +15,7 @@ import (
agentcontainers "github.com/coder/coder/v2/agent/agentcontainers" agentcontainers "github.com/coder/coder/v2/agent/agentcontainers"
codersdk "github.com/coder/coder/v2/codersdk" codersdk "github.com/coder/coder/v2/codersdk"
uuid "github.com/google/uuid"
gomock "go.uber.org/mock/gomock" gomock "go.uber.org/mock/gomock"
) )
@@ -216,3 +217,71 @@ func (mr *MockDevcontainerCLIMockRecorder) Up(ctx, workspaceFolder, configPath a
varargs := append([]any{ctx, workspaceFolder, configPath}, opts...) varargs := append([]any{ctx, workspaceFolder, configPath}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Up", reflect.TypeOf((*MockDevcontainerCLI)(nil).Up), varargs...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Up", reflect.TypeOf((*MockDevcontainerCLI)(nil).Up), varargs...)
} }
// MockSubAgentClient is a mock of SubAgentClient interface.
type MockSubAgentClient struct {
ctrl *gomock.Controller
recorder *MockSubAgentClientMockRecorder
isgomock struct{}
}
// MockSubAgentClientMockRecorder is the mock recorder for MockSubAgentClient.
type MockSubAgentClientMockRecorder struct {
mock *MockSubAgentClient
}
// NewMockSubAgentClient creates a new mock instance.
func NewMockSubAgentClient(ctrl *gomock.Controller) *MockSubAgentClient {
mock := &MockSubAgentClient{ctrl: ctrl}
mock.recorder = &MockSubAgentClientMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockSubAgentClient) EXPECT() *MockSubAgentClientMockRecorder {
return m.recorder
}
// Create mocks base method.
func (m *MockSubAgentClient) Create(ctx context.Context, agent agentcontainers.SubAgent) (agentcontainers.SubAgent, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Create", ctx, agent)
ret0, _ := ret[0].(agentcontainers.SubAgent)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Create indicates an expected call of Create.
func (mr *MockSubAgentClientMockRecorder) Create(ctx, agent any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSubAgentClient)(nil).Create), ctx, agent)
}
// Delete mocks base method.
func (m *MockSubAgentClient) Delete(ctx context.Context, id uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Delete", ctx, id)
ret0, _ := ret[0].(error)
return ret0
}
// Delete indicates an expected call of Delete.
func (mr *MockSubAgentClientMockRecorder) Delete(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockSubAgentClient)(nil).Delete), ctx, id)
}
// List mocks base method.
func (m *MockSubAgentClient) List(ctx context.Context) ([]agentcontainers.SubAgent, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "List", ctx)
ret0, _ := ret[0].([]agentcontainers.SubAgent)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// List indicates an expected call of List.
func (mr *MockSubAgentClientMockRecorder) List(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockSubAgentClient)(nil).List), ctx)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Package acmock contains a mock implementation of agentcontainers.Lister for use in tests. // Package acmock contains a mock implementation of agentcontainers.Lister for use in tests.
package acmock package acmock
//go:generate mockgen -destination ./acmock.go -package acmock .. ContainerCLI,DevcontainerCLI //go:generate mockgen -destination ./acmock.go -package acmock .. ContainerCLI,DevcontainerCLI,SubAgentClient
+47 -15
View File
@@ -562,12 +562,9 @@ func (api *API) discoverDevcontainersInProject(projectPath string) error {
api.broadcastUpdatesLocked() api.broadcastUpdatesLocked()
if dc.Status == codersdk.WorkspaceAgentDevcontainerStatusStarting { if dc.Status == codersdk.WorkspaceAgentDevcontainerStatusStarting {
api.asyncWg.Add(1) api.asyncWg.Go(func() {
go func() {
defer api.asyncWg.Done()
_ = api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath) _ = api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath)
}() })
} }
} }
api.mu.Unlock() api.mu.Unlock()
@@ -1627,16 +1624,25 @@ func (api *API) cleanupSubAgents(ctx context.Context) error {
api.mu.Lock() api.mu.Lock()
defer api.mu.Unlock() defer api.mu.Unlock()
injected := make(map[uuid.UUID]bool, len(api.injectedSubAgentProcs)) // Collect all subagent IDs that should be kept:
// 1. Subagents currently tracked by injectedSubAgentProcs
// 2. Subagents referenced by known devcontainers from the manifest
var keep []uuid.UUID
for _, proc := range api.injectedSubAgentProcs { for _, proc := range api.injectedSubAgentProcs {
injected[proc.agent.ID] = true keep = append(keep, proc.agent.ID)
}
for _, dc := range api.knownDevcontainers {
if dc.SubagentID.Valid {
keep = append(keep, dc.SubagentID.UUID)
}
} }
ctx, cancel := context.WithTimeout(ctx, defaultOperationTimeout) ctx, cancel := context.WithTimeout(ctx, defaultOperationTimeout)
defer cancel() defer cancel()
var errs []error
for _, agent := range agents { for _, agent := range agents {
if injected[agent.ID] { if slices.Contains(keep, agent.ID) {
continue continue
} }
client := *api.subAgentClient.Load() client := *api.subAgentClient.Load()
@@ -1647,10 +1653,11 @@ func (api *API) cleanupSubAgents(ctx context.Context) error {
slog.F("agent_id", agent.ID), slog.F("agent_id", agent.ID),
slog.F("agent_name", agent.Name), slog.F("agent_name", agent.Name),
) )
errs = append(errs, xerrors.Errorf("delete agent %s (%s): %w", agent.Name, agent.ID, err))
} }
} }
return nil return errors.Join(errs...)
} }
// maybeInjectSubAgentIntoContainerLocked injects a subagent into a dev // maybeInjectSubAgentIntoContainerLocked injects a subagent into a dev
@@ -2001,7 +2008,20 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
// logger.Warn(ctx, "set CAP_NET_ADMIN on agent binary failed", slog.Error(err)) // logger.Warn(ctx, "set CAP_NET_ADMIN on agent binary failed", slog.Error(err))
// } // }
deleteSubAgent := proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig) // Only delete and recreate subagents that were dynamically created
// (ID == uuid.Nil). Terraform-defined subagents (subAgentConfig.ID !=
// uuid.Nil) must not be deleted because they have attached resources
// managed by terraform.
isTerraformManaged := subAgentConfig.ID != uuid.Nil
configHasChanged := !proc.agent.EqualConfig(subAgentConfig)
logger.Debug(ctx, "checking if sub agent should be deleted",
slog.F("is_terraform_managed", isTerraformManaged),
slog.F("maybe_recreate_sub_agent", maybeRecreateSubAgent),
slog.F("config_has_changed", configHasChanged),
)
deleteSubAgent := !isTerraformManaged && maybeRecreateSubAgent && configHasChanged
if deleteSubAgent { if deleteSubAgent {
logger.Debug(ctx, "deleting existing subagent for recreation", slog.F("agent_id", proc.agent.ID)) logger.Debug(ctx, "deleting existing subagent for recreation", slog.F("agent_id", proc.agent.ID))
client := *api.subAgentClient.Load() client := *api.subAgentClient.Load()
@@ -2012,11 +2032,23 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
proc.agent = SubAgent{} // Clear agent to signal that we need to create a new one. proc.agent = SubAgent{} // Clear agent to signal that we need to create a new one.
} }
if proc.agent.ID == uuid.Nil { // Re-create (upsert) terraform-managed subagents when the config
logger.Debug(ctx, "creating new subagent", // changes so that display apps and other settings are updated
slog.F("directory", subAgentConfig.Directory), // without deleting the agent.
slog.F("display_apps", subAgentConfig.DisplayApps), recreateTerraformSubAgent := isTerraformManaged && maybeRecreateSubAgent && configHasChanged
)
if proc.agent.ID == uuid.Nil || recreateTerraformSubAgent {
if recreateTerraformSubAgent {
logger.Debug(ctx, "updating existing subagent",
slog.F("directory", subAgentConfig.Directory),
slog.F("display_apps", subAgentConfig.DisplayApps),
)
} else {
logger.Debug(ctx, "creating new subagent",
slog.F("directory", subAgentConfig.Directory),
slog.F("display_apps", subAgentConfig.DisplayApps),
)
}
// Create new subagent record in the database to receive the auth token. // Create new subagent record in the database to receive the auth token.
// If we get a unique constraint violation, try with expanded names that // If we get a unique constraint violation, try with expanded names that
+361 -1
View File
@@ -437,7 +437,11 @@ func (m *fakeSubAgentClient) Create(ctx context.Context, agent agentcontainers.S
} }
} }
agent.ID = uuid.New() // Only generate a new ID if one wasn't provided. Terraform-defined
// subagents have pre-existing IDs that should be preserved.
if agent.ID == uuid.Nil {
agent.ID = uuid.New()
}
agent.AuthToken = uuid.New() agent.AuthToken = uuid.New()
if m.agents == nil { if m.agents == nil {
m.agents = make(map[uuid.UUID]agentcontainers.SubAgent) m.agents = make(map[uuid.UUID]agentcontainers.SubAgent)
@@ -1035,6 +1039,30 @@ func TestAPI(t *testing.T) {
wantStatus: []int{http.StatusAccepted, http.StatusConflict}, wantStatus: []int{http.StatusAccepted, http.StatusConflict},
wantBody: []string{"Devcontainer recreation initiated", "is currently starting and cannot be restarted"}, wantBody: []string{"Devcontainer recreation initiated", "is currently starting and cannot be restarted"},
}, },
{
name: "Terraform-defined devcontainer can be rebuilt",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-terraform",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Container: &devContainer1,
SubagentID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
},
arch: "<none>",
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusAccepted, http.StatusConflict},
wantBody: []string{"Devcontainer recreation initiated", "is currently starting and cannot be restarted"},
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -2490,6 +2518,338 @@ func TestAPI(t *testing.T) {
assert.Empty(t, fakeSAC.agents) assert.Empty(t, fakeSAC.agents)
}) })
t.Run("SubAgentCleanupPreservesTerraformDefined", func(t *testing.T) {
t.Parallel()
var (
// Given: A terraform-defined agent and devcontainer that should be preserved
terraformAgentID = uuid.New()
terraformAgentToken = uuid.New()
terraformAgent = agentcontainers.SubAgent{
ID: terraformAgentID,
Name: "terraform-defined-agent",
Directory: "/workspace",
AuthToken: terraformAgentToken,
}
terraformDevcontainer = codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "terraform-devcontainer",
WorkspaceFolder: "/workspace/project",
SubagentID: uuid.NullUUID{UUID: terraformAgentID, Valid: true},
}
// Given: An orphaned agent that should be cleaned up
orphanedAgentID = uuid.New()
orphanedAgentToken = uuid.New()
orphanedAgent = agentcontainers.SubAgent{
ID: orphanedAgentID,
Name: "orphaned-agent",
Directory: "/tmp",
AuthToken: orphanedAgentToken,
}
ctx = testutil.Context(t, testutil.WaitMedium)
logger = slog.Make()
mClock = quartz.NewMock(t)
mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t))
fakeSAC = &fakeSubAgentClient{
logger: logger.Named("fakeSubAgentClient"),
agents: map[uuid.UUID]agentcontainers.SubAgent{
terraformAgentID: terraformAgent,
orphanedAgentID: orphanedAgent,
},
}
)
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{},
}, nil).AnyTimes()
mClock.Set(time.Now()).MustWait(ctx)
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
api := agentcontainers.NewAPI(logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(mCCLI),
agentcontainers.WithSubAgentClient(fakeSAC),
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
agentcontainers.WithDevcontainers([]codersdk.WorkspaceAgentDevcontainer{terraformDevcontainer}, nil),
)
api.Start()
defer api.Close()
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// When: We advance the clock, allowing cleanup to occur
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// Then: The orphaned agent should be deleted
assert.Contains(t, fakeSAC.deleted, orphanedAgentID, "orphaned agent should be deleted")
// And: The terraform-defined agent should not be deleted
assert.NotContains(t, fakeSAC.deleted, terraformAgentID, "terraform-defined agent should be preserved")
assert.Len(t, fakeSAC.agents, 1, "only terraform agent should remain")
assert.Contains(t, fakeSAC.agents, terraformAgentID, "terraform agent should still exist")
})
t.Run("TerraformDefinedSubAgentNotRecreatedOnConfigChange", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
var (
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mCtrl = gomock.NewController(t)
// Given: A terraform-defined devcontainer with a pre-assigned subagent ID.
terraformAgentID = uuid.New()
terraformContainer = codersdk.WorkspaceAgentContainer{
ID: "test-container-id",
FriendlyName: "test-container",
Image: "test-image",
Running: true,
CreatedAt: time.Now(),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/project/.devcontainer/devcontainer.json",
},
}
terraformDevcontainer = codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "terraform-devcontainer",
WorkspaceFolder: "/workspace/project",
ConfigPath: "/workspace/project/.devcontainer/devcontainer.json",
SubagentID: uuid.NullUUID{UUID: terraformAgentID, Valid: true},
}
fCCLI = &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{terraformContainer},
},
arch: runtime.GOARCH,
}
fDCCLI = &fakeDevcontainerCLI{
upID: terraformContainer.ID,
readConfig: agentcontainers.DevcontainerConfig{
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
Customizations: agentcontainers.DevcontainerMergedCustomizations{
Coder: []agentcontainers.CoderCustomization{{
Apps: []agentcontainers.SubAgentApp{{Slug: "app1"}},
}},
},
},
},
}
mSAC = acmock.NewMockSubAgentClient(mCtrl)
closed bool
)
mSAC.EXPECT().List(gomock.Any()).Return([]agentcontainers.SubAgent{}, nil).AnyTimes()
// EXPECT: Create is called twice with the terraform-defined ID:
// once for the initial creation and once after the rebuild with
// config changes (upsert).
mSAC.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, agent agentcontainers.SubAgent) (agentcontainers.SubAgent, error) {
assert.Equal(t, terraformAgentID, agent.ID, "agent should have terraform-defined ID")
agent.AuthToken = uuid.New()
return agent, nil
},
).Times(2)
// EXPECT: Delete may be called during Close, but not before.
mSAC.EXPECT().Delete(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _ uuid.UUID) error {
assert.True(t, closed, "Delete should only be called after Close, not during recreation")
return nil
}).AnyTimes()
api := agentcontainers.NewAPI(logger,
agentcontainers.WithContainerCLI(fCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithDevcontainers(
[]codersdk.WorkspaceAgentDevcontainer{terraformDevcontainer},
[]codersdk.WorkspaceAgentScript{{ID: terraformDevcontainer.ID, LogSourceID: uuid.New()}},
),
agentcontainers.WithSubAgentClient(mSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
// Given: We create the devcontainer for the first time.
err := api.CreateDevcontainer(terraformDevcontainer.WorkspaceFolder, terraformDevcontainer.ConfigPath)
require.NoError(t, err)
// When: The container is recreated (new container ID) with config changes.
terraformContainer.ID = "new-container-id"
fCCLI.containers.Containers = []codersdk.WorkspaceAgentContainer{terraformContainer}
fDCCLI.upID = terraformContainer.ID
fDCCLI.readConfig.MergedConfiguration.Customizations.Coder = []agentcontainers.CoderCustomization{{
Apps: []agentcontainers.SubAgentApp{{Slug: "app2"}}, // Changed app triggers recreation logic.
}}
err = api.CreateDevcontainer(terraformDevcontainer.WorkspaceFolder, terraformDevcontainer.ConfigPath, agentcontainers.WithRemoveExistingContainer())
require.NoError(t, err)
// Then: Mock expectations verify that Create was called once and Delete was not called during recreation.
closed = true
api.Close()
})
// Verify that rebuilding a terraform-defined devcontainer via the
// HTTP API does not delete the sub agent. The sub agent should be
// preserved (Create called again with the same terraform ID) and
// display app changes should be picked up.
t.Run("TerraformDefinedSubAgentRebuildViaHTTP", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
}
var (
ctx = testutil.Context(t, testutil.WaitMedium)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mCtrl = gomock.NewController(t)
terraformAgentID = uuid.New()
containerID = "test-container-id"
terraformContainer = codersdk.WorkspaceAgentContainer{
ID: containerID,
FriendlyName: "test-container",
Image: "test-image",
Running: true,
CreatedAt: time.Now(),
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/project/.devcontainer/devcontainer.json",
},
}
terraformDevcontainer = codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "terraform-devcontainer",
WorkspaceFolder: "/workspace/project",
ConfigPath: "/workspace/project/.devcontainer/devcontainer.json",
SubagentID: uuid.NullUUID{UUID: terraformAgentID, Valid: true},
}
fCCLI = &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{terraformContainer},
},
arch: runtime.GOARCH,
}
fDCCLI = &fakeDevcontainerCLI{
upID: containerID,
readConfig: agentcontainers.DevcontainerConfig{
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
Customizations: agentcontainers.DevcontainerMergedCustomizations{
Coder: []agentcontainers.CoderCustomization{{
DisplayApps: map[codersdk.DisplayApp]bool{
codersdk.DisplayAppSSH: true,
codersdk.DisplayAppWebTerminal: true,
},
}},
},
},
},
}
mSAC = acmock.NewMockSubAgentClient(mCtrl)
closed bool
createCalled = make(chan agentcontainers.SubAgent, 2)
)
mSAC.EXPECT().List(gomock.Any()).Return([]agentcontainers.SubAgent{}, nil).AnyTimes()
// Create should be called twice: once for the initial injection
// and once after the rebuild picks up the new container.
mSAC.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, agent agentcontainers.SubAgent) (agentcontainers.SubAgent, error) {
assert.Equal(t, terraformAgentID, agent.ID, "agent should always use terraform-defined ID")
agent.AuthToken = uuid.New()
createCalled <- agent
return agent, nil
},
).Times(2)
// Delete must only be called during Close, never during rebuild.
mSAC.EXPECT().Delete(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _ uuid.UUID) error {
assert.True(t, closed, "Delete should only be called after Close, not during rebuild")
return nil
}).AnyTimes()
api := agentcontainers.NewAPI(logger,
agentcontainers.WithContainerCLI(fCCLI),
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithDevcontainers(
[]codersdk.WorkspaceAgentDevcontainer{terraformDevcontainer},
[]codersdk.WorkspaceAgentScript{{ID: terraformDevcontainer.ID, LogSourceID: uuid.New()}},
),
agentcontainers.WithSubAgentClient(mSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
api.Start()
defer func() {
closed = true
api.Close()
}()
r := chi.NewRouter()
r.Mount("/", api.Routes())
// Perform the initial devcontainer creation directly to set up
// the subagent (mirrors the TerraformDefinedSubAgentNotRecreatedOnConfigChange
// test pattern).
err := api.CreateDevcontainer(terraformDevcontainer.WorkspaceFolder, terraformDevcontainer.ConfigPath)
require.NoError(t, err)
initialAgent := testutil.RequireReceive(ctx, t, createCalled)
assert.Equal(t, terraformAgentID, initialAgent.ID)
// Simulate container rebuild: new container ID, changed display apps.
newContainerID := "new-container-id"
terraformContainer.ID = newContainerID
fCCLI.containers.Containers = []codersdk.WorkspaceAgentContainer{terraformContainer}
fDCCLI.upID = newContainerID
fDCCLI.readConfig.MergedConfiguration.Customizations.Coder = []agentcontainers.CoderCustomization{{
DisplayApps: map[codersdk.DisplayApp]bool{
codersdk.DisplayAppSSH: true,
codersdk.DisplayAppWebTerminal: true,
codersdk.DisplayAppVSCodeDesktop: true,
codersdk.DisplayAppVSCodeInsiders: true,
},
}}
// Issue the rebuild request via the HTTP API.
req := httptest.NewRequest(http.MethodPost, "/devcontainers/"+terraformDevcontainer.ID.String()+"/recreate", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusAccepted, rec.Code)
// Wait for the post-rebuild injection to complete.
rebuiltAgent := testutil.RequireReceive(ctx, t, createCalled)
assert.Equal(t, terraformAgentID, rebuiltAgent.ID, "rebuilt agent should preserve terraform ID")
// Verify that the display apps were updated.
assert.Contains(t, rebuiltAgent.DisplayApps, codersdk.DisplayAppVSCodeDesktop,
"rebuilt agent should include updated display apps")
assert.Contains(t, rebuiltAgent.DisplayApps, codersdk.DisplayAppVSCodeInsiders,
"rebuilt agent should include updated display apps")
})
t.Run("Error", func(t *testing.T) { t.Run("Error", func(t *testing.T) {
t.Parallel() t.Parallel()
+10 -2
View File
@@ -24,10 +24,12 @@ type SubAgent struct {
DisplayApps []codersdk.DisplayApp DisplayApps []codersdk.DisplayApp
} }
// CloneConfig makes a copy of SubAgent without ID and AuthToken. The // CloneConfig makes a copy of SubAgent using configuration from the
// name is inherited from the devcontainer. // devcontainer. The ID is inherited from dc.SubagentID if present, and
// the name is inherited from the devcontainer. AuthToken is not copied.
func (s SubAgent) CloneConfig(dc codersdk.WorkspaceAgentDevcontainer) SubAgent { func (s SubAgent) CloneConfig(dc codersdk.WorkspaceAgentDevcontainer) SubAgent {
return SubAgent{ return SubAgent{
ID: dc.SubagentID.UUID,
Name: dc.Name, Name: dc.Name,
Directory: s.Directory, Directory: s.Directory,
Architecture: s.Architecture, Architecture: s.Architecture,
@@ -190,6 +192,11 @@ func (a *subAgentAPIClient) List(ctx context.Context) ([]SubAgent, error) {
func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (_ SubAgent, err error) { func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (_ SubAgent, err error) {
a.logger.Debug(ctx, "creating sub agent", slog.F("name", agent.Name), slog.F("directory", agent.Directory)) a.logger.Debug(ctx, "creating sub agent", slog.F("name", agent.Name), slog.F("directory", agent.Directory))
var id []byte
if agent.ID != uuid.Nil {
id = agent.ID[:]
}
displayApps := make([]agentproto.CreateSubAgentRequest_DisplayApp, 0, len(agent.DisplayApps)) displayApps := make([]agentproto.CreateSubAgentRequest_DisplayApp, 0, len(agent.DisplayApps))
for _, displayApp := range agent.DisplayApps { for _, displayApp := range agent.DisplayApps {
var app agentproto.CreateSubAgentRequest_DisplayApp var app agentproto.CreateSubAgentRequest_DisplayApp
@@ -228,6 +235,7 @@ func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (_ SubAg
OperatingSystem: agent.OperatingSystem, OperatingSystem: agent.OperatingSystem,
DisplayApps: displayApps, DisplayApps: displayApps,
Apps: apps, Apps: apps,
Id: id,
}) })
if err != nil { if err != nil {
return SubAgent{}, err return SubAgent{}, err
+125
View File
@@ -306,3 +306,128 @@ func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) {
} }
}) })
} }
func TestSubAgent_CloneConfig(t *testing.T) {
t.Parallel()
t.Run("CopiesIDFromDevcontainer", func(t *testing.T) {
t.Parallel()
subAgent := agentcontainers.SubAgent{
ID: uuid.New(),
Name: "original-name",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
DisplayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop},
Apps: []agentcontainers.SubAgentApp{{Slug: "app1"}},
}
expectedID := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")
dc := codersdk.WorkspaceAgentDevcontainer{
Name: "devcontainer-name",
SubagentID: uuid.NullUUID{UUID: expectedID, Valid: true},
}
cloned := subAgent.CloneConfig(dc)
assert.Equal(t, expectedID, cloned.ID)
assert.Equal(t, dc.Name, cloned.Name)
assert.Equal(t, subAgent.Directory, cloned.Directory)
assert.Zero(t, cloned.AuthToken, "AuthToken should not be copied")
})
t.Run("HandlesNilSubagentID", func(t *testing.T) {
t.Parallel()
subAgent := agentcontainers.SubAgent{
ID: uuid.New(),
Name: "original-name",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
}
dc := codersdk.WorkspaceAgentDevcontainer{
Name: "devcontainer-name",
SubagentID: uuid.NullUUID{Valid: false},
}
cloned := subAgent.CloneConfig(dc)
assert.Equal(t, uuid.Nil, cloned.ID)
})
}
func TestSubAgent_EqualConfig(t *testing.T) {
t.Parallel()
base := agentcontainers.SubAgent{
ID: uuid.New(),
Name: "test-agent",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
DisplayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop},
Apps: []agentcontainers.SubAgentApp{
{Slug: "test-app", DisplayName: "Test App"},
},
}
tests := []struct {
name string
modify func(*agentcontainers.SubAgent)
wantEqual bool
}{
{
name: "identical",
modify: func(s *agentcontainers.SubAgent) {},
wantEqual: true,
},
{
name: "different ID",
modify: func(s *agentcontainers.SubAgent) { s.ID = uuid.New() },
wantEqual: true,
},
{
name: "different Name",
modify: func(s *agentcontainers.SubAgent) { s.Name = "different-name" },
wantEqual: false,
},
{
name: "different Directory",
modify: func(s *agentcontainers.SubAgent) { s.Directory = "/different/path" },
wantEqual: false,
},
{
name: "different Architecture",
modify: func(s *agentcontainers.SubAgent) { s.Architecture = "arm64" },
wantEqual: false,
},
{
name: "different OperatingSystem",
modify: func(s *agentcontainers.SubAgent) { s.OperatingSystem = "windows" },
wantEqual: false,
},
{
name: "different DisplayApps",
modify: func(s *agentcontainers.SubAgent) { s.DisplayApps = []codersdk.DisplayApp{codersdk.DisplayAppSSH} },
wantEqual: false,
},
{
name: "different Apps",
modify: func(s *agentcontainers.SubAgent) {
s.Apps = []agentcontainers.SubAgentApp{{Slug: "different-app", DisplayName: "Different App"}}
},
wantEqual: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
modified := base
tt.modify(&modified)
assert.Equal(t, tt.wantEqual, base.EqualConfig(modified))
})
}
}
+8
View File
@@ -20837,6 +20837,14 @@ const docTemplate = `{
} }
] ]
}, },
"subagent_id": {
"format": "uuid",
"allOf": [
{
"$ref": "#/definitions/uuid.NullUUID"
}
]
},
"workspace_folder": { "workspace_folder": {
"type": "string" "type": "string"
} }
+8
View File
@@ -19141,6 +19141,14 @@
} }
] ]
}, },
"subagent_id": {
"format": "uuid",
"allOf": [
{
"$ref": "#/definitions/uuid.NullUUID"
}
]
},
"workspace_folder": { "workspace_folder": {
"type": "string" "type": "string"
} }
+15
View File
@@ -425,11 +425,20 @@ func DevcontainerFromProto(pdc *proto.WorkspaceAgentDevcontainer) (codersdk.Work
if err != nil { if err != nil {
return codersdk.WorkspaceAgentDevcontainer{}, xerrors.Errorf("parse id: %w", err) return codersdk.WorkspaceAgentDevcontainer{}, xerrors.Errorf("parse id: %w", err)
} }
var subagentID uuid.NullUUID
if pdc.SubagentId != nil {
subagentID.Valid = true
subagentID.UUID, err = uuid.FromBytes(pdc.SubagentId)
if err != nil {
return codersdk.WorkspaceAgentDevcontainer{}, xerrors.Errorf("parse subagent id: %w", err)
}
}
return codersdk.WorkspaceAgentDevcontainer{ return codersdk.WorkspaceAgentDevcontainer{
ID: id, ID: id,
Name: pdc.Name, Name: pdc.Name,
WorkspaceFolder: pdc.WorkspaceFolder, WorkspaceFolder: pdc.WorkspaceFolder,
ConfigPath: pdc.ConfigPath, ConfigPath: pdc.ConfigPath,
SubagentID: subagentID,
}, nil }, nil
} }
@@ -442,10 +451,16 @@ func ProtoFromDevcontainers(dcs []codersdk.WorkspaceAgentDevcontainer) []*proto.
} }
func ProtoFromDevcontainer(dc codersdk.WorkspaceAgentDevcontainer) *proto.WorkspaceAgentDevcontainer { func ProtoFromDevcontainer(dc codersdk.WorkspaceAgentDevcontainer) *proto.WorkspaceAgentDevcontainer {
var subagentID []byte
if dc.SubagentID.Valid {
subagentID = dc.SubagentID.UUID[:]
}
return &proto.WorkspaceAgentDevcontainer{ return &proto.WorkspaceAgentDevcontainer{
Id: dc.ID[:], Id: dc.ID[:],
Name: dc.Name, Name: dc.Name,
WorkspaceFolder: dc.WorkspaceFolder, WorkspaceFolder: dc.WorkspaceFolder,
ConfigPath: dc.ConfigPath, ConfigPath: dc.ConfigPath,
SubagentId: subagentID,
} }
} }
+1
View File
@@ -136,6 +136,7 @@ func TestManifest(t *testing.T) {
ID: uuid.New(), ID: uuid.New(),
WorkspaceFolder: "/home/coder/coder", WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json", ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
SubagentID: uuid.NullUUID{Valid: true, UUID: uuid.New()},
}, },
}, },
} }
+12 -4
View File
@@ -440,10 +440,11 @@ func (s WorkspaceAgentDevcontainerStatus) Transitioning() bool {
// WorkspaceAgentDevcontainer defines the location of a devcontainer // WorkspaceAgentDevcontainer defines the location of a devcontainer
// configuration in a workspace that is visible to the workspace agent. // configuration in a workspace that is visible to the workspace agent.
type WorkspaceAgentDevcontainer struct { type WorkspaceAgentDevcontainer struct {
ID uuid.UUID `json:"id" format:"uuid"` ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"` Name string `json:"name"`
WorkspaceFolder string `json:"workspace_folder"` WorkspaceFolder string `json:"workspace_folder"`
ConfigPath string `json:"config_path,omitempty"` ConfigPath string `json:"config_path,omitempty"`
SubagentID uuid.NullUUID `json:"subagent_id,omitempty" format:"uuid"`
// Additional runtime fields. // Additional runtime fields.
Status WorkspaceAgentDevcontainerStatus `json:"status"` Status WorkspaceAgentDevcontainerStatus `json:"status"`
@@ -458,6 +459,7 @@ func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) boo
return d.ID == other.ID && return d.ID == other.ID &&
d.Name == other.Name && d.Name == other.Name &&
d.WorkspaceFolder == other.WorkspaceFolder && d.WorkspaceFolder == other.WorkspaceFolder &&
d.SubagentID == other.SubagentID &&
d.Status == other.Status && d.Status == other.Status &&
d.Dirty == other.Dirty && d.Dirty == other.Dirty &&
(d.Container == nil && other.Container == nil || (d.Container == nil && other.Container == nil ||
@@ -467,6 +469,12 @@ func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) boo
d.Error == other.Error d.Error == other.Error
} }
// IsTerraformDefined returns true if this devcontainer has resources defined
// in Terraform.
func (d WorkspaceAgentDevcontainer) IsTerraformDefined() bool {
return d.SubagentID.Valid
}
// WorkspaceAgentDevcontainerAgent represents the sub agent for a // WorkspaceAgentDevcontainerAgent represents the sub agent for a
// devcontainer. // devcontainer.
type WorkspaceAgentDevcontainerAgent struct { type WorkspaceAgentDevcontainerAgent struct {
+139
View File
@@ -110,3 +110,142 @@ func TestWorkspaceAgentLogTextSpecialChars(t *testing.T) {
result := log.Text("main", "startup_script") result := log.Text("main", "startup_script")
require.Equal(t, "2024-01-28T10:30:00Z [debug] [agent.main|startup_script] \033[31mError!\033[0m 🚀 Unicode: 日本語", result) require.Equal(t, "2024-01-28T10:30:00Z [debug] [agent.main|startup_script] \033[31mError!\033[0m 🚀 Unicode: 日本語", result)
} }
func TestWorkspaceAgentDevcontainerEquals(t *testing.T) {
t.Parallel()
agentID := uuid.New()
base := codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "test-dc",
WorkspaceFolder: "/workspace",
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Dirty: false,
Container: &codersdk.WorkspaceAgentContainer{ID: "container-123"},
Agent: &codersdk.WorkspaceAgentDevcontainerAgent{ID: agentID, Name: "agent-1"},
Error: "",
}
tests := []struct {
name string
modify func(*codersdk.WorkspaceAgentDevcontainer)
wantEqual bool
}{
{
name: "identical",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) {},
wantEqual: true,
},
{
name: "different ID",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) { d.ID = uuid.New() },
wantEqual: false,
},
{
name: "different Name",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) { d.Name = "other-dc" },
wantEqual: false,
},
{
name: "different WorkspaceFolder",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) { d.WorkspaceFolder = "/other" },
wantEqual: false,
},
{
name: "different SubagentID (one valid, one nil)",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) {
d.SubagentID = uuid.NullUUID{Valid: true, UUID: uuid.New()}
},
wantEqual: false,
},
{
name: "different SubagentID UUIDs",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) {
d.SubagentID = uuid.NullUUID{Valid: true, UUID: uuid.New()}
},
wantEqual: false,
},
{
name: "different Status",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) {
d.Status = codersdk.WorkspaceAgentDevcontainerStatusStopped
},
wantEqual: false,
},
{
name: "different Dirty",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) { d.Dirty = true },
wantEqual: false,
},
{
name: "different Container (one nil)",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) { d.Container = nil },
wantEqual: false,
},
{
name: "different Container IDs",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) {
d.Container = &codersdk.WorkspaceAgentContainer{ID: "different-container"}
},
wantEqual: false,
},
{
name: "different Agent (one nil)",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) { d.Agent = nil },
wantEqual: false,
},
{
name: "different Agent values",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) {
d.Agent = &codersdk.WorkspaceAgentDevcontainerAgent{ID: agentID, Name: "agent-2"}
},
wantEqual: false,
},
{
name: "different Error",
modify: func(d *codersdk.WorkspaceAgentDevcontainer) { d.Error = "some error" },
wantEqual: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
modified := base
tt.modify(&modified)
require.Equal(t, tt.wantEqual, base.Equals(modified))
})
}
}
func TestWorkspaceAgentDevcontainerIsTerraformDefined(t *testing.T) {
t.Parallel()
t.Run("SubagentID Valid", func(t *testing.T) {
t.Parallel()
dc := codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "test-dc",
WorkspaceFolder: "/workspace",
SubagentID: uuid.NullUUID{Valid: true, UUID: uuid.New()},
}
require.True(t, dc.IsTerraformDefined())
})
t.Run("SubagentID Null", func(t *testing.T) {
t.Parallel()
dc := codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "test-dc",
WorkspaceFolder: "/workspace",
SubagentID: uuid.NullUUID{Valid: false},
}
require.False(t, dc.IsTerraformDefined())
})
}
+8
View File
@@ -838,6 +838,10 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string", "name": "string",
"status": "running", "status": "running",
"subagent_id": {
"uuid": "string",
"valid": true
},
"workspace_folder": "string" "workspace_folder": "string"
} }
], ],
@@ -1015,6 +1019,10 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string", "name": "string",
"status": "running", "status": "running",
"subagent_id": {
"uuid": "string",
"valid": true
},
"workspace_folder": "string" "workspace_folder": "string"
} }
], ],
+9
View File
@@ -10518,6 +10518,10 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string", "name": "string",
"status": "running", "status": "running",
"subagent_id": {
"uuid": "string",
"valid": true
},
"workspace_folder": "string" "workspace_folder": "string"
} }
``` ```
@@ -10534,6 +10538,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `id` | string | false | | | | `id` | string | false | | |
| `name` | string | false | | | | `name` | string | false | | |
| `status` | [codersdk.WorkspaceAgentDevcontainerStatus](#codersdkworkspaceagentdevcontainerstatus) | false | | Additional runtime fields. | | `status` | [codersdk.WorkspaceAgentDevcontainerStatus](#codersdkworkspaceagentdevcontainerstatus) | false | | Additional runtime fields. |
| `subagent_id` | [uuid.NullUUID](#uuidnulluuid) | false | | |
| `workspace_folder` | string | false | | | | `workspace_folder` | string | false | | |
## codersdk.WorkspaceAgentDevcontainerAgent ## codersdk.WorkspaceAgentDevcontainerAgent
@@ -10665,6 +10670,10 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string", "name": "string",
"status": "running", "status": "running",
"subagent_id": {
"uuid": "string",
"valid": true
},
"workspace_folder": "string" "workspace_folder": "string"
} }
], ],
+1
View File
@@ -6306,6 +6306,7 @@ export interface WorkspaceAgentDevcontainer {
readonly name: string; readonly name: string;
readonly workspace_folder: string; readonly workspace_folder: string;
readonly config_path?: string; readonly config_path?: string;
readonly subagent_id?: string;
/** /**
* Additional runtime fields. * Additional runtime fields.
*/ */
@@ -20,7 +20,7 @@ import {
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api"; import { API } from "api/api";
import { getPreferredProxy } from "contexts/ProxyContext"; import { getPreferredProxy } from "contexts/ProxyContext";
import { spyOn, userEvent, within } from "storybook/test"; import { screen, spyOn, userEvent, within } from "storybook/test";
import { AgentDevcontainerCard } from "./AgentDevcontainerCard"; import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
const meta: Meta<typeof AgentDevcontainerCard> = { const meta: Meta<typeof AgentDevcontainerCard> = {
@@ -185,6 +185,37 @@ export const WithPortForwarding: Story = {
], ],
}; };
export const TerraformManaged: Story = {
args: {
devcontainer: {
...MockWorkspaceAgentDevcontainer,
subagent_id: "precreated-subagent-id",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const label = canvas.getByText("dev container (terraform agent)");
await userEvent.hover(label);
await screen.findByRole("tooltip");
},
};
export const TerraformManagedDirty: Story = {
args: {
devcontainer: {
...MockWorkspaceAgentDevcontainer,
subagent_id: "precreated-subagent-id",
dirty: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const outdatedStatus = canvas.getByText("Outdated");
await userEvent.hover(outdatedStatus);
await screen.findByRole("tooltip");
},
};
export const WithDeleteError: Story = { export const WithDeleteError: Story = {
beforeEach: () => { beforeEach: () => {
spyOn(API, "deleteDevContainer").mockRejectedValue( spyOn(API, "deleteDevContainer").mockRejectedValue(
@@ -183,7 +183,19 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
text-xs text-content-secondary" text-xs text-content-secondary"
> >
<Container size={12} className="mr-1.5" /> <Container size={12} className="mr-1.5" />
<span>dev container</span> {devcontainer.subagent_id ? (
<Tooltip>
<TooltipTrigger asChild>
<span>dev container (terraform agent)</span>
</TooltipTrigger>
<TooltipContent>
This dev container agent is defined in Terraform and has limited
configurability via the devcontainer.json file.
</TooltipContent>
</Tooltip>
) : (
<span>dev container</span>
)}
</div> </div>
<header <header
className="flex items-center justify-between flex-wrap className="flex items-center justify-between flex-wrap
@@ -239,7 +251,6 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
disabled={isTransitioning} disabled={isTransitioning}
> >
<Spinner loading={isTransitioning} /> <Spinner loading={isTransitioning} />
{rebuildButtonLabel(devcontainer)} {rebuildButtonLabel(devcontainer)}
</Button> </Button>
@@ -10,7 +10,6 @@ import {
HelpTooltipText, HelpTooltipText,
HelpTooltipTitle, HelpTooltipTitle,
} from "components/HelpTooltip/HelpTooltip"; } from "components/HelpTooltip/HelpTooltip";
import { Stack } from "components/Stack/Stack";
import { TooltipTrigger } from "components/Tooltip/Tooltip"; import { TooltipTrigger } from "components/Tooltip/Tooltip";
import { RotateCcwIcon } from "lucide-react"; import { RotateCcwIcon } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
@@ -33,10 +32,6 @@ export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
return null; return null;
} }
const title = "Dev Container Outdated";
const opener = "This Dev Container is outdated.";
const text = `${opener} This can happen if you modify your devcontainer.json file after the Dev Container has been created. To fix this, you can rebuild the Dev Container.`;
return ( return (
<HelpTooltip> <HelpTooltip>
<TooltipTrigger className="px-0 py-1 bg-transparent text-inherit border-none opacity-50 hover:opacity-100"> <TooltipTrigger className="px-0 py-1 bg-transparent text-inherit border-none opacity-50 hover:opacity-100">
@@ -45,10 +40,14 @@ export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<HelpTooltipContent> <HelpTooltipContent>
<Stack spacing={1}> <div className="flex flex-col gap-2">
<div> <div>
<HelpTooltipTitle>{title}</HelpTooltipTitle> <HelpTooltipTitle>Dev Container Outdated</HelpTooltipTitle>
<HelpTooltipText>{text}</HelpTooltipText> <HelpTooltipText>
This Dev Container is outdated. This can happen if you modify your
devcontainer.json file after the Dev Container has been created.
To fix this, you can rebuild the Dev Container.
</HelpTooltipText>
</div> </div>
<HelpTooltipLinksGroup> <HelpTooltipLinksGroup>
@@ -60,7 +59,7 @@ export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
Rebuild Dev Container Rebuild Dev Container
</HelpTooltipAction> </HelpTooltipAction>
</HelpTooltipLinksGroup> </HelpTooltipLinksGroup>
</Stack> </div>
</HelpTooltipContent> </HelpTooltipContent>
</HelpTooltip> </HelpTooltip>
); );