Skip to content

Commit 022e832

Browse files
committed
Merge remote-tracking branch 'github/main' into asher/binary-verification
2 parents c4f3d0a + cb2a4ec commit 022e832

File tree

10 files changed

+1184
-268
lines changed

10 files changed

+1184
-268
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@
1313
and configFile are provided.
1414
- Add `coder.disableUpdateNotifications` setting to disable workspace template
1515
update notifications.
16+
- Coder output panel enhancements: All log entries now include timestamps, and you
17+
can filter messages by log level in the panel.
18+
- Consistently use the same session for each agent. Previously,
19+
depending on how you connected, it could be possible to get two
20+
different sessions for an agent. Existing connections may still
21+
have this problem, only new connections are fixed.
22+
- Added an agent metadata monitor status bar item, so you can view your active
23+
agent metadata at a glance.
1624
- Add binary signature verification. This can be disabled with
1725
`coder.disableSignatureVerification` if you purposefully run a binary that is
1826
not signed by Coder (for example a binary you built yourself).

package.json

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,8 @@
295295
"memfs": "^4.17.1",
296296
"node-forge": "^1.3.1",
297297
"openpgp": "^6.2.0",
298-
"pretty-bytes": "^6.1.1",
299-
"proxy-agent": "^6.4.0",
298+
"pretty-bytes": "^7.0.0",
299+
"proxy-agent": "^6.5.0",
300300
"semver": "^7.7.1",
301301
"ua-parser-js": "1.0.40",
302302
"ws": "^8.18.2",
@@ -314,7 +314,7 @@
314314
"@typescript-eslint/parser": "^6.21.0",
315315
"@vscode/test-cli": "^0.0.10",
316316
"@vscode/test-electron": "^2.5.2",
317-
"@vscode/vsce": "^2.21.1",
317+
"@vscode/vsce": "^3.6.0",
318318
"bufferutil": "^4.0.9",
319319
"coder": "https://github.com/coder/coder#main",
320320
"dayjs": "^1.11.13",
@@ -329,8 +329,7 @@
329329
"nyc": "^17.1.0",
330330
"prettier": "^3.5.3",
331331
"ts-loader": "^9.5.1",
332-
"tsc-watch": "^6.2.1",
333-
"typescript": "^5.4.5",
332+
"typescript": "^5.8.3",
334333
"utf-8-validate": "^6.0.5",
335334
"vitest": "^0.34.6",
336335
"vscode-test": "^1.5.0",

