Skip to content

Commit e3e4272

Browse files
authored
Reuse agent metadata watchers (#204)
1 parent 5ab2eb5 commit e3e4272

File tree

2 files changed

+112
-54
lines changed

2 files changed

+112
-54
lines changed

src/api-helper.ts

+17-3
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
22
import { z } from "zod"
33

4+
export function errToStr(error: unknown, def: string) {
5+
if (error instanceof Error && error.message) {
6+
return error.message
7+
}
8+
if (typeof error === "string" && error.trim().length > 0) {
9+
return error
10+
}
11+
return def
12+
}
13+
14+
export function extractAllAgents(workspaces: Workspace[]): WorkspaceAgent[] {
15+
return workspaces.reduce((acc, workspace) => {
16+
return acc.concat(extractAgents(workspace))
17+
}, [] as WorkspaceAgent[])
18+
}
19+
420
export function extractAgents(workspace: Workspace): WorkspaceAgent[] {
5-
const agents = workspace.latest_build.resources.reduce((acc, resource) => {
21+
return workspace.latest_build.resources.reduce((acc, resource) => {
622
return acc.concat(resource.agents || [])
723
}, [] as WorkspaceAgent[])
8-
9-
return agents
1024
}
1125

1226
export const AgentMetadataEventSchema = z.object({

src/workspacesProvider.ts

+95-51
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,26 @@ import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
33
import EventSource from "eventsource"
44
import * as path from "path"
55
import * as vscode from "vscode"
6-
import { AgentMetadataEvent, AgentMetadataEventSchemaArray, extractAgents } from "./api-helper"
6+
import {
7+
AgentMetadataEvent,
8+
AgentMetadataEventSchemaArray,
9+
extractAllAgents,
10+
extractAgents,
11+
errToStr,
12+
} from "./api-helper"
713
import { Storage } from "./storage"
814

915
export enum WorkspaceQuery {
1016
Mine = "owner:me",
1117
All = "",
1218
}
1319

14-
type AgentWatcher = { dispose: () => void; metadata?: AgentMetadataEvent[] }
20+
type AgentWatcher = {
21+
onChange: vscode.EventEmitter<null>["event"]
22+
dispose: () => void
23+
metadata?: AgentMetadataEvent[]
24+
error?: unknown
25+
}
1526

1627
export class WorkspaceProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
1728
private workspaces: WorkspaceTreeItem[] = []
@@ -39,9 +50,6 @@ export class WorkspaceProvider implements vscode.TreeDataProvider<vscode.TreeIte
3950
}
4051
this.fetching = true
4152

42-
// TODO: It would be better to reuse these.
43-
Object.values(this.agentWatchers).forEach((watcher) => watcher.dispose())
44-
4553
// It is possible we called fetchAndRefresh() manually (through the button
4654
// for example), in which case we might still have a pending refresh that
4755
// needs to be cleared.
@@ -93,12 +101,38 @@ export class WorkspaceProvider implements vscode.TreeDataProvider<vscode.TreeIte
93101
return this.fetch()
94102
}
95103

96-
return resp.workspaces.map((workspace) => {
97-
const showMetadata = this.getWorkspacesQuery === WorkspaceQuery.Mine
98-
if (showMetadata) {
99-
const agents = extractAgents(workspace)
100-
agents.forEach((agent) => this.monitorMetadata(agent.id, url, token2)) // monitor metadata for all agents
104+
const oldWatcherIds = Object.keys(this.agentWatchers)
105+
const reusedWatcherIds: string[] = []
106+
107+
// TODO: I think it might make more sense for the tree items to contain
108+
// their own watchers, rather than recreate the tree items every time and
109+
// have this separate map held outside the tree.
110+
const showMetadata = this.getWorkspacesQuery === WorkspaceQuery.Mine
111+
if (showMetadata) {
112+
const agents = extractAllAgents(resp.workspaces)
113+
agents.forEach((agent) => {
114+
// If we have an existing watcher, re-use it.
115+
if (this.agentWatchers[agent.id]) {
116+
reusedWatcherIds.push(agent.id)
117+
return this.agentWatchers[agent.id]
118+
}
119+
// Otherwise create a new watcher.
120+
const watcher = monitorMetadata(agent.id, url, token2)
121+
watcher.onChange(() => this.refresh())
122+
this.agentWatchers[agent.id] = watcher
123+
return watcher
124+
})
125+
}
126+
127+
// Dispose of watchers we ended up not reusing.
128+
oldWatcherIds.forEach((id) => {
129+
if (!reusedWatcherIds.includes(id)) {
130+
this.agentWatchers[id].dispose()
131+
delete this.agentWatchers[id]
101132
}
133+
})
134+
135+
return resp.workspaces.map((workspace) => {
102136
return new WorkspaceTreeItem(workspace, this.getWorkspacesQuery === WorkspaceQuery.All, showMetadata)
103137
})
104138
}
@@ -157,61 +191,69 @@ export class WorkspaceProvider implements vscode.TreeDataProvider<vscode.TreeIte
157191
)
158192
return Promise.resolve(agentTreeItems)
159193
} else if (element instanceof AgentTreeItem) {
160-
const savedMetadata = this.agentWatchers[element.agent.id]?.metadata || []
194+
const watcher = this.agentWatchers[element.agent.id]
195+
if (watcher?.error) {
196+
return Promise.resolve([new ErrorTreeItem(watcher.error)])
197+
}
198+
const savedMetadata = watcher?.metadata || []
161199
return Promise.resolve(savedMetadata.map((metadata) => new AgentMetadataTreeItem(metadata)))
162200
}
163201

164202
return Promise.resolve([])
165203
}
166204
return Promise.resolve(this.workspaces)
167205
}
206+
}
168207

