diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go index 878007d4..1c34afb2 100644 --- a/server/internal/daemon/client.go +++ b/server/internal/daemon/client.go @@ -146,6 +146,21 @@ func (c *Client) ReportPingResult(ctx context.Context, runtimeID, pingID string, return c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/ping/%s/result", runtimeID, pingID), result, nil) } +// WorkspaceInfo holds minimal workspace metadata returned by the API. +type WorkspaceInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// ListWorkspaces fetches all workspaces the authenticated user belongs to. +func (c *Client) ListWorkspaces(ctx context.Context) ([]WorkspaceInfo, error) { + var workspaces []WorkspaceInfo + if err := c.getJSON(ctx, "/api/workspaces", &workspaces); err != nil { + return nil, err + } + return workspaces, nil +} + func (c *Client) Deregister(ctx context.Context, runtimeIDs []string) error { return c.postJSON(ctx, "/api/daemon/deregister", map[string]any{ "runtime_ids": runtimeIDs, diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index f9e942ad..756560da 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -16,8 +16,9 @@ const ( DefaultHeartbeatInterval = 15 * time.Second DefaultAgentTimeout = 2 * time.Hour DefaultRuntimeName = "Local Agent" - DefaultConfigReloadInterval = 5 * time.Second - DefaultHealthPort = 19514 + DefaultConfigReloadInterval = 5 * time.Second + DefaultWorkspaceSyncInterval = 30 * time.Second + DefaultHealthPort = 19514 DefaultMaxConcurrentTasks = 20 ) diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index a25eea95..c4431d14 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -85,6 +85,9 @@ func (d *Daemon) Run(ctx context.Context) error { // Start config watcher for hot-reload. go d.configWatchLoop(ctx) + // Start workspace sync loop to discover newly created workspaces. + go d.workspaceSyncLoop(ctx) + go d.heartbeatLoop(ctx) go d.usageScanLoop(ctx) go d.serveHealth(ctx, healthLn, time.Now()) @@ -276,6 +279,60 @@ func (d *Daemon) configWatchLoop(ctx context.Context) { } } +// workspaceSyncLoop periodically fetches the user's workspaces from the API +// and adds any new ones to the CLI config. The configWatchLoop will then +// detect the config change and register runtimes for the new workspaces. +func (d *Daemon) workspaceSyncLoop(ctx context.Context) { + ticker := time.NewTicker(DefaultWorkspaceSyncInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + d.syncWorkspacesFromAPI(ctx) + } + } +} + +// syncWorkspacesFromAPI fetches all workspaces the user belongs to and adds +// any missing ones to the CLI config's watched list. +func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) { + apiCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + workspaces, err := d.client.ListWorkspaces(apiCtx) + if err != nil { + d.logger.Debug("workspace sync: failed to list workspaces", "error", err) + return + } + + cfg, err := cli.LoadCLIConfig() + if err != nil { + d.logger.Warn("workspace sync: failed to load config", "error", err) + return + } + + var added int + for _, ws := range workspaces { + if cfg.AddWatchedWorkspace(ws.ID, ws.Name) { + added++ + d.logger.Info("workspace sync: discovered new workspace", "workspace_id", ws.ID, "name", ws.Name) + } + } + + if added == 0 { + return + } + + if err := cli.SaveCLIConfig(cfg); err != nil { + d.logger.Warn("workspace sync: failed to save config", "error", err) + return + } + d.logger.Info("workspace sync: added new workspace(s) to config", "count", added) +} + // reloadWorkspaces reconciles the active workspace set with the config file. // NOTE: Token changes (e.g. re-login as a different user) are not picked up; // the daemon must be restarted for a new auth token to take effect.