Skip to content

Commit f528eb3

Browse files
feat(agent/agentcontainers): auto detect dev containers
1 parent 0cdcf89 commit f528eb3

File tree

4 files changed

+102
-9
lines changed

4 files changed

+102
-9
lines changed

agent/agent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1168,7 +1168,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
11681168
// return existing devcontainers but actual container detection
11691169
// and creation will be deferred.
11701170
a.containerAPI.Init(
1171-
agentcontainers.WithManifestInfo(manifest.OwnerName, manifest.WorkspaceName, manifest.AgentName),
1171+
agentcontainers.WithManifestInfo(manifest.OwnerName, manifest.WorkspaceName, manifest.AgentName, manifest.Directory),
11721172
agentcontainers.WithDevcontainers(manifest.Devcontainers, manifest.Scripts),
11731173
agentcontainers.WithSubAgentClient(agentcontainers.NewSubAgentClientFromAPI(a.logger, aAPI)),
11741174
)

agent/agentcontainers/api.go

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"io/fs"
89
"maps"
910
"net/http"
1011
"os"
@@ -21,6 +22,7 @@ import (
2122
"github.com/fsnotify/fsnotify"
2223
"github.com/go-chi/chi/v5"
2324
"github.com/google/uuid"
25+
"github.com/spf13/afero"
2426
"golang.org/x/xerrors"
2527

2628
"cdr.dev/slog"
@@ -60,6 +62,7 @@ type API struct {
6062
updateInterval time.Duration // Interval for periodic container updates.
6163
logger slog.Logger
6264
watcher watcher.Watcher
65+
fs afero.Fs
6366
execer agentexec.Execer
6467
commandEnv CommandEnv
6568
ccli ContainerCLI
@@ -71,9 +74,10 @@ type API struct {
7174
subAgentURL string
7275
subAgentEnv []string
7376

74-
ownerName string
75-
workspaceName string
76-
parentAgent string
77+
ownerName string
78+
workspaceName string
79+
parentAgent string
80+
agentDirectory string
7781

7882
mu sync.RWMutex // Protects the following fields.
7983
initDone chan struct{} // Closed by Init.
@@ -192,11 +196,12 @@ func WithSubAgentEnv(env ...string) Option {
192196

193197
// WithManifestInfo sets the owner name, and workspace name
194198
// for the sub-agent.
195-
func WithManifestInfo(owner, workspace, parentAgent string) Option {
199+
func WithManifestInfo(owner, workspace, parentAgent, agentDirectory string) Option {
196200
return func(api *API) {
197201
api.ownerName = owner
198202
api.workspaceName = workspace
199203
api.parentAgent = parentAgent
204+
api.agentDirectory = agentDirectory
200205
}
201206
}
202207

@@ -261,6 +266,13 @@ func WithWatcher(w watcher.Watcher) Option {
261266
}
262267
}
263268

269+
// WithFileSystem sets the file system used for discovering projects.
270+
func WithFileSystem(fs afero.Fs) Option {
271+
return func(api *API) {
272+
api.fs = fs
273+
}
274+
}
275+
264276
// ScriptLogger is an interface for sending devcontainer logs to the
265277
// controlplane.
266278
type ScriptLogger interface {
@@ -331,6 +343,9 @@ func NewAPI(logger slog.Logger, options ...Option) *API {
331343
api.watcher = watcher.NewNoop()
332344
}
333345
}
346+
if api.fs == nil {
347+
api.fs = afero.NewOsFs()
348+
}
334349
if api.subAgentClient.Load() == nil {
335350
var c SubAgentClient = noopSubAgentClient{}
336351
api.subAgentClient.Store(&c)
@@ -375,10 +390,85 @@ func (api *API) Start() {
375390
api.watcherDone = make(chan struct{})
376391
api.updaterDone = make(chan struct{})
377392

393+
go func() {
394+
if err := api.discoverDevcontainerProjects(); err != nil {
395+
api.logger.Error(api.ctx, "discovering dev container projects", slog.Error(err))
396+
}
397+
}()
398+
378399
go api.watcherLoop()
379400
go api.updaterLoop()
380401
}
381402

403+
func (api *API) discoverDevcontainerProjects() error {
404+
isGitProject, err := afero.DirExists(api.fs, filepath.Join(api.agentDirectory, "/.git"))
405+
if err != nil {
406+
return xerrors.Errorf(".git dir exists: %w", err)
407+
}
408+
409+
// If the agent directory is a git project, we'll search
410+
// the project for any `.devcontainer/devcontainer.json`
411+
// files.
412+
if isGitProject {
413+
return api.discoverDevcontainersInProject(api.agentDirectory)
414+
}
415+
416+
// The agent directory is _not_ a git project, so we'll
417+
// search the top level of the agent directory for any
418+
// git projects, and search those.
419+
entries, err := afero.ReadDir(api.fs, api.agentDirectory)
420+
if err != nil {
421+
return xerrors.Errorf("read agent directory: %w", err)
422+
}
423+
424+
for _, entry := range entries {
425+
if !entry.IsDir() {
426+
continue
427+
}
428+
429+
isGitProject, err = afero.DirExists(api.fs, filepath.Join(api.agentDirectory, entry.Name(), ".git"))
430+
if err != nil {
431+
return xerrors.Errorf(".git dir exists: %w", err)
432+
}
433+
434+
// If this directory is a git project, we'll search
435+
// it for any `.devcontainer/devcontainer.json` files.
436+
if isGitProject {
437+
if err := api.discoverDevcontainersInProject(filepath.Join(api.agentDirectory, entry.Name())); err != nil {
438+
return err
439+
}
440+
}
441+
}
442+
443+
return nil
444+
}
445+
446+
func (api *API) discoverDevcontainersInProject(projectPath string) error {
447+
return afero.Walk(api.fs, projectPath, func(path string, info fs.FileInfo, err error) error {
448+
if strings.HasSuffix(path, ".devcontainer/devcontainer.json") {
449+
workspaceFolder := strings.TrimSuffix(path, ".devcontainer/devcontainer.json")
450+
451+
api.mu.Lock()
452+
if _, found := api.knownDevcontainers[workspaceFolder]; !found {
453+
dc := codersdk.WorkspaceAgentDevcontainer{
454+
ID: uuid.New(),
455+
Name: "", // Updated later based on container state.
456+
WorkspaceFolder: workspaceFolder,
457+
ConfigPath: path,
458+
Status: "", // Updated later based on container state.
459+
Dirty: false, // Updated later based on config file changes.
460+
Container: nil,
461+
}
462+
463+
api.knownDevcontainers[workspaceFolder] = dc
464+
}
465+
api.mu.Unlock()
466+
}
467+
468+
return nil
469+
})
470+
}
471+
382472
func (api *API) watcherLoop() {
383473
defer close(api.watcherDone)
384474
defer api.logger.Debug(api.ctx, "watcher loop stopped")

agent/agentcontainers/api_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1678,7 +1678,7 @@ func TestAPI(t *testing.T) {
16781678
agentcontainers.WithSubAgentClient(fakeSAC),
16791679
agentcontainers.WithSubAgentURL("test-subagent-url"),
16801680
agentcontainers.WithDevcontainerCLI(fakeDCCLI),
1681-
agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent"),
1681+
agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent", "/parent-agent"),
16821682
)
16831683
api.Start()
16841684
apiClose := func() {
@@ -2662,7 +2662,7 @@ func TestAPI(t *testing.T) {
26622662
agentcontainers.WithSubAgentClient(fSAC),
26632663
agentcontainers.WithSubAgentURL("test-subagent-url"),
26642664
agentcontainers.WithWatcher(watcher.NewNoop()),
2665-
agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent"),
2665+
agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent", "/parent-agent"),
26662666
)
26672667
api.Start()
26682668
defer api.Close()

site/src/modules/resources/AgentDevcontainerCard.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
181181

182182
const appsClasses = "flex flex-wrap gap-4 empty:hidden md:justify-start";
183183

184+
console.log(devcontainer);
185+
184186
return (
185187
<Stack
186188
key={devcontainer.id}
@@ -218,7 +220,8 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
218220
text-sm font-semibold text-content-primary
219221
md:overflow-visible"
220222
>
221-
{subAgent?.name ?? devcontainer.name}
223+
{subAgent?.name ??
224+
(devcontainer.name || devcontainer.config_path)}
222225
{devcontainer.container && (
223226
<span className="text-content-tertiary">
224227
{" "}
@@ -253,7 +256,7 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
253256
disabled={devcontainer.status === "starting"}
254257
>
255258
<Spinner loading={devcontainer.status === "starting"} />
256-
Rebuild
259+
{devcontainer.container === undefined ? <>Start</> : <>Rebuild</>}
257260
</Button>
258261

259262
{showDevcontainerControls && displayApps.includes("ssh_helper") && (

0 commit comments

Comments
 (0)