169-
// monitorMetadata opens an SSE endpoint to monitor metadata on the specified
170-
// agent and registers a disposer that can be used to stop the watch.
171-
monitorMetadata(agentId: WorkspaceAgent["id"], url: string, token: string): void {
172-
const agentMetadataURL = new URL(`${url}/api/v2/workspaceagents/${agentId}/watch-metadata`)
173-
const agentMetadataEventSource = new EventSource(agentMetadataURL.toString(), {
174-
headers: {
175-
"Coder-Session-Token": token,
176-
},
177-
})
178-
179-
let disposed = false
180-
const watcher: AgentWatcher = {
181-
dispose: () => {
182-
if (!disposed) {
183-
delete this.agentWatchers[agentId]
184-
agentMetadataEventSource.close()
185-
disposed = true
186-
}
187-
},
188-
}
208+
// monitorMetadata opens an SSE endpoint to monitor metadata on the specified
209+
// agent and registers a watcher that can be disposed to stop the watch and
210+
// emits an event when the metadata changes.
211+
function monitorMetadata(agentId: WorkspaceAgent["id"], url: string, token: string): AgentWatcher {
212+
const metadataUrl = new URL(`${url}/api/v2/workspaceagents/${agentId}/watch-metadata`)
213+
const eventSource = new EventSource(metadataUrl.toString(), {
214+
headers: {
215+
"Coder-Session-Token": token,
216+
},
217+
})
218+
219+
let disposed = false
220+
const onChange = new vscode.EventEmitter<null>()
221+
const watcher: AgentWatcher = {
222+
onChange: onChange.event,
223+
dispose: () => {
224+
if (!disposed) {
225+
eventSource.close()
226+
disposed = true
227+
}
228+
},
229+
}
189230

190-
this.agentWatchers[agentId] = watcher
231+
eventSource.addEventListener("data", (event) => {
232+
try {
233+
const dataEvent = JSON.parse(event.data)
234+
const metadata = AgentMetadataEventSchemaArray.parse(dataEvent)
191235

192-
agentMetadataEventSource.addEventListener("data", (event) => {
193-
try {
194-
const dataEvent = JSON.parse(event.data)
195-
const agentMetadata = AgentMetadataEventSchemaArray.parse(dataEvent)
236+
// Overwrite metadata if it changed.
237+
if (JSON.stringify(watcher.metadata) !== JSON.stringify(metadata)) {
238+
watcher.metadata = metadata
239+
onChange.fire(null)
240+
}
241+
} catch (error) {
242+
watcher.error = error
243+
onChange.fire(null)
244+
}
245+
})
196246

197-
if (agentMetadata.length === 0) {
198-
watcher.dispose()
199-
}
247+
return watcher
248+
}
200249

201-
// Overwrite metadata if it changed.
202-
if (JSON.stringify(watcher.metadata) !== JSON.stringify(agentMetadata)) {
203-
watcher.metadata = agentMetadata
204-
this.refresh()
205-
}
206-
} catch (error) {
207-
watcher.dispose()
208-
}
209-
})
250+
class ErrorTreeItem extends vscode.TreeItem {
251+
constructor(error: unknown) {
252+
super("Failed to query metadata: " + errToStr(error, "no error provided"), vscode.TreeItemCollapsibleState.None)
253+
this.contextValue = "coderAgentMetadata"
210254
}
211255
}
212256

213-
type CoderTreeItemType = "coderWorkspaceSingleAgent" | "coderWorkspaceMultipleAgents" | "coderAgent"
214-
215257
class AgentMetadataTreeItem extends vscode.TreeItem {
216258
constructor(metadataEvent: AgentMetadataEvent) {
217259
const label =
@@ -225,6 +267,8 @@ class AgentMetadataTreeItem extends vscode.TreeItem {
225267
}
226268
}
227269

270+
type CoderOpenableTreeItemType = "coderWorkspaceSingleAgent" | "coderWorkspaceMultipleAgents" | "coderAgent"
271+
228272
export class OpenableTreeItem extends vscode.TreeItem {
229273
constructor(
230274
label: string,
@@ -236,7 +280,7 @@ export class OpenableTreeItem extends vscode.TreeItem {
236280
public readonly workspaceAgent: string | undefined,
237281
public readonly workspaceFolderPath: string | undefined,
238282

239-
contextValue: CoderTreeItemType,
283+
contextValue: CoderOpenableTreeItemType,
240284
) {
241285
super(label, collapsibleState)
242286
this.contextValue = contextValue

0 commit comments

Comments
 (0)