diff --git a/tools/vscode/CHANGELOG.md b/tools/vscode/CHANGELOG.md index 1aa6a93f5..30706ae2f 100644 --- a/tools/vscode/CHANGELOG.md +++ b/tools/vscode/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.3.34 + +- Show Inspect version in status bar +- Release with internal changes to support future log viewing features. + ## 0.3.33 - Fix bug that prevented run, debug buttons from appearing for tasks whose function declarations spanned more than single line. diff --git a/tools/vscode/assets/icon/eval.svg b/tools/vscode/assets/icon/eval.svg new file mode 100644 index 000000000..7e3ef40fb --- /dev/null +++ b/tools/vscode/assets/icon/eval.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/tools/vscode/package.json b/tools/vscode/package.json index 046afffc0..7a23a7edd 100644 --- a/tools/vscode/package.json +++ b/tools/vscode/package.json @@ -7,7 +7,7 @@ "author": { "name": "UK AI Safety Institute" }, - "version": "0.3.33", + "version": "0.3.34", "license": "MIT", "homepage": "https://inspect.ai-safety-institute.org.uk/", "repository": { @@ -32,12 +32,44 @@ "onWebviewPanel:inspect.logview", "onWebviewPanel:inspect_ai.task-configuration", "onWebviewPanel:inspect_ai.env-configuration-view", + "onLanguage:eval", + "workspaceContains:**/*.eval", "onLanguage:python", - "workspaceContains:*.py", - "workspaceContains:*.ipynb" + "workspaceContains:**/*.py", + "workspaceContains:**/*.ipynb" ], "main": "./dist/extension.js", "contributes": { + "languages": [ + { + "id": "eval-log", + "aliases": [ + "Eval Log" + ], + "extensions": [ + ".eval" + ], + "icon": { + "light": "./assets/icon/eval.svg", + "dark": "./assets/icon/eval.svg" + } + } + ], + "customEditors": [ + { + "viewType": "inspect-ai.log-editor", + "displayName": "Inspect Log Viewer", + "selector": [ + { + "filenamePattern": "*.eval" + }, + { + "filenamePattern": "{[0-9][0-9][0-9][0-9]}-{[0-9][0-9]}-{[0-9][0-9]}T{[0-9][0-9]}[:-]{[0-9][0-9]}[:-]{[0-9][0-9]}*{[A-Za-z0-9]{21}}*.json" + } + ], + "priority": "default" + } + ], "commands": [ { "command": "inspect.showLogview", @@ -163,7 +195,8 @@ "inspect_ai.openLogView": { "type": "boolean", "default": true, - "description": "Open the Inspect log view when evaluations in the workspace complete." + "description": "Open the Inspect Log View when evaluations in the workspace complete.", + "order": 1 }, "inspect_ai.logViewType": { "type": "string", @@ -175,7 +208,14 @@ "markdownEnumDescriptions": [ "Use `inspect view` to display evaluation log files.", "Use a text editor to display evaluation log files." - ] + ], + "order": 2 + }, + "inspect_ai.jsonLogView": { + "type": "boolean", + "default": true, + "markdownDescription": "Use Inspect Log View as the default viewer for JSON log files.", + "order": 3 }, "inspect_ai.taskListView": { "type": "string", @@ -184,12 +224,14 @@ "tree", "list" ], - "description": "Display task outline as a tree or list." + "description": "Display task outline as a tree or list.", + "order": 4 }, "inspect_ai.debugSingleSample": { "type": "boolean", "default": true, - "description": "Limit evaluation to one sample when debugging." + "description": "Limit evaluation to one sample when debugging.", + "order": 5 } } }, @@ -265,7 +307,7 @@ { "command": "inspect.openInInspectView", "group": "navigation@100", - "when": "inspect_ai.enableOpenInView && resourceFilename =~ /^.*\\.json$/" + "when": "inspect_ai.enableOpenInView && resourceFilename =~ /^.*\\.(json|eval)$/" } ], "view/item/context": [ @@ -340,6 +382,7 @@ "test": "vscode-test" }, "devDependencies": { + "@types/async-lock": "^1.4.2", "@types/lodash": "^4.17.0", "@types/mocha": "^10.0.6", "@types/node": "18.x", @@ -362,7 +405,8 @@ "@microsoft/fast-components": "^2.30.6", "@microsoft/fast-element": "^1.13.0", "@vscode/webview-ui-toolkit": "^1.4.0", + "async-lock": "^1.4.1", "lodash": "^4.17.21", "semver": "^7.6.0" } -} +} \ No newline at end of file diff --git a/tools/vscode/src/components/webview.ts b/tools/vscode/src/components/webview.ts index 7e2c195a0..319374a97 100644 --- a/tools/vscode/src/components/webview.ts +++ b/tools/vscode/src/components/webview.ts @@ -5,6 +5,7 @@ import { ExtensionContext, window, commands, + WebviewPanel, } from "vscode"; import { Disposable } from "../core/dispose"; @@ -13,6 +14,7 @@ import { ExtensionHost, HostWebviewPanel } from "../hooks"; import { isNotebook } from "./notebook"; import { FocusManager } from "./focus"; import { log } from "../core/log"; +import { InspectViewServer } from "../providers/inspect/inspect-view-server"; export interface ShowOptions { readonly preserveFocus?: boolean; @@ -21,32 +23,38 @@ export interface ShowOptions { export class InspectWebviewManager, S> { constructor( - protected readonly context: ExtensionContext, + protected readonly context_: ExtensionContext, + private readonly server_: InspectViewServer, private readonly viewType_: string, private readonly title_: string, private readonly localResourceRoots: Uri[], private webviewType_: new ( context: ExtensionContext, + server: InspectViewServer, state: S, webviewPanel: HostWebviewPanel ) => T, private host_: ExtensionHost ) { - this.extensionUri_ = context.extensionUri; + this.extensionUri_ = context_.extensionUri; - context.subscriptions.push( + context_.subscriptions.push( window.registerWebviewPanelSerializer(this.viewType_, { - deserializeWebviewPanel: (panel) => { - //this.restoreWebview(panel as HostWebviewPanel, state); - setTimeout(() => { - panel.dispose(); - }, 200); + deserializeWebviewPanel: (panel: WebviewPanel, state?: S) => { + state = state || this.getWorkspaceState(); + if (state) { + this.restoreWebview(panel as HostWebviewPanel, state); + } else { + setTimeout(() => { + panel.dispose(); + }, 200); + } return Promise.resolve(); }, }) ); - this.focusManager_ = new FocusManager(context); + this.focusManager_ = new FocusManager(context_); } private focusManager_: FocusManager; @@ -62,7 +70,7 @@ export class InspectWebviewManager, S> { if (this.activeView_) { this.activeView_.show(state, options); } else { - const view = this.createWebview(this.context, state, options); + const view = this.createWebview(this.context_, state, options); this.registerWebviewListeners(view); this.activeView_ = view; } @@ -97,6 +105,11 @@ export class InspectWebviewManager, S> { protected onViewStateChanged() { } + protected getWorkspaceState(): S | undefined { + return undefined; + } + + private resolveOnShow() { if (this.onShow_) { this.onShow_(); @@ -147,7 +160,7 @@ export class InspectWebviewManager, S> { private restoreWebview(panel: HostWebviewPanel, state: S): void { - const view = new this.webviewType_(this.context, state, panel); + const view = new this.webviewType_(this.context_, this.server_, state, panel); this.registerWebviewListeners(view); this.activeView_ = view; } @@ -172,7 +185,7 @@ export class InspectWebviewManager, S> { } ); - const inspectWebView = new this.webviewType_(context, state, previewPanel); + const inspectWebView = new this.webviewType_(context, this.server_, state, previewPanel); return inspectWebView; } @@ -217,7 +230,8 @@ export abstract class InspectWebview extends Disposable { public readonly onDispose = this._onDidDispose.event; public constructor( - private readonly context: ExtensionContext, + private readonly _context: ExtensionContext, + private readonly _server: InspectViewServer, state: T, webviewPanel: HostWebviewPanel ) { @@ -229,8 +243,6 @@ export abstract class InspectWebview extends Disposable { this.dispose(); }) ); - - this.show(state); } public override dispose() { @@ -254,7 +266,7 @@ export abstract class InspectWebview extends Disposable { protected abstract getHtml(state: T): string; protected getExtensionVersion(): string { - return (this.context.extension.packageJSON as Record) + return (this._context.extension.packageJSON as Record) .version as string; } @@ -331,7 +343,7 @@ export abstract class InspectWebview extends Disposable { protected extensionResourceUrl(parts: string[]): Uri { return this._webviewPanel.webview.asWebviewUri( - Uri.joinPath(this.context.extensionUri, ...parts) + Uri.joinPath(this._context.extensionUri, ...parts) ); } diff --git a/tools/vscode/src/core/jsonrpc.ts b/tools/vscode/src/core/jsonrpc.ts index 3eb30616b..4efd175c6 100644 --- a/tools/vscode/src/core/jsonrpc.ts +++ b/tools/vscode/src/core/jsonrpc.ts @@ -1,6 +1,8 @@ // constants for json-rpc methods export const kMethodEvalLogs = "eval_logs"; export const kMethodEvalLog = "eval_log"; +export const kMethodEvalLogSize = "eval_log_size"; +export const kMethodEvalLogBytes = "eval_log_bytes"; export const kMethodEvalLogHeaders = "eval_log_headers"; diff --git a/tools/vscode/src/core/process.ts b/tools/vscode/src/core/process.ts index 0759a9fc8..fdb132f6e 100644 --- a/tools/vscode/src/core/process.ts +++ b/tools/vscode/src/core/process.ts @@ -1,4 +1,4 @@ -import { SpawnSyncOptionsWithStringEncoding, spawn, spawnSync } from "child_process"; +import { SpawnOptions, SpawnSyncOptionsWithStringEncoding, spawn, spawnSync } from "child_process"; import { AbsolutePath } from "./path"; @@ -35,10 +35,10 @@ export function runProcess( export function spawnProcess( cmd: string, args: string[], - cwd: AbsolutePath, + options: SpawnOptions, io?: { - stdout?: (data: Buffer | string) => void; - stderr?: (data: Buffer | string) => void; + stdout?: (data: string) => void; + stderr?: (data: string) => void; }, lifecycle?: { onError?: (error: Error) => void; @@ -46,16 +46,14 @@ export function spawnProcess( } ) { // Process options - const options = { - cwd: cwd.path, - detached: true, - }; + options = { detached: true, ...options }; // Start the actual process const process = spawn(cmd, args, options); // Capture stdout if (process.stdout) { + process.stdout.setEncoding("utf-8"); if (io?.stdout) { process.stdout.on("data", io.stdout); } @@ -65,6 +63,7 @@ export function spawnProcess( // Capture stderr if (process.stderr) { + process.stderr.setEncoding("utf-8"); if (io?.stderr) { process.stderr.on("data", io.stderr); } diff --git a/tools/vscode/src/core/python/exec.ts b/tools/vscode/src/core/python/exec.ts index 8a9fcd945..9bd2a3f75 100644 --- a/tools/vscode/src/core/python/exec.ts +++ b/tools/vscode/src/core/python/exec.ts @@ -77,8 +77,8 @@ export function spawnPython( args: string[], cwd: AbsolutePath, io?: { - stdout?: (data: Buffer | string) => void; - stderr?: (data: Buffer | string) => void; + stdout?: (data: string) => void; + stderr?: (data: string) => void; }, lifecycle?: { onError?: (error: Error) => void; @@ -89,7 +89,7 @@ export function spawnPython( if (execCommand) { const cmd = execCommand[0]; args = [...execCommand.slice(1), ...args]; - return spawnProcess(cmd, args, cwd, io, lifecycle); + return spawnProcess(cmd, args, { cwd: cwd.path }, io, lifecycle); } else { throw new Error("No active Python interpreter available."); } diff --git a/tools/vscode/src/extension.ts b/tools/vscode/src/extension.ts index 6b3a841a9..68ce3dab1 100644 --- a/tools/vscode/src/extension.ts +++ b/tools/vscode/src/extension.ts @@ -5,7 +5,7 @@ import { activateCodeLens } from "./providers/codelens/codelens-provider"; import { activateLogview } from "./providers/logview/logview"; import { LogViewFileWatcher } from "./providers/logview/logview-file-watcher"; import { logviewTerminalLinkProvider } from "./providers/logview/logview-link-provider"; -import { InspectLogviewManager } from "./providers/logview/logview-manager"; +import { InspectViewManager } from "./providers/logview/logview-view"; import { InspectSettingsManager } from "./providers/settings/inspect-settings"; import { initializeGlobalSettings } from "./providers/settings/user-settings"; import { activateEvalManager } from "./providers/inspect/inspect-eval"; @@ -24,6 +24,7 @@ import { activateInspectManager } from "./providers/inspect/inspect-manager"; import { checkActiveWorkspaceFolder } from "./core/workspace"; import { inspectBinPath, inspectVersionDescriptor } from "./inspect/props"; import { extensionHost } from "./hooks"; +import { activateStatusBar } from "./providers/statusbar"; const kInspectMinimumVersion = "0.3.8"; @@ -129,6 +130,9 @@ export async function activate(context: ExtensionContext) { // Activate Code Lens activateCodeLens(context); + // Activate Status Bar + activateStatusBar(context, inspectManager); + // Activate commands [ ...logViewCommands, @@ -153,7 +157,7 @@ export function deactivate() { let logFileWatcher: LogViewFileWatcher | undefined; const startLogWatcher = ( - logviewWebviewManager: InspectLogviewManager, + logviewWebviewManager: InspectViewManager, workspaceStateManager: WorkspaceStateManager, settingsMgr: InspectSettingsManager ) => { diff --git a/tools/vscode/src/inspect/props.ts b/tools/vscode/src/inspect/props.ts index 73f33e84d..db2153372 100644 --- a/tools/vscode/src/inspect/props.ts +++ b/tools/vscode/src/inspect/props.ts @@ -13,6 +13,7 @@ import { existsSync } from "fs"; const kPythonPackageName = "inspect_ai"; export interface VersionDescriptor { + raw: string; version: SemVer, isDeveloperBuild: boolean } @@ -96,6 +97,7 @@ export function inspectVersionDescriptor(): VersionDescriptor | null { if (parsedVersion) { const isDeveloperVersion = version.version.indexOf('.dev') > -1; const inspectVersion = { + raw: version.version, version: parsedVersion, isDeveloperBuild: isDeveloperVersion }; diff --git a/tools/vscode/src/inspect/version.ts b/tools/vscode/src/inspect/version.ts index ff8855f5a..d4f506062 100644 --- a/tools/vscode/src/inspect/version.ts +++ b/tools/vscode/src/inspect/version.ts @@ -4,11 +4,18 @@ export function withMinimumInspectVersion(version: string, hasVersion: () => voi export function withMinimumInspectVersion(version: string, hasVersion: () => T, doesntHaveVersion: () => T): T; export function withMinimumInspectVersion(version: string, hasVersion: () => T, doesntHaveVersion: () => T): T | void { - const descriptor = inspectVersionDescriptor(); - if (descriptor && (descriptor.version.compare(version) >= 0 || descriptor.isDeveloperBuild)) { + if (hasMinimumInspectVersion(version)) { return hasVersion(); } else { return doesntHaveVersion(); } } +export function hasMinimumInspectVersion(version: string, strictDevCheck = false): boolean { + const descriptor = inspectVersionDescriptor(); + if (descriptor && (descriptor.version.compare(version) >= 0 || (!strictDevCheck && descriptor.isDeveloperBuild))) { + return true; + } else { + return false; + } +} \ No newline at end of file diff --git a/tools/vscode/src/providers/activity-bar/activity-bar-provider.ts b/tools/vscode/src/providers/activity-bar/activity-bar-provider.ts index 7bc31cbbe..47c5422a7 100644 --- a/tools/vscode/src/providers/activity-bar/activity-bar-provider.ts +++ b/tools/vscode/src/providers/activity-bar/activity-bar-provider.ts @@ -9,12 +9,12 @@ import { WorkspaceStateManager } from "../workspace/workspace-state-provider"; import { TaskConfigurationProvider } from "./task-config-provider"; import { InspectManager } from "../inspect/inspect-manager"; import { DebugConfigTaskCommand, RunConfigTaskCommand } from "./task-config-commands"; -import { InspectLogviewManager } from "../logview/logview-manager"; +import { InspectViewManager } from "../logview/logview-view"; export async function activateActivityBar( inspectManager: InspectManager, inspectEvalMgr: InspectEvalManager, - inspectLogviewManager: InspectLogviewManager, + inspectLogviewManager: InspectViewManager, activeTaskManager: ActiveTaskManager, workspaceTaskMgr: WorkspaceTaskManager, workspaceStateMgr: WorkspaceStateManager, diff --git a/tools/vscode/src/providers/activity-bar/task-outline-commands.ts b/tools/vscode/src/providers/activity-bar/task-outline-commands.ts index 941c223c7..e4cda5b37 100644 --- a/tools/vscode/src/providers/activity-bar/task-outline-commands.ts +++ b/tools/vscode/src/providers/activity-bar/task-outline-commands.ts @@ -31,7 +31,7 @@ import { taskRangeForNotebook, } from "../../components/notebook"; import { scheduleReturnFocus } from "../../components/focus"; -import { InspectLogviewManager } from "../logview/logview-manager"; +import { InspectViewManager } from "../logview/logview-view"; import { ActiveTaskManager } from "../active-task/active-task-provider"; export class ShowTaskTree implements Command { @@ -93,7 +93,7 @@ export class DebugSelectedEvalCommand implements Command { export class EditSelectedTaskCommand implements Command { constructor( private readonly tree_: TreeView, - private inspectLogviewManager_: InspectLogviewManager, + private inspectLogviewManager_: InspectViewManager, private activeTaskManager_: ActiveTaskManager ) { } async execute() { diff --git a/tools/vscode/src/providers/activity-bar/task-outline-provider.ts b/tools/vscode/src/providers/activity-bar/task-outline-provider.ts index 306220ff4..91e153b6e 100644 --- a/tools/vscode/src/providers/activity-bar/task-outline-provider.ts +++ b/tools/vscode/src/providers/activity-bar/task-outline-provider.ts @@ -38,7 +38,7 @@ import { import { throttle } from "lodash"; import { inspectVersion } from "../../inspect"; import { InspectManager } from "../inspect/inspect-manager"; -import { InspectLogviewManager } from "../logview/logview-manager"; +import { InspectViewManager } from "../logview/logview-view"; import { DocumentTaskInfo } from "../../components/task"; // Activation function for the task outline @@ -48,7 +48,7 @@ export async function activateTaskOutline( workspaceTaskMgr: WorkspaceTaskManager, activeTaskManager: ActiveTaskManager, inspectManager: InspectManager, - inspectLogviewManager: InspectLogviewManager + inspectLogviewManager: InspectViewManager ): Promise<[Command[], Disposable]> { // Command when item is clicked const treeDataProvider = new TaskOutLineTreeDataProvider(workspaceTaskMgr, { diff --git a/tools/vscode/src/providers/inspect/inspect-constants.ts b/tools/vscode/src/providers/inspect/inspect-constants.ts index b6f3818af..d094b8a0f 100644 --- a/tools/vscode/src/providers/inspect/inspect-constants.ts +++ b/tools/vscode/src/providers/inspect/inspect-constants.ts @@ -14,3 +14,4 @@ export const kLogLevelEnv = "INSPECT_EVAL_MODEL"; export const kInspectChangeEvalSignalVersion = "0.3.10"; export const kInspectOpenInspectViewVersion = "0.3.16"; export const kInspectMaxLogFileSizeVersion = "0.3.26"; +export const kInspectEvalLogFormatVersion = "0.3.42"; diff --git a/tools/vscode/src/providers/inspect/inspect-view-server.ts b/tools/vscode/src/providers/inspect/inspect-view-server.ts new file mode 100644 index 000000000..d62aca3b6 --- /dev/null +++ b/tools/vscode/src/providers/inspect/inspect-view-server.ts @@ -0,0 +1,281 @@ +import { ChildProcess, SpawnOptions } from "child_process"; +import { randomUUID } from "crypto"; +import * as os from "os"; +import AsyncLock from "async-lock"; + +import { Disposable, ExtensionContext, OutputChannel, Uri, window } from "vscode"; + +import { findOpenPort } from "../../core/port"; +import { hasMinimumInspectVersion, withMinimumInspectVersion } from "../../inspect/version"; +import { kInspectEvalLogFormatVersion, kInspectOpenInspectViewVersion } from "./inspect-constants"; +import { inspectEvalLog, inspectEvalLogHeaders, inspectEvalLogs } from "../../inspect/logs"; +import { activeWorkspacePath } from "../../core/path"; +import { inspectBinPath } from "../../inspect/props"; +import { shQuote } from "../../core/string"; +import { spawnProcess } from "../../core/process"; +import { InspectManager } from "./inspect-manager"; + + +export class InspectViewServer implements Disposable { + constructor(context: ExtensionContext, inspectManager: InspectManager) { + // create output channel for debugging + this.outputChannel_ = window.createOutputChannel("Inspect View"); + + // shutdown server when inspect version changes (then we'll launch + // a new instance w/ the correct version) + context.subscriptions.push( + inspectManager.onInspectChanged(() => { + this.shutdown(); + }) + ); + } + + public async evalLogs(log_dir: Uri): Promise { + if (this.haveInspectEvalLogFormat()) { + return this.api_json(`/api/logs?log_dir=${encodeURIComponent(log_dir.toString())}`); + } else { + return evalLogs(log_dir); + } + } + + public async evalLogsSolo(log_file: Uri): Promise { + if (this.haveInspectEvalLogFormat()) { + await this.ensureRunning(); + } + return JSON.stringify({ log_dir: "", files: [{ name: log_file.toString(true) }] }); + } + + public async evalLog( + file: string, + headerOnly: boolean | number + ): Promise { + if (this.haveInspectEvalLogFormat()) { + return await this.api_json(`/api/logs/${encodeURIComponent(file)}?header-only=${headerOnly}`); + } else { + return evalLog(file, headerOnly); + } + } + + + public async evalLogSize( + file: string + ): Promise { + + if (this.haveInspectEvalLogFormat()) { + return Number(await this.api_json(`/api/log-size/${encodeURIComponent(file)}`)); + } else { + throw new Error("evalLogSize not implemented"); + } + } + + public async evalLogBytes( + file: string, + start: number, + end: number + ): Promise { + if (this.haveInspectEvalLogFormat()) { + return this.api_bytes(`/api/log-bytes/${encodeURIComponent(file)}?start=${start}&end=${end}`); + } else { + throw new Error("evalLogBytes not implemented"); + } + } + + public async evalLogHeaders(files: string[]): Promise { + + if (this.haveInspectEvalLogFormat()) { + const params = new URLSearchParams(); + for (const file of files) { + params.append("file", file); + } + return this.api_json(`/api/log-headers?${params.toString()}`); + } else { + return evalLogHeaders(files); + } + + } + + private async ensureRunning(): Promise { + + // only do this if we have a new enough version of inspect + if (!this.haveInspectEvalLogFormat()) { + return; + } + + await this.serverStartupLock_.acquire("server-startup", async () => { + if (this.serverProcess_ === undefined || this.serverProcess_.exitCode !== null) { + + // find port and establish auth token + this.serverProcess_ = undefined; + this.serverPort_ = await findOpenPort(7676); + this.serverAuthToken_ = randomUUID(); + + // launch server and wait to resolve/return until it produces output + return new Promise((resolve, reject) => { + + // find inspect + const inspect = inspectBinPath(); + if (!inspect) { + throw new Error("inspect view: inspect installation not found"); + } + + // launch process + const options: SpawnOptions = { + cwd: activeWorkspacePath().path, + env: { + "COLUMNS": "150", + "INSPECT_VIEW_AUTHORIZATION_TOKEN": this.serverAuthToken_, + }, + shell: os.platform() === "win32" + }; + + // forward output to channel and resolve promise + let resolved = false; + const onOutput = (output: string) => { + this.outputChannel_.append(output); + if (!resolved) { + if (output.includes("Running on ")) { + resolved = true; + resolve(undefined); + } + } + }; + + // run server + const quote = os.platform() === "win32" ? shQuote : (arg: string) => arg; + const args = [ + "view", "start", + "--port", String(this.serverPort_), + "--log-level", "info", "--no-ansi" + ]; + this.serverProcess_ = spawnProcess(quote(inspect.path), args.map(quote), options, { + stdout: onOutput, + stderr: onOutput, + }, { + onClose: (code: number) => { + this.outputChannel_.appendLine(`Inspect View exited with code ${code} (pid=${this.serverProcess_?.pid})`); + }, + onError: (error: Error) => { + this.outputChannel_.appendLine(`Error starting Inspect View ${error.message}`); + reject(error); + }, + }); + this.outputChannel_.appendLine(`Starting Inspect View on port ${this.serverPort_} (pid=${this.serverProcess_?.pid})`); + }); + + } + }); + } + + + private haveInspectEvalLogFormat() { + return hasMinimumInspectVersion(kInspectEvalLogFormatVersion, true); + } + + private async api_json(path: string): Promise { + return await this.api(path, false) as string; + } + + private async api_bytes(path: string): Promise { + return await this.api(path, true) as Uint8Array; + } + + private async api(path: string, binary: boolean = false): Promise { + + // ensure the server is started and ready + await this.ensureRunning(); + + // build headers + const headers = { + Authorization: this.serverAuthToken_, + Accept: binary ? "application/octet-stream" : "application/json", + Pragma: "no-cache", + Expires: "0", + ["Cache-Control"]: "no-cache", + }; + + // make request + const response = await fetch(`http://localhost:${this.serverPort_}${path}`, { method: "GET", headers }); + if (response.ok) { + if (binary) { + const buffer = await response.arrayBuffer(); + return new Uint8Array(buffer); + } else { + return await response.text(); + } + } else if (response.status !== 200) { + const message = (await response.text()) || response.statusText; + const error = new Error(`Error: ${response.status}: ${message})`); + throw error; + } else { + throw new Error(`${response.status} - ${response.statusText} `); + } + } + + private shutdown() { + this.serverProcess_?.kill(); + this.serverProcess_ = undefined; + this.serverPort_ = undefined; + this.serverAuthToken_ = ""; + } + + dispose() { + this.shutdown(); + this.outputChannel_.dispose(); + } + + private outputChannel_: OutputChannel; + private serverStartupLock_ = new AsyncLock(); + private serverProcess_?: ChildProcess = undefined; + private serverPort_?: number = undefined; + private serverAuthToken_: string = ""; + +} + + + +// The eval commands below need to be coordinated in terms of their working directory +// The evalLogs() call will return log files with relative paths to the working dir (if possible) +// and subsequent calls like evalLog() need to be able to deal with these relative paths +// by using the same working directory. +// +// So, we always use the workspace root as the working directory and will resolve +// paths that way. Note that paths can be S3 urls, for example, in which case the paths +// will be absolute (so cwd doesn't really matter so much in this case). +function evalLogs(log_dir: Uri): Promise { + // Return both the log_dir and the logs + + const response = withMinimumInspectVersion( + kInspectOpenInspectViewVersion, + () => { + const logs = inspectEvalLogs(activeWorkspacePath(), log_dir); + const logsJson = logs ? (JSON.parse(logs) as unknown) : []; + return JSON.stringify({ log_dir: log_dir.toString(), files: logsJson }); + }, + () => { + // Return the original log content + return inspectEvalLogs(activeWorkspacePath()); + } + ); + return Promise.resolve(response); + +} + +function evalLog( + file: string, + headerOnly: boolean | number +): Promise { + // Old clients pass a boolean value which we need to resolve + // into the max number of MB the log can be before samples are excluded + // and it becomes header_only + if (typeof headerOnly === "boolean") { + headerOnly = headerOnly ? 0 : Number.MAX_SAFE_INTEGER; + } + + return Promise.resolve( + inspectEvalLog(activeWorkspacePath(), file, headerOnly) + ); +} + +function evalLogHeaders(files: string[]) { + return Promise.resolve(inspectEvalLogHeaders(activeWorkspacePath(), files)); +} diff --git a/tools/vscode/src/providers/logview/commands.ts b/tools/vscode/src/providers/logview/commands.ts index d11359113..ae9200434 100644 --- a/tools/vscode/src/providers/logview/commands.ts +++ b/tools/vscode/src/providers/logview/commands.ts @@ -1,24 +1,20 @@ import { Command } from "../../core/command"; -import { InspectLogviewManager } from "./logview-manager"; +import { InspectViewManager } from "./logview-view"; import { showError } from "../../components/error"; import { MessageItem, Uri, commands, window } from "vscode"; import { withMinimumInspectVersion } from "../../inspect/version"; import { kInspectOpenInspectViewVersion } from "../inspect/inspect-constants"; import { inspectLogInfo } from "../../inspect/logs"; - -export interface LogviewState { - log_file?: Uri; - log_dir: Uri; - background_refresh?: boolean; -} +import { LogviewState } from "./logview-state"; export interface LogviewOptions { state?: LogviewState; activate?: boolean; } + export async function logviewCommands( - manager: InspectLogviewManager, + manager: InspectViewManager, ): Promise { // Check whether the open in inspect view command should be enabled @@ -35,7 +31,7 @@ export async function logviewCommands( } class ShowLogviewCommand implements Command { - constructor(private readonly manager_: InspectLogviewManager) { } + constructor(private readonly manager_: InspectViewManager) { } async execute(): Promise { // ensure logview is visible try { @@ -54,7 +50,7 @@ class ShowLogviewCommand implements Command { class OpenInInspectViewCommand implements Command { - constructor(private readonly manager_: InspectLogviewManager) { } + constructor(private readonly manager_: InspectViewManager) { } async execute(fileUri: Uri): Promise { // ensure logview is visible try { diff --git a/tools/vscode/src/providers/logview/logview-editor.ts b/tools/vscode/src/providers/logview/logview-editor.ts new file mode 100644 index 000000000..a5971c3ce --- /dev/null +++ b/tools/vscode/src/providers/logview/logview-editor.ts @@ -0,0 +1,122 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import * as vscode from 'vscode'; +import { Uri, window } from 'vscode'; +import { inspectViewPath } from '../../inspect/props'; +import { LogviewPanel } from './logview-panel'; +import { InspectViewServer } from '../inspect/inspect-view-server'; +import { HostWebviewPanel } from '../../hooks'; +import { InspectSettingsManager } from "../settings/inspect-settings"; +import { log } from '../../core/log'; +import { hasMinimumInspectVersion } from '../../inspect/version'; +import { kInspectEvalLogFormatVersion } from '../inspect/inspect-constants'; + + +class InspectLogReadonlyEditor implements vscode.CustomReadonlyEditorProvider { + + static register( + context: vscode.ExtensionContext, + settings: InspectSettingsManager, + server: InspectViewServer + ): vscode.Disposable { + const provider = new InspectLogReadonlyEditor(context, settings, server); + const providerRegistration = vscode.window.registerCustomEditorProvider( + InspectLogReadonlyEditor.viewType, + provider, + { + webviewOptions: { + retainContextWhenHidden: false + }, + supportsMultipleEditorsPerDocument: true + } + ); + return providerRegistration; + } + + private static readonly viewType = 'inspect-ai.log-editor'; + + constructor( + private readonly context_: vscode.ExtensionContext, + private readonly settings_: InspectSettingsManager, + private readonly server_: InspectViewServer + ) { } + + // eslint-disable-next-line @typescript-eslint/require-await + async openCustomDocument( + uri: vscode.Uri, + _openContext: vscode.CustomDocumentOpenContext, + _token: vscode.CancellationToken + ): Promise { + return { uri, dispose: () => { } }; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async resolveCustomEditor( + document: vscode.CustomDocument, + webviewPanel: vscode.WebviewPanel, + _token: vscode.CancellationToken + ): Promise { + + // check if we should use the log viewer (pref + never show files > 100mb) + let useLogViewer = this.settings_.getSettings().jsonLogView; + + // Check for a required version + useLogViewer = hasMinimumInspectVersion(kInspectEvalLogFormatVersion, true); + + if (useLogViewer) { + const docUri = document.uri.toString(); + if (docUri.endsWith(".json")) { + const fileSize = await this.server_.evalLogSize(docUri); + if (fileSize > (1024 * 1000 * 100)) { + log.info(`JSON log file ${document.uri.path} is to large for Inspect View, opening in text editor.`); + useLogViewer = false; + } + } + } + + if (useLogViewer) { + // local resource roots + const localResourceRoots: Uri[] = []; + const viewDir = inspectViewPath(); + if (viewDir) { + localResourceRoots.push(Uri.file(viewDir.path)); + } + Uri.joinPath(this.context_.extensionUri, "assets", "www"); + + // set webview options + webviewPanel.webview.options = { + enableScripts: true, + enableForms: true, + localResourceRoots + }; + + // editor panel implementation + this.logviewPanel_ = new LogviewPanel( + webviewPanel as HostWebviewPanel, + this.context_, + this.server_, + "file", + document.uri + ); + + // set html + webviewPanel.webview.html = this.logviewPanel_.getHtml(document.uri); + } else { + const viewColumn = webviewPanel.viewColumn; + await vscode.commands.executeCommand('vscode.openWith', document.uri, 'default', viewColumn); + } + } + + dispose() { + this.logviewPanel_?.dispose(); + } + + private logviewPanel_?: LogviewPanel; + +} + +export function activateLogviewEditor( + context: vscode.ExtensionContext, + settings: InspectSettingsManager, + server: InspectViewServer) { + context.subscriptions.push(InspectLogReadonlyEditor.register(context, settings, server)); +} \ No newline at end of file diff --git a/tools/vscode/src/providers/logview/logview-file-watcher.ts b/tools/vscode/src/providers/logview/logview-file-watcher.ts index b7025fe6d..a51d81b54 100644 --- a/tools/vscode/src/providers/logview/logview-file-watcher.ts +++ b/tools/vscode/src/providers/logview/logview-file-watcher.ts @@ -1,5 +1,5 @@ import { Uri, Disposable } from "vscode"; -import { InspectLogviewManager } from "./logview-manager"; +import { InspectViewManager } from "./logview-view"; import { showError } from "../../components/error"; import { inspectLastEvalPath } from "../../inspect/props"; @@ -13,7 +13,7 @@ import { InspectSettingsManager } from "../settings/inspect-settings"; export class LogViewFileWatcher implements Disposable { constructor( - private readonly logviewManager_: InspectLogviewManager, + private readonly logviewManager_: InspectViewManager, private readonly workspaceStateManager_: WorkspaceStateManager, private readonly settingsMgr_: InspectSettingsManager ) { diff --git a/tools/vscode/src/providers/logview/logview-link-provider.ts b/tools/vscode/src/providers/logview/logview-link-provider.ts index 9a66d7455..c05f1ebda 100644 --- a/tools/vscode/src/providers/logview/logview-link-provider.ts +++ b/tools/vscode/src/providers/logview/logview-link-provider.ts @@ -1,6 +1,6 @@ import { MessageItem, Uri, window, workspace } from "vscode"; -import { InspectLogviewManager } from "./logview-manager"; +import { InspectViewManager } from "./logview-view"; import { workspacePath } from "../../core/path"; import { showError } from "../../components/error"; import { TerminalLink, TerminalLinkContext } from "vscode"; @@ -13,7 +13,7 @@ interface LogViewTerminalLink extends TerminalLink { data: string; } -export const logviewTerminalLinkProvider = (manager: InspectLogviewManager) => { +export const logviewTerminalLinkProvider = (manager: InspectViewManager) => { return { provideTerminalLinks: ( context: TerminalLinkContext, diff --git a/tools/vscode/src/providers/logview/logview-manager.ts b/tools/vscode/src/providers/logview/logview-manager.ts deleted file mode 100644 index e4c46b897..000000000 --- a/tools/vscode/src/providers/logview/logview-manager.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Uri, ViewColumn, window, workspace } from "vscode"; -import { InspectLogviewWebviewManager } from "./logview-webview"; -import { InspectSettingsManager } from "../settings/inspect-settings"; -import { WorkspaceEnvManager } from "../workspace/workspace-env-provider"; -import { activeWorkspaceFolder } from "../../core/workspace"; -import { workspacePath } from "../../core/path"; -import { kInspectEnvValues } from "../inspect/inspect-constants"; -import { join } from "path"; - -export class InspectLogviewManager { - constructor( - private readonly webViewManager_: InspectLogviewWebviewManager, - private readonly settingsMgr_: InspectSettingsManager, - private readonly envMgr_: WorkspaceEnvManager - ) { } - - public async showLogFile(logFile: Uri, activation?: "open" | "activate") { - const settings = this.settingsMgr_.getSettings(); - if (settings.logViewType === "text" && logFile.scheme === "file") { - await workspace.openTextDocument(logFile).then(async (doc) => { - await window.showTextDocument(doc, { - preserveFocus: true, - viewColumn: ViewColumn.Two, - }); - }); - } else { - await this.webViewManager_.showLogFile(logFile, activation); - } - } - - public async showInspectView() { - // See if there is a log dir - const envVals = this.envMgr_.getValues(); - const env_log = envVals[kInspectEnvValues.logDir]; - - // If there is a log dir, try to parse and use it - let log_uri; - try { - log_uri = Uri.parse(env_log, true); - } catch { - // This isn't a uri, bud - const logDir = env_log ? workspacePath(env_log).path : join(workspacePath().path, "logs"); - log_uri = Uri.file(logDir); - } - - // Show the log view for the log dir (or the workspace) - const log_dir = log_uri || activeWorkspaceFolder().uri; - await this.webViewManager_.showLogview({ log_dir }, "activate"); - } - - public viewColumn() { - return this.webViewManager_.viewColumn(); - } -} diff --git a/tools/vscode/src/providers/logview/logview-panel.ts b/tools/vscode/src/providers/logview/logview-panel.ts new file mode 100644 index 000000000..c1b9e9ae0 --- /dev/null +++ b/tools/vscode/src/providers/logview/logview-panel.ts @@ -0,0 +1,250 @@ +import vscode from "vscode"; +import { env, ExtensionContext, MessageItem, Uri, window } from "vscode"; +import { getNonce } from "../../core/nonce"; +import { HostWebviewPanel } from "../../hooks"; +import { inspectViewPath } from "../../inspect/props"; +import { readFileSync } from "fs"; +import { Disposable } from "../../core/dispose"; +import { jsonRpcPostMessageServer, JsonRpcPostMessageTarget, JsonRpcServerMethod, kMethodEvalLog, kMethodEvalLogBytes, kMethodEvalLogHeaders, kMethodEvalLogs, kMethodEvalLogSize } from "../../core/jsonrpc"; +import { InspectViewServer } from "../inspect/inspect-view-server"; +import { workspacePath } from "../../core/path"; + + + +export class LogviewPanel extends Disposable { + constructor( + private panel_: HostWebviewPanel, + private context_: ExtensionContext, + server: InspectViewServer, + type: "file" | "dir", + uri: Uri, + ) { + super(); + + // serve eval log api to webview + this._rpcDisconnect = webviewPanelJsonRpcServer(panel_, { + [kMethodEvalLogs]: async () => type === "dir" + ? server.evalLogs(uri) + : server.evalLogsSolo(uri), + [kMethodEvalLog]: (params: unknown[]) => server.evalLog(params[0] as string, params[1] as number | boolean), + [kMethodEvalLogSize]: (params: unknown[]) => server.evalLogSize(params[0] as string), + [kMethodEvalLogBytes]: (params: unknown[]) => server.evalLogBytes(params[0] as string, params[1] as number, params[2] as number), + [kMethodEvalLogHeaders]: (params: unknown[]) => server.evalLogHeaders(params[0] as string[]) + }); + + // serve post message api to webview + this._pmUnsubcribe = panel_.webview.onDidReceiveMessage( + async (e: { type: string; url: string;[key: string]: unknown }) => { + switch (e.type) { + case "openExternal": + try { + const url = Uri.parse(e.url); + await env.openExternal(url); + } catch { + // Noop + } + break; + case "openWorkspaceFile": + { + if (e.url) { + const file = workspacePath(e.url); + try { + await window.showTextDocument(Uri.file(file.path)); + } catch (err) { + if ( + err instanceof Error && + err.name === "CodeExpectedError" + ) { + const close: MessageItem = { title: "Close" }; + await window.showInformationMessage( + "This file is too large to be opened by the viewer.", + close + ); + } else { + throw err; + } + } + } + } + break; + } + } + ); + } + + public dispose() { + this._rpcDisconnect(); + this._pmUnsubcribe.dispose(); + } + + public getHtml(log_file?: Uri): string { + // read the index.html from the log view directory + const viewDir = inspectViewPath(); + if (viewDir) { + // get nonce + const nonce = getNonce(); + + // file uri for view dir + const viewDirUri = Uri.file(viewDir.path); + + // get base html + let indexHtml = readFileSync(viewDir.child("index.html").path, "utf-8"); + + // Determine whether this is the old unbundled version of the html or the new + // bundled version + const isUnbundled = indexHtml.match(/"\.(\/App\.mjs)"/g); + + // Add a stylesheet to further customize the view appearance + const overrideCssPath = this.extensionResourceUrl([ + "assets", + "www", + "view", + "view-overrides.css", + ]); + const overrideCssHtml = isUnbundled + ? `` + : ""; + + // If there is a log file selected in state, embed the startup message + // within the view itself. This will allow the log to be set immediately + // which avoids timing issues when first opening the view (e.g. the updateState + // message being sent before the view itself is configured to receive messages) + const stateMsg = { + type: "updateState", + url: log_file?.toString(), + }; + const stateScript = + log_file + ? `` + : ""; + + // decorate the html tag + indexHtml = indexHtml.replace("\n", + ` + + + ${stateScript} + ${overrideCssHtml} + + ` + ); + + // function to resolve resource uri + const resourceUri = (path: string) => + this.panel_.webview + .asWebviewUri(Uri.joinPath(viewDirUri, path)) + .toString(); + + // nonces for scripts + indexHtml = indexHtml.replace( + /])/g, + `` - : ""; - - // decorate the html tag - indexHtml = indexHtml.replace("\n", - ` - - - ${stateScript} - ${overrideCssHtml} - - ` - ); - - // function to resolve resource uri - const resourceUri = (path: string) => - this._webviewPanel.webview - .asWebviewUri(Uri.joinPath(viewDirUri, path)) - .toString(); - - // nonces for scripts - indexHtml = indexHtml.replace( - /])/g, - `