src/agentMetadataHelper.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Api } from "coder/site/src/api/api";
2+
import { WorkspaceAgent } from "coder/site/src/api/typesGenerated";
3+
import { EventSource } from "eventsource";
4+
import * as vscode from "vscode";
5+
import { createStreamingFetchAdapter } from "./api";
6+
import {
7+
AgentMetadataEvent,
8+
AgentMetadataEventSchemaArray,
9+
errToStr,
10+
} from "./api-helper";
11+
12+
export type AgentMetadataWatcher = {
13+
onChange: vscode.EventEmitter<null>["event"];
14+
dispose: () => void;
15+
metadata?: AgentMetadataEvent[];
16+
error?: unknown;
17+
};
18+
19+
/**
20+
* Opens an SSE connection to watch metadata for a given workspace agent.
21+
* Emits onChange when metadata updates or an error occurs.
22+
*/
23+
export function createAgentMetadataWatcher(
24+
agentId: WorkspaceAgent["id"],
25+
restClient: Api,
26+
): AgentMetadataWatcher {
27+
// TODO: Is there a better way to grab the url and token?
28+
const url = restClient.getAxiosInstance().defaults.baseURL;
29+
const metadataUrl = new URL(
30+
`${url}/api/v2/workspaceagents/${agentId}/watch-metadata`,
31+
);
32+
const eventSource = new EventSource(metadataUrl.toString(), {
33+
fetch: createStreamingFetchAdapter(restClient.getAxiosInstance()),
34+
});
35+
36+
let disposed = false;
37+
const onChange = new vscode.EventEmitter<null>();
38+
const watcher: AgentMetadataWatcher = {
39+
onChange: onChange.event,
40+
dispose: () => {
41+
if (!disposed) {
42+
eventSource.close();
43+
disposed = true;
44+
}
45+
},
46+
};
47+
48+
eventSource.addEventListener("data", (event) => {
49+
try {
50+
const dataEvent = JSON.parse(event.data);
51+
const metadata = AgentMetadataEventSchemaArray.parse(dataEvent);
52+
53+
// Overwrite metadata if it changed.
54+
if (JSON.stringify(watcher.metadata) !== JSON.stringify(metadata)) {
55+
watcher.metadata = metadata;
56+
onChange.fire(null);
57+
}
58+
} catch (error) {
59+
watcher.error = error;
60+
onChange.fire(null);
61+
}
62+
});
63+
64+
return watcher;
65+
}
66+
67+
export function formatMetadataError(error: unknown): string {
68+
return "Failed to query metadata: " + errToStr(error, "no error provided");
69+
}
70+
71+
export function formatEventLabel(metadataEvent: AgentMetadataEvent): string {
72+
return getEventName(metadataEvent) + ": " + getEventValue(metadataEvent);
73+
}
74+
75+
export function getEventName(metadataEvent: AgentMetadataEvent): string {
76+
return metadataEvent.description.display_name.trim();
77+
}
78+
79+
export function getEventValue(metadataEvent: AgentMetadataEvent): string {
80+
return metadataEvent.result.value.replace(/\n/g, "").trim();
81+
}

