@@ -36,6 +36,8 @@ import (
3636 "github.com/coder/quartz"
3737)
3838
39+ const workspaceCacheRefreshInterval = 5 * time .Minute
40+
3941// API implements the DRPC agent API interface from agent/proto. This struct is
4042// instantiated once per agent connection and kept alive for the duration of the
4143// session.
@@ -54,6 +56,8 @@ type API struct {
5456 * SubAgentAPI
5557 * tailnet.DRPCService
5658
59+ cachedWorkspaceFields * CachedWorkspaceFields
60+
5761 mu sync.Mutex
5862}
5963
@@ -92,7 +96,7 @@ type Options struct {
9296 UpdateAgentMetricsFn func (ctx context.Context , labels prometheusmetrics.AgentMetricLabels , metrics []* agentproto.Stats_Metric )
9397}
9498
95- func New (opts Options ) * API {
99+ func New (opts Options , workspace database. Workspace ) * API {
96100 if opts .Clock == nil {
97101 opts .Clock = quartz .NewReal ()
98102 }
@@ -114,6 +118,13 @@ func New(opts Options) *API {
114118 WorkspaceID : opts .WorkspaceID ,
115119 }
116120
121+ // Don't cache details for prebuilds, though the cached fields will eventually be updated
122+ // by the refresh routine once the prebuild workspace is claimed.
123+ api .cachedWorkspaceFields = & CachedWorkspaceFields {}
124+ if ! workspace .IsPrebuild () {
125+ api .cachedWorkspaceFields .UpdateValues (workspace )
126+ }
127+
117128 api .AnnouncementBannerAPI = & AnnouncementBannerAPI {
118129 appearanceFetcher : opts .AppearanceFetcher ,
119130 }
@@ -139,6 +150,7 @@ func New(opts Options) *API {
139150
140151 api .StatsAPI = & StatsAPI {
141152 AgentFn : api .agent ,
153+ Workspace : api .cachedWorkspaceFields ,
142154 Database : opts .Database ,
143155 Log : opts .Log ,
144156 StatsReporter : opts .StatsReporter ,
@@ -162,10 +174,11 @@ func New(opts Options) *API {
162174 }
163175
164176 api .MetadataAPI = & MetadataAPI {
165- AgentFn : api .agent ,
166- Database : opts .Database ,
167- Pubsub : opts .Pubsub ,
168- Log : opts .Log ,
177+ AgentFn : api .agent ,
178+ Workspace : api .cachedWorkspaceFields ,
179+ Database : opts .Database ,
180+ Pubsub : opts .Pubsub ,
181+ Log : opts .Log ,
169182 }
170183
171184 api .LogsAPI = & LogsAPI {
@@ -205,6 +218,10 @@ func New(opts Options) *API {
205218 Database : opts .Database ,
206219 }
207220
221+ // Start background cache refresh loop to handle workspace changes
222+ // like prebuild claims where owner_id and other fields may be modified in the DB.
223+ go api .startCacheRefreshLoop (opts .Ctx )
224+
208225 return api
209226}
210227
@@ -254,6 +271,56 @@ func (a *API) agent(ctx context.Context) (database.WorkspaceAgent, error) {
254271 return agent , nil
255272}
256273
274+ // refreshCachedWorkspace periodically updates the cached workspace fields.
275+ // This ensures that changes like prebuild claims (which modify owner_id, name, etc.)
276+ // are eventually reflected in the cache without requiring agent reconnection.
277+ func (a * API ) refreshCachedWorkspace (ctx context.Context ) {
278+ ws , err := a .opts .Database .GetWorkspaceByID (ctx , a .opts .WorkspaceID )
279+ if err != nil {
280+ a .opts .Log .Warn (ctx , "failed to refresh cached workspace fields" , slog .Error (err ))
281+ a .cachedWorkspaceFields .Clear ()
282+ return
283+ }
284+
285+ if ws .IsPrebuild () {
286+ return
287+ }
288+
289+ // If we still have the same values, skip the update and logging calls.
290+ if a .cachedWorkspaceFields .identity .Equal (database .WorkspaceIdentityFromWorkspace (ws )) {
291+ return
292+ }
293+ // Update fields that can change during workspace lifecycle (e.g., AutostartSchedule)
294+ a .cachedWorkspaceFields .UpdateValues (ws )
295+
296+ a .opts .Log .Debug (ctx , "refreshed cached workspace fields" ,
297+ slog .F ("workspace_id" , ws .ID ),
298+ slog .F ("owner_id" , ws .OwnerID ),
299+ slog .F ("name" , ws .Name ))
300+ }
301+
302+ // startCacheRefreshLoop runs a background goroutine that periodically refreshes
303+ // the cached workspace fields. This is primarily needed to handle prebuild claims
304+ // where the owner_id and other fields change while the agent connection persists.
305+ func (a * API ) startCacheRefreshLoop (ctx context.Context ) {
306+ // Refresh every 5 minutes. This provides a reasonable balance between:
307+ // - Keeping cache fresh for prebuild claims and other workspace updates
308+ // - Minimizing unnecessary database queries
309+ ticker := a .opts .Clock .TickerFunc (ctx , workspaceCacheRefreshInterval , func () error {
310+ a .refreshCachedWorkspace (ctx )
311+ return nil
312+ }, "cache_refresh" )
313+
314+ // We need to wait on the ticker exiting.
315+ _ = ticker .Wait ()
316+
317+ a .opts .Log .Debug (ctx , "cache refresh loop exited, invalidating the workspace cache on agent API" ,
318+ slog .F ("workspace_id" , a .cachedWorkspaceFields .identity .ID ),
319+ slog .F ("owner_id" , a .cachedWorkspaceFields .identity .OwnerUsername ),
320+ slog .F ("name" , a .cachedWorkspaceFields .identity .Name ))
321+ a .cachedWorkspaceFields .Clear ()
322+ }
323+
257324func (a * API ) publishWorkspaceUpdate (ctx context.Context , agent * database.WorkspaceAgent , kind wspubsub.WorkspaceEventKind ) error {
258325 a .opts .PublishWorkspaceUpdateFn (ctx , a .opts .OwnerID , wspubsub.WorkspaceEvent {
259326 Kind : kind ,
0 commit comments