@@ -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+
5761mu sync.Mutex
5862}
5963
@@ -92,7 +96,7 @@ type Options struct {
9296UpdateAgentMetricsFn 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 {
96100if opts .Clock == nil {
97101opts .Clock = quartz .NewReal ()
98102}
@@ -114,6 +118,13 @@ func New(opts Options) *API {
114118WorkspaceID :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+
117128api .AnnouncementBannerAPI = & AnnouncementBannerAPI {
118129appearanceFetcher :opts .AppearanceFetcher ,
119130}
@@ -139,6 +150,7 @@ func New(opts Options) *API {
139150
140151api .StatsAPI = & StatsAPI {
141152AgentFn :api .agent ,
153+ Workspace :api .cachedWorkspaceFields ,
142154Database :opts .Database ,
143155Log :opts .Log ,
144156StatsReporter :opts .StatsReporter ,
@@ -162,10 +174,11 @@ func New(opts Options) *API {
162174}
163175
164176api .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
171184api .LogsAPI = & LogsAPI {
@@ -205,6 +218,10 @@ func New(opts Options) *API {
205218Database :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+
208225return api
209226}
210227
@@ -254,6 +271,56 @@ func (a *API) agent(ctx context.Context) (database.WorkspaceAgent, error) {
254271return 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 {
258325a .opts .PublishWorkspaceUpdateFn (ctx ,a .opts .OwnerID , wspubsub.WorkspaceEvent {
259326Kind :kind ,