src/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as vscode from "vscode";
1212
import * as ws from "ws";
1313
import { errToStr } from "./api-helper";
1414
import { CertificateError } from "./error";
15+
import { FeatureSet } from "./featureSet";
1516
import { getHeaderArgs } from "./headers";
1617
import { getProxyForUrl } from "./proxy";
1718
import { Storage } from "./storage";
@@ -174,6 +175,7 @@ export async function startWorkspaceIfStoppedOrFailed(
174175
binPath: string,
175176
workspace: Workspace,
176177
writeEmitter: vscode.EventEmitter<string>,
178+
featureSet: FeatureSet,
177179
): Promise<Workspace> {
178180
// Before we start a workspace, we make an initial request to check it's not already started
179181
const updatedWorkspace = await restClient.getWorkspace(workspace.id);
@@ -191,6 +193,10 @@ export async function startWorkspaceIfStoppedOrFailed(
191193
"--yes",
192194
workspace.owner_name + "/" + workspace.name,
193195
];
196+
if (featureSet.buildReason) {
197+
startArgs.push(...["--reason", "vscode_connection"]);
198+
}
199+
194200
const startProcess = spawn(binPath, startArgs);
195201

196202
startProcess.stdout.on("data", (data: Buffer) => {

src/commands.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -437,12 +437,15 @@ export class Commands {
437437
if (!baseUrl) {
438438
throw new Error("You are not logged in");
439439
}
440+
if (treeItem.primaryAgentName === undefined) {
441+
return;
442+
}
440443
await openWorkspace(
441444
baseUrl,
442445
treeItem.workspaceOwner,
443446
treeItem.workspaceName,
444-
treeItem.workspaceAgent,
445-
treeItem.workspaceFolderPath,
447+
treeItem.primaryAgentName,
448+
treeItem.primaryAgentFolderPath,
446449
true,
447450
);
448451
} else {
@@ -525,6 +528,8 @@ export class Commands {
525528
let folderPath: string | undefined;
526529
let openRecent: boolean | undefined;
527530

531+
let workspace: Workspace | undefined;
532+
528533
const baseUrl = this.restClient.getAxiosInstance().defaults.baseURL;
529534
if (!baseUrl) {
530535
throw new Error("You are not logged in");
@@ -571,7 +576,7 @@ export class Commands {
571576
});
572577
});
573578
quickPick.show();
574-
const workspace = await new Promise<Workspace | undefined>((resolve) => {
579+
workspace = await new Promise<Workspace | undefined>((resolve) => {
575580
quickPick.onDidHide(() => {
576581
resolve(undefined);
577582
});
@@ -590,20 +595,31 @@ export class Commands {
590595
}
591596
workspaceOwner = workspace.owner_name;
592597
workspaceName = workspace.name;
598+
} else {
599+
workspaceOwner = args[0] as string;
600+
workspaceName = args[1] as string;
601+
workspaceAgent = args[2] as string | undefined;
602+
folderPath = args[3] as string | undefined;
603+
openRecent = args[4] as boolean | undefined;
604+
}
605+
606+
if (!workspaceAgent) {
607+
if (workspace === undefined) {
608+
workspace = await this.restClient.getWorkspaceByOwnerAndName(
609+
workspaceOwner,
610+
workspaceName,
611+
);
612+
}
593613

594614
const agent = await this.maybeAskAgent(workspace);
595615
if (!agent) {
596616
// User declined to pick an agent.
597617
return;
598618
}
599-
folderPath = agent.expanded_directory;
619+
if (!folderPath) {
620+
folderPath = agent.expanded_directory;
621+
}
600622
workspaceAgent = agent.name;
601-
} else {
602-
workspaceOwner = args[0] as string;
603-
workspaceName = args[1] as string;
604-
workspaceAgent = args[2] as string | undefined;
605-
folderPath = args[3] as string | undefined;
606-
openRecent = args[4] as boolean | undefined;
607623
}
608624

609625
await openWorkspace(
@@ -655,14 +671,15 @@ export class Commands {
655671
if (!this.workspace || !this.workspaceRestClient) {
656672
return;
657673
}
658-
const action = await this.vscodeProposed.window.showInformationMessage(
674+
const action = await this.vscodeProposed.window.showWarningMessage(
659675
"Update Workspace",
660676
{
661677
useCustom: true,
662678
modal: true,
663-
detail: `Update ${this.workspace.owner_name}/${this.workspace.name} to the latest version?`,
679+
detail: `Update ${this.workspace.owner_name}/${this.workspace.name} to the latest version?\n\nUpdating will restart your workspace which stops any running processes and may result in the loss of unsaved work.`,
664680
},
665681
"Update",
682+
"Cancel",
666683
);
667684
if (action === "Update") {
668685
await this.workspaceRestClient.updateWorkspaceVersion(this.workspace);
@@ -678,7 +695,7 @@ async function openWorkspace(
678695
baseUrl: string,
679696
workspaceOwner: string,
680697
workspaceName: string,
681-
workspaceAgent: string | undefined,
698+
workspaceAgent: string,
682699
folderPath: string | undefined,
683700
openRecent: boolean | undefined,
684701
) {

src/featureSet.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type FeatureSet = {
44
vscodessh: boolean;
55
proxyLogDirectory: boolean;
66
wildcardSSH: boolean;
7+
buildReason: boolean;
78
};
89

910
/**
@@ -29,5 +30,10 @@ export function featureSetForVersion(
2930
wildcardSSH:
3031
(version ? version.compare("2.19.0") : -1) >= 0 ||
3132
version?.prerelease[0] === "devel",
33+
34+
// The --reason flag was added to `coder start` in 2.25.0
35+
buildReason:
36+
(version?.compare("2.25.0") || 0) >= 0 ||
37+
version?.prerelease[0] === "devel",
3238
};
3339
}

src/remote.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { isAxiosError } from "axios";
22
import { Api } from "coder/site/src/api/api";
3-
import { Workspace } from "coder/site/src/api/typesGenerated";
3+
import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated";
44
import find from "find-process";
55
import * as fs from "fs/promises";
66
import * as jsonc from "jsonc-parser";
@@ -9,6 +9,12 @@ import * as path from "path";
99
import prettyBytes from "pretty-bytes";
1010
import * as semver from "semver";
1111
import * as vscode from "vscode";
12+
import {
13+
createAgentMetadataWatcher,
14+
getEventValue,
15+
formatEventLabel,
16+
formatMetadataError,
17+
} from "./agentMetadataHelper";
1218
import {
1319
createHttpAgent,
1420
makeCoderSdk,
@@ -68,6 +74,7 @@ export class Remote {
6874
workspace: Workspace,
6975
label: string,
7076
binPath: string,
77+
featureSet: FeatureSet,
7178
): Promise<Workspace | undefined> {
7279
const workspaceName = `${workspace.owner_name}/${workspace.name}`;
7380

@@ -136,6 +143,7 @@ export class Remote {
136143
binPath,
137144
workspace,
138145
writeEmitter,
146+
featureSet,
139147
);
140148
break;
141149
case "failed":
@@ -153,6 +161,7 @@ export class Remote {
153161
binPath,
154162
workspace,
155163
writeEmitter,
164+
featureSet,
156165
);
157166
break;
158167
}
@@ -383,6 +392,7 @@ export class Remote {
383392
workspace,
384393
parts.label,
385394
binaryPath,
395+
featureSet,
386396
);
387397
if (!updatedWorkspace) {
388398
// User declined to start the workspace.
@@ -620,6 +630,10 @@ export class Remote {
620630
}),
621631
);
622632

633+
disposables.push(
634+
...this.createAgentMetadataStatusBar(agent, workspaceRestClient),
635+
);
636+
623637
this.storage.output.info("Remote setup complete");
624638

625639
// Returning the URL and token allows the plugin to authenticate its own
@@ -962,6 +976,56 @@ export class Remote {
962976
return loop();
963977
}
964978

979+
/**
980+
* Creates and manages a status bar item that displays metadata information for a given workspace agent.
981+
* The status bar item updates dynamically based on changes to the agent's metadata,
982+
* and hides itself if no metadata is available or an error occurs.
983+
*/
984+
private createAgentMetadataStatusBar(
985+
agent: WorkspaceAgent,
986+
restClient: Api,
987+
): vscode.Disposable[] {
988+
const statusBarItem = vscode.window.createStatusBarItem(
989+
"agentMetadata",
990+
vscode.StatusBarAlignment.Left,
991+
);
992+
993+
const agentWatcher = createAgentMetadataWatcher(agent.id, restClient);
994+
995+
const onChangeDisposable = agentWatcher.onChange(() => {
996+
if (agentWatcher.error) {
997+
const errMessage = formatMetadataError(agentWatcher.error);
998+
this.storage.output.warn(errMessage);
999+
1000+
statusBarItem.text = "$(warning) Agent Status Unavailable";
1001+
statusBarItem.tooltip = errMessage;
1002+
statusBarItem.color = new vscode.ThemeColor(
1003+
"statusBarItem.warningForeground",
1004+
);
1005+
statusBarItem.backgroundColor = new vscode.ThemeColor(
1006+
"statusBarItem.warningBackground",
1007+
);
1008+
statusBarItem.show();
1009+
return;
1010+
}
1011+
1012+
if (agentWatcher.metadata && agentWatcher.metadata.length > 0) {
1013+
statusBarItem.text =
1014+
"$(dashboard) " + getEventValue(agentWatcher.metadata[0]);
1015+
statusBarItem.tooltip = agentWatcher.metadata
1016+
.map((metadata) => formatEventLabel(metadata))
1017+
.join("\n");
1018+
statusBarItem.color = undefined;
1019+
statusBarItem.backgroundColor = undefined;
1020+
statusBarItem.show();
1021+
} else {
1022+
statusBarItem.hide();
1023+
}
1024+
});
1025+
1026+
return [statusBarItem, agentWatcher, onChangeDisposable];
1027+
}
1028+
9651029
// closeRemote ends the current remote session.
9661030
public async closeRemote() {
9671031
await vscode.commands.executeCommand("workbench.action.remote.close");

0 commit comments

Comments
 (0)