diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af930a3a34d..0e23a20d71d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -427,6 +427,32 @@ Example: } ``` +Overrides specifically for the Amazon Q language server can be set using the `aws.dev.amazonqLsp` setting. This is a JSON object consisting of keys/values required to override language server: `manifestUrl`, `supportedVersions`, `id`, and `path`. + +Example: + +```json +"aws.dev.amazonqLsp": { + "manifestUrl": "https://custom.url/manifest.json", + "supportedVersions": "4.0.0", + "id": "AmazonQ", + "path": "/custom/path/to/local/lsp/folder", +} +``` + +Overrides specifically for the Amazon Q Workspace Context language server can be set using the `aws.dev.amazonqWorkspaceLsp` setting. This is a JSON object consisting of keys/values required to override language server: `manifestUrl`, `supportedVersions`, `id`, and `path`. + +Example: + +```json +"aws.dev.amazonqWorkspaceLsp": { + "manifestUrl": "https://custom.url/manifest.json", + "supportedVersions": "4.0.0", + "id": "AmazonQ", + "path": "/custom/path/to/local/lsp/folder", +} +``` + ### Environment variables Environment variables can be used to modify the behaviour of VSCode. The following are environment variables that can be used to configure the extension: @@ -472,6 +498,14 @@ Unlike the user setting overrides, not all of these environment variables have t - `__CODEWHISPERER_REGION`: for aws.dev.codewhispererService.region - `__CODEWHISPERER_ENDPOINT`: for aws.dev.codewhispererService.endpoint +- `__AMAZONQLSP_MANIFEST_URL`: for aws.dev.amazonqLsp.manifestUrl +- `__AMAZONQLSP_SUPPORTED_VERSIONS`: for aws.dev.amazonqLsp.supportedVersions +- `__AMAZONQLSP_ID`: for aws.dev.amazonqLsp.id +- `__AMAZONQLSP_PATH`: for aws.dev.amazonqWorkspaceLsp.locationOverride +- `__AMAZONQWORKSPACELSP_MANIFEST_URL`: for aws.dev.amazonqWorkspaceLsp.manifestUrl +- `__AMAZONQWORKSPACELSP_SUPPORTED_VERSIONS`: for aws.dev.amazonqWorkspaceLsp.supportedVersions +- `__AMAZONQWORKSPACELSP_ID`: for aws.dev.amazonqWorkspaceLsp.id +- `__AMAZONQWORKSPACELSP_PATH`: for aws.dev.amazonqWorkspaceLsp.locationOverride #### Lambda diff --git a/docs/TEST_E2E.md b/docs/TEST_E2E.md index 32009d13103..10927c098e3 100644 --- a/docs/TEST_E2E.md +++ b/docs/TEST_E2E.md @@ -17,3 +17,27 @@ With this approach, the follow things can be tested: - Whether or not certain features show/not show depending on the status of the users auth - Run requests directly against the backend and see if we get results back - Clicking any follow up buttons (including examples) + +## Flare Chat E2E Test flow (Not implemented yet) + +This is the new flow that should be introduced when we moved to Flare chat. + +```mermaid +sequenceDiagram + participant test as Test + participant framework as Test Framework + participant ui as Virtual DOM + participant lsp as Language Server + participant mynah as Mynah UI + + test->>test: starts + test->>framework: creates test framework + framework->>ui: adds mynah ui to virtual dom + test->>lsp: waits for language server activation + test->>mynah: triggers action on mynah ui + mynah->>framework: sends message + framework->>lsp: sends message + lsp->>framework: gets response + framework->>ui: displays response + test->>ui: assert test expectations +``` diff --git a/docs/lsp.md b/docs/lsp.md new file mode 100644 index 00000000000..64a3af3e8c0 --- /dev/null +++ b/docs/lsp.md @@ -0,0 +1,53 @@ +# Flare Language Server + +## Chat Activation flow + +```mermaid +sequenceDiagram + participant user as User + participant ext as Extension + participant webview as Chat Webview + participant flare as Amazon Q LSP + participant backend as Amazon Q Backend + + user->>ext: opens IDE + ext->>ext: activates + ext->>webview: loads UI + ext->>flare: initialize process + flare->>flare: starts and waits + user->>webview: interacts + webview->>ext: sends message + ext->>flare: sends message + flare->>backend: call api + backend->>flare: returns + flare->>ext: display + ext->>webview: display +``` + +## Language Server Debugging + +1. Clone https://github.com/aws/language-servers.git and set it up in the same workspace as this project + + e.g. + + ``` + /aws-toolkit-vscode + /toolkit + /core + /amazonq + /language-servers + ``` + +2. Inside of the language-servers project run: + ``` + npm install + npm run compile + npm run package + ``` + to get the project setup +3. Uncomment the `__AMAZONQLSP_PATH` variable in `amazonq/.vscode/launch.json` Extension configuration +4. Use the `Launch LSP with Debugging` configuration and set breakpoints in VSCode or the language server + +## Amazon Q Inline Activation + +- In order to get inline completion working you must open a supported file type defined in CodewhispererInlineCompletionLanguages in `packages/amazonq/src/app/inline/completion.ts` diff --git a/package-lock.json b/package-lock.json index e8f0e06a793..a23a586adf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10936,6 +10936,102 @@ "yargs": "^17.0.1" } }, + "node_modules/@aws/chat-client-ui-types": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@aws/chat-client-ui-types/-/chat-client-ui-types-0.0.8.tgz", + "integrity": "sha512-aU8r0FaCKIhMiTWvr/yuWYZmVWPgE2vBAPsVcafhlu7ucubiH/+YodqDw+0Owk0R0kxxZDdjdZghPZSyy0G84A==", + "dev": true, + "dependencies": { + "@aws/language-server-runtimes-types": "^0.0.7" + } + }, + "node_modules/@aws/language-server-runtimes": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.27.tgz", + "integrity": "sha512-qWog7upRVc09xLcuL0HladoxO3JbkgdtgkI/RUWRDcr6YB8hBvmSCADGWjUGbOyvK4CpaXqHIr883PAqnosoXg==", + "dev": true, + "dependencies": { + "@aws/language-server-runtimes-types": "^0.0.7", + "jose": "^5.9.6", + "rxjs": "^7.8.1", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-protocol": "^3.17.5" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws/language-server-runtimes-types": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.0.7.tgz", + "integrity": "sha512-P83YkgWITcUGHaZvYFI0N487nWErgRpejALKNm/xs8jEcHooDfjigOpliN8TgzfF9BGvGeQnnAzIG16UBXc9ig==", + "dev": true, + "dependencies": { + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5" + } + }, + "node_modules/@aws/language-server-runtimes-types/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true + }, + "node_modules/@aws/language-server-runtimes/node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@aws/language-server-runtimes/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@aws/language-server-runtimes/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws/language-server-runtimes/node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dev": true, + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/@aws/language-server-runtimes/node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/@aws/language-server-runtimes/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true + }, "node_modules/@aws/mynah-ui": { "version": "4.25.1", "hasInstallScript": true, @@ -23582,8 +23678,9 @@ "license": "MIT" }, "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.8", - "license": "MIT" + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==" }, "node_modules/vscode-languageserver-types": { "version": "3.17.5", @@ -24821,6 +24918,8 @@ }, "devDependencies": { "@aws-sdk/types": "^3.13.1", + "@aws/chat-client-ui-types": "^0.0.8", + "@aws/language-server-runtimes": "^0.2.27", "@cspotcode/source-map-support": "^0.8.1", "@sinonjs/fake-timers": "^10.0.2", "@types/adm-zip": "^0.4.34", diff --git a/packages/amazonq/.changes/next-release/Feature-7a3c930e-cc8e-4323-9ecd-c1bf767fd2ed.json b/packages/amazonq/.changes/next-release/Feature-7a3c930e-cc8e-4323-9ecd-c1bf767fd2ed.json new file mode 100644 index 00000000000..81a1e026db4 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-7a3c930e-cc8e-4323-9ecd-c1bf767fd2ed.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "(Experimental) Amazon Q inline code suggestions via Amazon Q Language Server. (enable with `aws.experiments.amazonqLSP: true`)" +} diff --git a/packages/amazonq/.vscode/launch.json b/packages/amazonq/.vscode/launch.json index 84f48b6f785..f4b8642ea4e 100644 --- a/packages/amazonq/.vscode/launch.json +++ b/packages/amazonq/.vscode/launch.json @@ -14,6 +14,7 @@ "env": { "SSMDOCUMENT_LANGUAGESERVER_PORT": "6010", "WEBPACK_DEVELOPER_SERVER": "http://localhost:8080" + // "__AMAZONQLSP_PATH": "${workspaceFolder}/../../../language-servers/app/aws-lsp-codewhisperer-runtimes/out/token-standalone.js", }, "envFile": "${workspaceFolder}/.local.env", "outFiles": ["${workspaceFolder}/dist/**/*.js", "${workspaceFolder}/../core/dist/**/*.js"], @@ -135,6 +136,31 @@ "group": "4_E2ETestCurrentFile", "order": 2 } + }, + { + "name": "Attach to Language Server", + "type": "node", + "request": "attach", + "port": 6080, // Hard defined in core/src/shared/lsp/platform.ts + "outFiles": ["${workspaceFolder}/../../../language-servers/**/out/**/*.js"], + "skipFiles": [ + "/**", + "${workspaceFolder}/../../../language-servers/**/node_modules/**/*.js" + ], + "restart": { + "maxAttempts": 10, + "delay": 1000 + } + } + ], + "compounds": [ + { + "name": "Launch LSP with Debugging", + "configurations": ["Extension", "Attach to Language Server"], + "presentation": { + "group": "1_Extension", + "order": 5 + } } ] } diff --git a/packages/amazonq/src/app/inline/activation.ts b/packages/amazonq/src/app/inline/activation.ts new file mode 100644 index 00000000000..d786047b2aa --- /dev/null +++ b/packages/amazonq/src/app/inline/activation.ts @@ -0,0 +1,124 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import vscode from 'vscode' +import { + AuthUtil, + CodeSuggestionsState, + CodeWhispererCodeCoverageTracker, + CodeWhispererConstants, + CodeWhispererSettings, + ConfigurationEntry, + DefaultCodeWhispererClient, + invokeRecommendation, + isInlineCompletionEnabled, + KeyStrokeHandler, + RecommendationHandler, + runtimeLanguageContext, + TelemetryHelper, + UserWrittenCodeTracker, + vsCodeState, +} from 'aws-core-vscode/codewhisperer' +import { Commands, getLogger, globals, sleep } from 'aws-core-vscode/shared' + +export async function activate() { + const codewhispererSettings = CodeWhispererSettings.instance + const client = new DefaultCodeWhispererClient() + + if (isInlineCompletionEnabled()) { + await setSubscriptionsforInlineCompletion() + await AuthUtil.instance.setVscodeContextProps() + } + + function getAutoTriggerStatus(): boolean { + return CodeSuggestionsState.instance.isSuggestionsEnabled() + } + + async function getConfigEntry(): Promise { + const isShowMethodsEnabled: boolean = + vscode.workspace.getConfiguration('editor').get('suggest.showMethods') || false + const isAutomatedTriggerEnabled: boolean = getAutoTriggerStatus() + const isManualTriggerEnabled: boolean = true + const isSuggestionsWithCodeReferencesEnabled = codewhispererSettings.isSuggestionsWithCodeReferencesEnabled() + + // TODO:remove isManualTriggerEnabled + return { + isShowMethodsEnabled, + isManualTriggerEnabled, + isAutomatedTriggerEnabled, + isSuggestionsWithCodeReferencesEnabled, + } + } + + async function setSubscriptionsforInlineCompletion() { + RecommendationHandler.instance.subscribeSuggestionCommands() + + /** + * Automated trigger + */ + globals.context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(async (editor) => { + await RecommendationHandler.instance.onEditorChange() + }), + vscode.window.onDidChangeWindowState(async (e) => { + await RecommendationHandler.instance.onFocusChange() + }), + vscode.window.onDidChangeTextEditorSelection(async (e) => { + await RecommendationHandler.instance.onCursorChange(e) + }), + vscode.workspace.onDidChangeTextDocument(async (e) => { + const editor = vscode.window.activeTextEditor + if (!editor) { + return + } + if (e.document !== editor.document) { + return + } + if (!runtimeLanguageContext.isLanguageSupported(e.document)) { + return + } + + CodeWhispererCodeCoverageTracker.getTracker(e.document.languageId)?.countTotalTokens(e) + UserWrittenCodeTracker.instance.onTextDocumentChange(e) + /** + * Handle this keystroke event only when + * 1. It is not a backspace + * 2. It is not caused by CodeWhisperer editing + * 3. It is not from undo/redo. + */ + if (e.contentChanges.length === 0 || vsCodeState.isCodeWhispererEditing) { + return + } + + if (vsCodeState.lastUserModificationTime) { + TelemetryHelper.instance.setTimeSinceLastModification( + performance.now() - vsCodeState.lastUserModificationTime + ) + } + vsCodeState.lastUserModificationTime = performance.now() + /** + * Important: Doing this sleep(10) is to make sure + * 1. this event is processed by vs code first + * 2. editor.selection.active has been successfully updated by VS Code + * Then this event can be processed by our code. + */ + await sleep(CodeWhispererConstants.vsCodeCursorUpdateDelay) + if (!RecommendationHandler.instance.isSuggestionVisible()) { + await KeyStrokeHandler.instance.processKeyStroke(e, editor, client, await getConfigEntry()) + } + }), + // manual trigger + Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => { + invokeRecommendation( + vscode.window.activeTextEditor as vscode.TextEditor, + client, + await getConfigEntry() + ).catch((e) => { + getLogger().error('invokeRecommendation failed: %s', (e as Error).message) + }) + }) + ) + } +} diff --git a/packages/amazonq/src/app/inline/completion.ts b/packages/amazonq/src/app/inline/completion.ts new file mode 100644 index 00000000000..94700768607 --- /dev/null +++ b/packages/amazonq/src/app/inline/completion.ts @@ -0,0 +1,93 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CancellationToken, + InlineCompletionContext, + InlineCompletionItem, + InlineCompletionItemProvider, + InlineCompletionList, + Position, + TextDocument, + commands, + languages, +} from 'vscode' +import { LanguageClient } from 'vscode-languageclient' +import { + InlineCompletionListWithReferences, + InlineCompletionWithReferencesParams, + inlineCompletionWithReferencesRequestType, + logInlineCompletionSessionResultsNotificationType, + LogInlineCompletionSessionResultsParams, +} from '@aws/language-server-runtimes/protocol' +import { CodeWhispererConstants } from 'aws-core-vscode/codewhisperer' + +export function registerInlineCompletion(languageClient: LanguageClient) { + const inlineCompletionProvider = new AmazonQInlineCompletionItemProvider(languageClient) + languages.registerInlineCompletionItemProvider(CodeWhispererConstants.platformLanguageIds, inlineCompletionProvider) + + const onInlineAcceptance = async ( + sessionId: string, + itemId: string, + requestStartTime: number, + firstCompletionDisplayLatency?: number + ) => { + const params: LogInlineCompletionSessionResultsParams = { + sessionId: sessionId, + completionSessionResult: { + [itemId]: { + seen: true, + accepted: true, + discarded: false, + }, + }, + totalSessionDisplayTime: Date.now() - requestStartTime, + firstCompletionDisplayLatency: firstCompletionDisplayLatency, + } + languageClient.sendNotification(logInlineCompletionSessionResultsNotificationType as any, params) + } + commands.registerCommand('aws.sample-vscode-ext-amazonq.accept', onInlineAcceptance) +} + +export class AmazonQInlineCompletionItemProvider implements InlineCompletionItemProvider { + constructor(private readonly languageClient: LanguageClient) {} + + async provideInlineCompletionItems( + document: TextDocument, + position: Position, + context: InlineCompletionContext, + token: CancellationToken + ): Promise { + const requestStartTime = Date.now() + const request: InlineCompletionWithReferencesParams = { + textDocument: { + uri: document.uri.toString(), + }, + position, + context, + } + + const response = await this.languageClient.sendRequest( + inlineCompletionWithReferencesRequestType as any, + request, + token + ) + + const list: InlineCompletionListWithReferences = response as InlineCompletionListWithReferences + this.languageClient.info(`Client: Received ${list.items.length} suggestions`) + const firstCompletionDisplayLatency = Date.now() - requestStartTime + + // Add completion session tracking and attach onAcceptance command to each item to record used decision + for (const item of list.items) { + item.command = { + command: 'aws.sample-vscode-ext-amazonq.accept', + title: 'On acceptance', + arguments: [list.sessionId, item.itemId, requestStartTime, firstCompletionDisplayLatency], + } + } + + return list as InlineCompletionList + } +} diff --git a/packages/amazonq/src/extension.ts b/packages/amazonq/src/extension.ts index c351b7ba9eb..ad89a44ed4d 100644 --- a/packages/amazonq/src/extension.ts +++ b/packages/amazonq/src/extension.ts @@ -31,6 +31,7 @@ import { setContext, setupUninstallHandler, maybeShowMinVscodeWarning, + Experiments, } from 'aws-core-vscode/shared' import { ExtStartUpSources } from 'aws-core-vscode/telemetry' import { VSCODE_EXTENSION_ID } from 'aws-core-vscode/utils' @@ -39,6 +40,8 @@ import * as semver from 'semver' import * as vscode from 'vscode' import { registerCommands } from './commands' import { focusAmazonQPanel } from 'aws-core-vscode/codewhispererChat' +import { activate as activateAmazonqLsp } from './lsp/activation' +import { activate as activateInlineCompletion } from './app/inline/activation' export const amazonQContextPrefix = 'amazonq' @@ -113,7 +116,13 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is const extContext = { extensionContext: context, } + // This contains every lsp agnostic things (auth, security scan, code scan) await activateCodeWhisperer(extContext as ExtContext) + if (Experiments.instance.get('amazonqLSP', false)) { + await activateAmazonqLsp(context) + } else { + await activateInlineCompletion() + } // Generic extension commands registerGenericCommands(context, amazonQContextPrefix) @@ -145,6 +154,23 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is void focusAmazonQPanel.execute(placeholder, ExtStartUpSources.firstStartUp) }, 1000) } + + context.subscriptions.push( + Experiments.instance.onDidChange(async (event) => { + if (event.key === 'amazonqLSP' || event.key === 'amazonqChatLSP') { + await vscode.window + .showInformationMessage( + 'Amazon Q LSP setting has changed. Reload VS Code for the changes to take effect.', + 'Reload Now' + ) + .then(async (selection) => { + if (selection === 'Reload Now') { + await vscode.commands.executeCommand('workbench.action.reloadWindow') + } + }) + } + }) + ) } export async function deactivateCommon() { diff --git a/packages/amazonq/src/extensionNode.ts b/packages/amazonq/src/extensionNode.ts index 04ca8c4023f..d9d36f828eb 100644 --- a/packages/amazonq/src/extensionNode.ts +++ b/packages/amazonq/src/extensionNode.ts @@ -7,7 +7,15 @@ import * as vscode from 'vscode' import { activateAmazonQCommon, amazonQContextPrefix, deactivateCommon } from './extension' import { DefaultAmazonQAppInitContext } from 'aws-core-vscode/amazonq' import { activate as activateQGumby } from 'aws-core-vscode/amazonqGumby' -import { ExtContext, globals, CrashMonitoring, getLogger, isNetworkError, isSageMaker } from 'aws-core-vscode/shared' +import { + ExtContext, + globals, + CrashMonitoring, + getLogger, + isNetworkError, + isSageMaker, + Experiments, +} from 'aws-core-vscode/shared' import { filetypes, SchemaService } from 'aws-core-vscode/sharedNode' import { updateDevMode } from 'aws-core-vscode/dev' import { CommonAuthViewProvider } from 'aws-core-vscode/login' @@ -42,8 +50,11 @@ async function activateAmazonQNode(context: vscode.ExtensionContext) { const extContext = { extensionContext: context, } - await activateCWChat(context) - await activateQGumby(extContext as ExtContext) + + if (!Experiments.instance.get('amazonqChatLSP', false)) { + await activateCWChat(context) + await activateQGumby(extContext as ExtContext) + } const authProvider = new CommonAuthViewProvider( context, diff --git a/packages/amazonq/src/lsp/activation.ts b/packages/amazonq/src/lsp/activation.ts new file mode 100644 index 00000000000..4d7918d76b0 --- /dev/null +++ b/packages/amazonq/src/lsp/activation.ts @@ -0,0 +1,29 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import vscode from 'vscode' +import { startLanguageServer } from './client' +import { AmazonQLspInstaller } from './lspInstaller' +import { Commands, lspSetupStage, ToolkitError } from 'aws-core-vscode/shared' + +export async function activate(ctx: vscode.ExtensionContext): Promise { + try { + await lspSetupStage('all', async () => { + const installResult = await new AmazonQLspInstaller().resolve() + await lspSetupStage('launch', async () => await startLanguageServer(ctx, installResult.resourcePaths)) + }) + ctx.subscriptions.push( + Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => { + await vscode.commands.executeCommand('editor.action.inlineSuggest.trigger') + }), + Commands.declare('aws.amazonq.rejectCodeSuggestion', () => async () => { + await vscode.commands.executeCommand('editor.action.inlineSuggest.hide') + }).register() + ) + } catch (err) { + const e = err as ToolkitError + void vscode.window.showInformationMessage(`Unable to launch amazonq language server: ${e.message}`) + } +} diff --git a/packages/amazonq/src/lsp/auth.ts b/packages/amazonq/src/lsp/auth.ts new file mode 100644 index 00000000000..70753e75c6b --- /dev/null +++ b/packages/amazonq/src/lsp/auth.ts @@ -0,0 +1,98 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ConnectionMetadata, + NotificationType, + RequestType, + ResponseMessage, +} from '@aws/language-server-runtimes/protocol' +import * as jose from 'jose' +import * as crypto from 'crypto' +import { LanguageClient } from 'vscode-languageclient' +import { AuthUtil } from 'aws-core-vscode/codewhisperer' +import { Writable } from 'stream' + +export const encryptionKey = crypto.randomBytes(32) + +/** + * Sends a json payload to the language server, who is waiting to know what the encryption key is. + * Code reference: https://github.com/aws/language-servers/blob/7da212185a5da75a72ce49a1a7982983f438651a/client/vscode/src/credentialsActivation.ts#L77 + */ +export function writeEncryptionInit(stream: Writable): void { + const request = { + version: '1.0', + mode: 'JWT', + key: encryptionKey.toString('base64'), + } + stream.write(JSON.stringify(request)) + stream.write('\n') +} + +/** + * Request for custom notifications that Update Credentials and tokens. + * See core\aws-lsp-core\src\credentials\updateCredentialsRequest.ts for details + */ +export interface UpdateCredentialsRequest { + /** + * Encrypted token (JWT or PASETO) + * The token's contents differ whether IAM or Bearer token is sent + */ + data: string + /** + * Used by the runtime based language servers. + * Signals that this client will encrypt its credentials payloads. + */ + encrypted: boolean +} + +export const notificationTypes = { + updateBearerToken: new RequestType( + 'aws/credentials/token/update' + ), + deleteBearerToken: new NotificationType('aws/credentials/token/delete'), + getConnectionMetadata: new RequestType( + 'aws/credentials/getConnectionMetadata' + ), +} + +/** + * Facade over our VSCode Auth that does crud operations on the language server auth + */ +export class AmazonQLspAuth { + constructor(private readonly client: LanguageClient) {} + + async init() { + const activeConnection = AuthUtil.instance.auth.activeConnection + if (activeConnection?.type === 'sso') { + // send the token to the language server + const token = await AuthUtil.instance.getBearerToken() + await this.updateBearerToken(token) + } + } + + private async updateBearerToken(token: string) { + const request = await this.createUpdateCredentialsRequest({ + token, + }) + + await this.client.sendRequest(notificationTypes.updateBearerToken.method, request) + + this.client.info(`UpdateBearerToken: ${JSON.stringify(request)}`) + } + + private async createUpdateCredentialsRequest(data: any) { + const payload = new TextEncoder().encode(JSON.stringify({ data })) + + const jwt = await new jose.CompactEncrypt(payload) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) + .encrypt(encryptionKey) + + return { + data: jwt, + encrypted: true, + } + } +} diff --git a/packages/amazonq/src/lsp/chat/activation.ts b/packages/amazonq/src/lsp/chat/activation.ts new file mode 100644 index 00000000000..406b753716f --- /dev/null +++ b/packages/amazonq/src/lsp/chat/activation.ts @@ -0,0 +1,34 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { window } from 'vscode' +import { LanguageClient } from 'vscode-languageclient' +import { AmazonQChatViewProvider } from './webviewProvider' +import { registerCommands } from './commands' +import { registerLanguageServerEventListener, registerMessageListeners } from './messages' +import { globals } from 'aws-core-vscode/shared' + +export function activate(languageClient: LanguageClient, encryptionKey: Buffer, mynahUIPath: string) { + const provider = new AmazonQChatViewProvider(mynahUIPath) + + globals.context.subscriptions.push( + window.registerWebviewViewProvider(AmazonQChatViewProvider.viewType, provider, { + webviewOptions: { + retainContextWhenHidden: true, + }, + }) + ) + + /** + * Commands are registered independent of the webview being open because when they're executed + * they focus the webview + **/ + registerCommands(provider) + registerLanguageServerEventListener(languageClient, provider) + + provider.onDidResolveWebview(() => { + registerMessageListeners(languageClient, provider, encryptionKey) + }) +} diff --git a/packages/amazonq/src/lsp/chat/commands.ts b/packages/amazonq/src/lsp/chat/commands.ts new file mode 100644 index 00000000000..3febc748442 --- /dev/null +++ b/packages/amazonq/src/lsp/chat/commands.ts @@ -0,0 +1,79 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { Commands, globals } from 'aws-core-vscode/shared' +import { window } from 'vscode' +import { AmazonQChatViewProvider } from './webviewProvider' + +export function registerCommands(provider: AmazonQChatViewProvider) { + globals.context.subscriptions.push( + registerGenericCommand('aws.amazonq.explainCode', 'Explain', provider), + registerGenericCommand('aws.amazonq.refactorCode', 'Refactor', provider), + registerGenericCommand('aws.amazonq.fixCode', 'Fix', provider), + registerGenericCommand('aws.amazonq.optimizeCode', 'Optimize', provider), + Commands.register('aws.amazonq.sendToPrompt', (data) => { + const triggerType = getCommandTriggerType(data) + const selection = getSelectedText() + + void focusAmazonQPanel().then(() => { + void provider.webview?.postMessage({ + command: 'sendToPrompt', + params: { selection: selection, triggerType }, + }) + }) + }), + Commands.register('aws.amazonq.openTab', () => { + void focusAmazonQPanel().then(() => { + void provider.webview?.postMessage({ + command: 'aws/chat/openTab', + params: {}, + }) + }) + }) + ) +} + +function getSelectedText(): string { + const editor = window.activeTextEditor + if (editor) { + const selection = editor.selection + const selectedText = editor.document.getText(selection) + return selectedText + } + + return ' ' +} + +function getCommandTriggerType(data: any): string { + // data is undefined when commands triggered from keybinding or command palette. Currently no + // way to differentiate keybinding and command palette, so both interactions are recorded as keybinding + return data === undefined ? 'hotkeys' : 'contextMenu' +} + +function registerGenericCommand(commandName: string, genericCommand: string, provider: AmazonQChatViewProvider) { + return Commands.register(commandName, (data) => { + const triggerType = getCommandTriggerType(data) + const selection = getSelectedText() + + void focusAmazonQPanel().then(() => { + void provider.webview?.postMessage({ + command: 'genericCommand', + params: { genericCommand, selection, triggerType }, + }) + }) + }) +} + +/** + * Importing focusAmazonQPanel from aws-core-vscode/amazonq leads to several dependencies down the chain not resolving since AmazonQ chat + * is currently only activated on node, but the language server is activated on both web and node. + * + * Instead, we just create our own as a temporary solution + */ +async function focusAmazonQPanel() { + await vscode.commands.executeCommand('aws.amazonq.AmazonQChatView.focus') + await vscode.commands.executeCommand('aws.amazonq.AmazonCommonAuth.focus') +} diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts new file mode 100644 index 00000000000..4c9d93f7f65 --- /dev/null +++ b/packages/amazonq/src/lsp/chat/messages.ts @@ -0,0 +1,225 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + isValidAuthFollowUpType, + INSERT_TO_CURSOR_POSITION, + AUTH_FOLLOW_UP_CLICKED, + CHAT_OPTIONS, + COPY_TO_CLIPBOARD, +} from '@aws/chat-client-ui-types' +import { + ChatResult, + chatRequestType, + ChatParams, + followUpClickNotificationType, + quickActionRequestType, + QuickActionResult, + QuickActionParams, + insertToCursorPositionNotificationType, +} from '@aws/language-server-runtimes/protocol' +import { v4 as uuidv4 } from 'uuid' +import { window } from 'vscode' +import { Disposable, LanguageClient, Position, State, TextDocumentIdentifier } from 'vscode-languageclient' +import * as jose from 'jose' +import { AmazonQChatViewProvider } from './webviewProvider' + +export function registerLanguageServerEventListener(languageClient: LanguageClient, provider: AmazonQChatViewProvider) { + languageClient.onDidChangeState(({ oldState, newState }) => { + if (oldState === State.Starting && newState === State.Running) { + languageClient.info( + 'Language client received initializeResult from server:', + JSON.stringify(languageClient.initializeResult) + ) + + const chatOptions = languageClient.initializeResult?.awsServerCapabilities?.chatOptions + + void provider.webview?.postMessage({ + command: CHAT_OPTIONS, + params: chatOptions, + }) + } + }) + + languageClient.onTelemetry((e) => { + languageClient.info(`[VSCode Client] Received telemetry event from server ${JSON.stringify(e)}`) + }) +} + +export function registerMessageListeners( + languageClient: LanguageClient, + provider: AmazonQChatViewProvider, + encryptionKey: Buffer +) { + provider.webview?.onDidReceiveMessage(async (message) => { + languageClient.info(`[VSCode Client] Received ${JSON.stringify(message)} from chat`) + + switch (message.command) { + case COPY_TO_CLIPBOARD: + // TODO see what we need to hook this up + languageClient.info('[VSCode Client] Copy to clipboard event received') + break + case INSERT_TO_CURSOR_POSITION: { + const editor = window.activeTextEditor + let textDocument: TextDocumentIdentifier | undefined = undefined + let cursorPosition: Position | undefined = undefined + if (editor) { + cursorPosition = editor.selection.active + textDocument = { uri: editor.document.uri.toString() } + } + + languageClient.sendNotification(insertToCursorPositionNotificationType.method, { + ...message.params, + cursorPosition, + textDocument, + }) + break + } + case AUTH_FOLLOW_UP_CLICKED: + // TODO hook this into auth + languageClient.info('[VSCode Client] AuthFollowUp clicked') + break + case chatRequestType.method: { + const partialResultToken = uuidv4() + const chatDisposable = languageClient.onProgress(chatRequestType, partialResultToken, (partialResult) => + handlePartialResult(partialResult, encryptionKey, provider, message.params.tabId) + ) + + const editor = + window.activeTextEditor || + window.visibleTextEditors.find((editor) => editor.document.languageId !== 'Log') + if (editor) { + message.params.cursorPosition = [editor.selection.active] + message.params.textDocument = { uri: editor.document.uri.toString() } + } + + const chatRequest = await encryptRequest(message.params, encryptionKey) + const chatResult = (await languageClient.sendRequest(chatRequestType.method, { + ...chatRequest, + partialResultToken, + })) as string | ChatResult + void handleCompleteResult( + chatResult, + encryptionKey, + provider, + message.params.tabId, + chatDisposable + ) + break + } + case quickActionRequestType.method: { + const quickActionPartialResultToken = uuidv4() + const quickActionDisposable = languageClient.onProgress( + quickActionRequestType, + quickActionPartialResultToken, + (partialResult) => + handlePartialResult( + partialResult, + encryptionKey, + provider, + message.params.tabId + ) + ) + + const quickActionRequest = await encryptRequest(message.params, encryptionKey) + const quickActionResult = (await languageClient.sendRequest(quickActionRequestType.method, { + ...quickActionRequest, + partialResultToken: quickActionPartialResultToken, + })) as string | ChatResult + void handleCompleteResult( + quickActionResult, + encryptionKey, + provider, + message.params.tabId, + quickActionDisposable + ) + break + } + case followUpClickNotificationType.method: + if (!isValidAuthFollowUpType(message.params.followUp.type)) { + languageClient.sendNotification(followUpClickNotificationType.method, message.params) + } + break + default: + if (isServerEvent(message.command)) { + languageClient.sendNotification(message.command, message.params) + } + break + } + }, undefined) +} + +function isServerEvent(command: string) { + return command.startsWith('aws/chat/') || command === 'telemetry/event' +} + +async function encryptRequest(params: T, encryptionKey: Buffer): Promise<{ message: string } | T> { + const payload = new TextEncoder().encode(JSON.stringify(params)) + + const encryptedMessage = await new jose.CompactEncrypt(payload) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) + .encrypt(encryptionKey) + + return { message: encryptedMessage } +} + +async function decodeRequest(request: string, key: Buffer): Promise { + const result = await jose.jwtDecrypt(request, key, { + clockTolerance: 60, // Allow up to 60 seconds to account for clock differences + contentEncryptionAlgorithms: ['A256GCM'], + keyManagementAlgorithms: ['dir'], + }) + + if (!result.payload) { + throw new Error('JWT payload not found') + } + return result.payload as T +} + +/** + * Decodes partial chat responses from the language server before sending them to mynah UI + */ +async function handlePartialResult( + partialResult: string | T, + encryptionKey: Buffer | undefined, + provider: AmazonQChatViewProvider, + tabId: string +) { + const decryptedMessage = + typeof partialResult === 'string' && encryptionKey + ? await decodeRequest(partialResult, encryptionKey) + : (partialResult as T) + + if (decryptedMessage.body) { + void provider.webview?.postMessage({ + command: chatRequestType.method, + params: decryptedMessage, + isPartialResult: true, + tabId: tabId, + }) + } +} + +/** + * Decodes the final chat responses from the language server before sending it to mynah UI. + * Once this is called the answer response is finished + */ +async function handleCompleteResult( + result: string | T, + encryptionKey: Buffer | undefined, + provider: AmazonQChatViewProvider, + tabId: string, + disposable: Disposable +) { + const decryptedMessage = + typeof result === 'string' && encryptionKey ? await decodeRequest(result, encryptionKey) : result + + void provider.webview?.postMessage({ + command: chatRequestType.method, + params: decryptedMessage, + tabId: tabId, + }) + disposable.dispose() +} diff --git a/packages/amazonq/src/lsp/chat/webviewProvider.ts b/packages/amazonq/src/lsp/chat/webviewProvider.ts new file mode 100644 index 00000000000..7bfab17f3ae --- /dev/null +++ b/packages/amazonq/src/lsp/chat/webviewProvider.ts @@ -0,0 +1,73 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EventEmitter, + CancellationToken, + Webview, + WebviewView, + WebviewViewProvider, + WebviewViewResolveContext, + Uri, +} from 'vscode' +import { LanguageServerResolver } from 'aws-core-vscode/shared' + +export class AmazonQChatViewProvider implements WebviewViewProvider { + public static readonly viewType = 'aws.amazonq.AmazonQChatView' + private readonly onDidResolveWebviewEmitter = new EventEmitter() + public readonly onDidResolveWebview = this.onDidResolveWebviewEmitter.event + + webview: Webview | undefined + + constructor(private readonly mynahUIPath: string) {} + + public resolveWebviewView(webviewView: WebviewView, context: WebviewViewResolveContext, _token: CancellationToken) { + this.webview = webviewView.webview + + const lspDir = Uri.parse(LanguageServerResolver.defaultDir) + webviewView.webview.options = { + enableScripts: true, + enableCommandUris: true, + localResourceRoots: [lspDir], + } + + const uiPath = webviewView.webview.asWebviewUri(Uri.parse(this.mynahUIPath)).toString() + webviewView.webview.html = getWebviewContent(uiPath) + + this.onDidResolveWebviewEmitter.fire() + } +} + +function getWebviewContent(mynahUIPath: string) { + return ` + + + + + + Chat + + + + + + + ` +} diff --git a/packages/amazonq/src/lsp/client.ts b/packages/amazonq/src/lsp/client.ts new file mode 100644 index 00000000000..297ac21c1d6 --- /dev/null +++ b/packages/amazonq/src/lsp/client.ts @@ -0,0 +1,130 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import vscode, { env, version } from 'vscode' +import * as nls from 'vscode-nls' +import * as crypto from 'crypto' +import { LanguageClient, LanguageClientOptions } from 'vscode-languageclient' +import { registerInlineCompletion } from '../app/inline/completion' +import { AmazonQLspAuth, encryptionKey, notificationTypes } from './auth' +import { AuthUtil } from 'aws-core-vscode/codewhisperer' +import { ConnectionMetadata } from '@aws/language-server-runtimes/protocol' +import { Settings, oidcClientName, createServerOptions, globals, Experiments, getLogger } from 'aws-core-vscode/shared' +import { activate } from './chat/activation' +import { AmazonQResourcePaths } from './lspInstaller' + +const localize = nls.loadMessageBundle() + +export async function startLanguageServer( + extensionContext: vscode.ExtensionContext, + resourcePaths: AmazonQResourcePaths +) { + const toDispose = extensionContext.subscriptions + + const serverModule = resourcePaths.lsp + + const serverOptions = createServerOptions({ + encryptionKey, + executable: resourcePaths.node, + serverModule, + execArgv: [ + '--nolazy', + '--preserve-symlinks', + '--stdio', + '--pre-init-encryption', + '--set-credentials-encryption-key', + ], + }) + + const documentSelector = [{ scheme: 'file', language: '*' }] + + const clientId = 'amazonq' + const traceServerEnabled = Settings.instance.isSet(`${clientId}.trace.server`) + + // Options to control the language client + const clientOptions: LanguageClientOptions = { + // Register the server for json documents + documentSelector, + initializationOptions: { + aws: { + clientInfo: { + name: env.appName, + version: version, + extension: { + name: oidcClientName(), + version: '0.0.1', + }, + clientId: crypto.randomUUID(), + }, + awsClientCapabilities: { + window: { + notifications: true, + }, + }, + }, + credentials: { + providesBearerToken: true, + }, + }, + /** + * When the trace server is enabled it outputs a ton of log messages so: + * When trace server is enabled, logs go to a seperate "Amazon Q Language Server" output. + * Otherwise, logs go to the regular "Amazon Q Logs" channel. + */ + ...(traceServerEnabled + ? {} + : { + outputChannel: globals.logOutputChannel, + }), + } + + const client = new LanguageClient( + clientId, + localize('amazonq.server.name', 'Amazon Q Language Server'), + serverOptions, + clientOptions + ) + + const disposable = client.start() + toDispose.push(disposable) + + const auth = new AmazonQLspAuth(client) + + return client.onReady().then(async () => { + await auth.init() + registerInlineCompletion(client) + if (Experiments.instance.get('amazonqChatLSP', false)) { + activate(client, encryptionKey, resourcePaths.mynahUI) + } + + // Request handler for when the server wants to know about the clients auth connnection + client.onRequest(notificationTypes.getConnectionMetadata.method, () => { + return { + sso: { + startUrl: AuthUtil.instance.auth.startUrl, + }, + } + }) + + // Temporary code for pen test. Will be removed when we switch to the real flare auth + const authInterval = setInterval(async () => { + try { + await auth.init() + } catch (e) { + getLogger('amazonqLsp').error('Unable to update bearer token: %s', (e as Error).message) + clearInterval(authInterval) + } + }, 300000) // every 5 minutes + + toDispose.push( + AuthUtil.instance.auth.onDidChangeActiveConnection(async () => { + await auth.init() + }), + AuthUtil.instance.auth.onDidDeleteConnection(async () => { + client.sendNotification(notificationTypes.deleteBearerToken.method) + }) + ) + }) +} diff --git a/packages/amazonq/src/lsp/config.ts b/packages/amazonq/src/lsp/config.ts new file mode 100644 index 00000000000..634cc43aab2 --- /dev/null +++ b/packages/amazonq/src/lsp/config.ts @@ -0,0 +1,22 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DevSettings, getServiceEnvVarConfig } from 'aws-core-vscode/shared' +import { LspConfig } from 'aws-core-vscode/amazonq' + +export const defaultAmazonQLspConfig: LspConfig = { + manifestUrl: 'https://aws-toolkit-language-servers.amazonaws.com/codewhisperer/0/manifest.json', + supportedVersions: '^3.1.1', + id: 'AmazonQ', // used for identification in global storage/local disk location. Do not change. + path: undefined, +} + +export function getAmazonQLspConfig(): LspConfig { + return { + ...defaultAmazonQLspConfig, + ...(DevSettings.instance.getServiceConfig('amazonqLsp', {}) as LspConfig), + ...getServiceEnvVarConfig('amazonqLsp', Object.keys(defaultAmazonQLspConfig)), + } +} diff --git a/packages/amazonq/src/lsp/lspInstaller.ts b/packages/amazonq/src/lsp/lspInstaller.ts new file mode 100644 index 00000000000..31866588e07 --- /dev/null +++ b/packages/amazonq/src/lsp/lspInstaller.ts @@ -0,0 +1,41 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fs, getNodeExecutableName, BaseLspInstaller, ResourcePaths } from 'aws-core-vscode/shared' +import path from 'path' +import { getAmazonQLspConfig } from './config' +import { LspConfig } from 'aws-core-vscode/amazonq' + +export interface AmazonQResourcePaths extends ResourcePaths { + mynahUI: string +} + +export class AmazonQLspInstaller extends BaseLspInstaller.BaseLspInstaller { + constructor(lspConfig: LspConfig = getAmazonQLspConfig()) { + super(lspConfig, 'amazonqLsp') + } + + protected override async postInstall(assetDirectory: string): Promise { + const resourcePaths = this.resourcePaths(assetDirectory) + await fs.chmod(resourcePaths.node, 0o755) + } + + protected override resourcePaths(assetDirectory?: string): AmazonQResourcePaths { + if (!assetDirectory) { + return { + lsp: this.config.path ?? '', + node: getNodeExecutableName(), + mynahUI: '', // TODO make mynah UI configurable + } + } + + const nodePath = path.join(assetDirectory, `servers/${getNodeExecutableName()}`) + return { + lsp: path.join(assetDirectory, 'servers/aws-lsp-codewhisperer.js'), + node: nodePath, + mynahUI: path.join(assetDirectory, 'clients/amazonq-ui.js'), + } + } +} diff --git a/packages/amazonq/test/e2e/lsp/amazonqLsp.test.ts b/packages/amazonq/test/e2e/lsp/amazonqLsp.test.ts new file mode 100644 index 00000000000..d3e90ec4e8e --- /dev/null +++ b/packages/amazonq/test/e2e/lsp/amazonqLsp.test.ts @@ -0,0 +1,31 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AmazonQLspInstaller } from '../../../src/lsp/lspInstaller' +import { defaultAmazonQLspConfig } from '../../../src/lsp/config' +import { createLspInstallerTests } from './lspInstallerUtil' +import { LspConfig } from 'aws-core-vscode/amazonq' + +describe('AmazonQLSP', () => { + createLspInstallerTests({ + suiteName: 'AmazonQLSPInstaller', + lspConfig: defaultAmazonQLspConfig, + createInstaller: (lspConfig?: LspConfig) => new AmazonQLspInstaller(lspConfig), + targetContents: [ + { + bytes: 0, + filename: 'servers.zip', + hashes: [], + url: 'http://fakeurl', + }, + ], + setEnv: (path: string) => { + process.env.__AMAZONQLSP_PATH = path + }, + resetEnv: () => { + delete process.env.__AMAZONQLSP_PATH + }, + }) +}) diff --git a/packages/amazonq/test/e2e/lsp/lspInstallerUtil.ts b/packages/amazonq/test/e2e/lsp/lspInstallerUtil.ts new file mode 100644 index 00000000000..0031e627d8c --- /dev/null +++ b/packages/amazonq/test/e2e/lsp/lspInstallerUtil.ts @@ -0,0 +1,287 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import { + BaseLspInstaller, + DevSettings, + fs, + LanguageServerResolver, + makeTemporaryToolkitFolder, + ManifestResolver, + request, + TargetContent, +} from 'aws-core-vscode/shared' +import * as semver from 'semver' +import { assertTelemetry } from 'aws-core-vscode/test' +import { LspConfig, LspController } from 'aws-core-vscode/amazonq' +import { LanguageServerSetup } from 'aws-core-vscode/telemetry' + +function createVersion(version: string, contents: TargetContent[]) { + return { + isDelisted: false, + serverVersion: version, + targets: [ + { + arch: process.arch, + platform: process.platform, + contents, + }, + ], + } +} + +export function createLspInstallerTests({ + suiteName, + lspConfig, + createInstaller, + targetContents, + setEnv, + resetEnv, +}: { + suiteName: string + lspConfig: LspConfig + createInstaller: (lspConfig?: LspConfig) => BaseLspInstaller.BaseLspInstaller + targetContents: TargetContent[] + setEnv: (path: string) => void + resetEnv: () => void +}) { + describe(suiteName, () => { + let installer: BaseLspInstaller.BaseLspInstaller + let sandbox: sinon.SinonSandbox + let tempDir: string + + beforeEach(async () => { + sandbox = sinon.createSandbox() + installer = createInstaller() + tempDir = await makeTemporaryToolkitFolder() + sandbox.stub(LanguageServerResolver.prototype, 'defaultDownloadFolder').returns(tempDir) + // Called on extension activation and can contaminate telemetry. + sandbox.stub(LspController.prototype, 'trySetupLsp') + }) + + afterEach(async () => { + resetEnv() + sandbox.restore() + await fs.delete(tempDir, { + recursive: true, + }) + }) + + describe('resolve()', () => { + it('uses dev setting override', async () => { + const path = '/custom/path/to/lsp' + sandbox.stub(DevSettings.instance, 'getServiceConfig').returns({ + path, + }) + /** + * The installer pre-evaluates the config, so if we want to override the config + * we need to stub then re-create it + */ + const result = await createInstaller().resolve() + + assert.strictEqual(result.assetDirectory, path) + assert.strictEqual(result.location, 'override') + assert.strictEqual(result.version, '0.0.0') + }) + + it('uses environment variable override', async () => { + const overridePath = '/custom/path/to/lsp' + setEnv(overridePath) + + /** + * The installer pre-evaluates the config, so if we want to override the environment variables + * we need to override the env then re-create it + */ + const result = await createInstaller().resolve() + + assert.strictEqual(result.assetDirectory, overridePath) + assert.strictEqual(result.location, 'override') + assert.strictEqual(result.version, '0.0.0') + }) + + it('resolves', async () => { + // First try - should download the file + const download = await installer.resolve() + + assert.ok(download.assetDirectory.startsWith(tempDir)) + assert.deepStrictEqual(download.location, 'remote') + assert.ok(semver.satisfies(download.version, lspConfig.supportedVersions)) + + // Second try - Should see the contents in the cache + const cache = await installer.resolve() + + assert.ok(cache.assetDirectory.startsWith(tempDir)) + assert.deepStrictEqual(cache.location, 'cache') + assert.ok(semver.satisfies(cache.version, lspConfig.supportedVersions)) + + /** + * Always make sure the latest version is one patch higher. This stops a problem + * where the fallback can't be used because the latest compatible version + * is equal to the min version, so if the cache isn't valid, then there + * would be no fallback location + * + * Instead, increasing the latest compatible lsp version means we can just + * use the one we downloaded earlier in the test as the fallback + */ + const nextVer = semver.inc(cache.version, 'patch', true) + if (!nextVer) { + throw new Error('Could not increment version') + } + sandbox.stub(ManifestResolver.prototype, 'resolve').resolves({ + manifestSchemaVersion: '0.0.0', + artifactId: 'foo', + artifactDescription: 'foo', + isManifestDeprecated: false, + versions: [createVersion(nextVer, targetContents), createVersion(cache.version, targetContents)], + }) + + // fail the next http request for the language server + sandbox.stub(request, 'fetch').returns({ + response: Promise.resolve({ + ok: false, + }), + } as any) + + const config = { + ...lspConfig, + // contains the old version thats actually on disk + the new version + supportedVersions: `${cache.version} || ${nextVer}`, + } + + // Third try - Cache doesn't exist and we couldn't download from the internet, fallback to a local version + const fallback = await createInstaller(config).resolve() + + assert.ok(fallback.assetDirectory.startsWith(tempDir)) + assert.deepStrictEqual(fallback.location, 'fallback') + assert.ok(semver.satisfies(fallback.version, lspConfig.supportedVersions)) + + /* First Try Telemetry + getManifest: remote succeeds + getServer: cache fails then remote succeeds. + validate: succeeds. + */ + const firstTryTelemetry: Partial[] = [ + { + id: lspConfig.id, + manifestLocation: 'remote', + languageServerSetupStage: 'getManifest', + result: 'Succeeded', + }, + { + id: lspConfig.id, + languageServerLocation: 'cache', + languageServerSetupStage: 'getServer', + result: 'Failed', + }, + { + id: lspConfig.id, + languageServerLocation: 'remote', + languageServerSetupStage: 'validate', + result: 'Succeeded', + }, + { + id: lspConfig.id, + languageServerLocation: 'remote', + languageServerSetupStage: 'getServer', + result: 'Succeeded', + }, + ] + + /* Second Try Telemetry + getManifest: remote fails, then cache succeeds. + getServer: cache succeeds + validate: doesn't run since its cached. + */ + const secondTryTelemetry: Partial[] = [ + { + id: lspConfig.id, + manifestLocation: 'remote', + languageServerSetupStage: 'getManifest', + result: 'Failed', + }, + { + id: lspConfig.id, + manifestLocation: 'cache', + languageServerSetupStage: 'getManifest', + result: 'Succeeded', + }, + { + id: lspConfig.id, + languageServerLocation: 'cache', + languageServerSetupStage: 'getServer', + result: 'Succeeded', + }, + ] + + /* Third Try Telemetry + getManifest: (stubbed to fail, no telemetry) + getServer: remote and cache fail + validate: no validation since not remote. + */ + const thirdTryTelemetry: Partial[] = [ + { + id: lspConfig.id, + languageServerLocation: 'cache', + languageServerSetupStage: 'getServer', + result: 'Failed', + }, + { + id: lspConfig.id, + languageServerLocation: 'remote', + languageServerSetupStage: 'getServer', + result: 'Failed', + }, + { + id: lspConfig.id, + languageServerLocation: 'fallback', + languageServerSetupStage: 'getServer', + result: 'Succeeded', + }, + ] + + const expectedTelemetry = firstTryTelemetry.concat(secondTryTelemetry, thirdTryTelemetry) + + assertTelemetry('languageServer_setup', expectedTelemetry) + }) + + it('resolves release candidiates', async () => { + const original = new ManifestResolver(lspConfig.manifestUrl, lspConfig.id).resolve() + sandbox.stub(ManifestResolver.prototype, 'resolve').callsFake(async () => { + const originalManifest = await original + + const latestVersion = originalManifest.versions.reduce((latest, current) => { + return semver.gt(current.serverVersion, latest.serverVersion) ? current : latest + }, originalManifest.versions[0]) + + // These convert something like 3.1.1 to 3.1.2-rc.0 + const incrementedVersion = semver.inc(latestVersion.serverVersion, 'patch') + if (!incrementedVersion) { + assert.fail('Failed to increment minor version') + } + + const prereleaseVersion = semver.inc(incrementedVersion, 'prerelease', 'rc') + if (!prereleaseVersion) { + assert.fail('Failed to create pre-release version') + } + + const newVersion = { + ...latestVersion, + serverVersion: prereleaseVersion, + } + + originalManifest.versions = [newVersion, ...originalManifest.versions] + return originalManifest + }) + + const version = lspConfig.supportedVersions + lspConfig.supportedVersions = version.startsWith('^') ? version : `^${version}` + const download = await createInstaller(lspConfig).resolve() + assert.ok(download.assetDirectory.endsWith('-rc.0')) + }) + }) + }) +} diff --git a/packages/amazonq/test/e2e/lsp/workspaceContextLsp.test.ts b/packages/amazonq/test/e2e/lsp/workspaceContextLsp.test.ts new file mode 100644 index 00000000000..75d57949c0b --- /dev/null +++ b/packages/amazonq/test/e2e/lsp/workspaceContextLsp.test.ts @@ -0,0 +1,42 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as os from 'os' +import { createLspInstallerTests } from './lspInstallerUtil' +import { defaultAmazonQWorkspaceLspConfig, LspClient, LspConfig, WorkspaceLspInstaller } from 'aws-core-vscode/amazonq' +import assert from 'assert' + +describe('AmazonQWorkspaceLSP', () => { + createLspInstallerTests({ + suiteName: 'AmazonQWorkspaceLSPInstaller', + lspConfig: defaultAmazonQWorkspaceLspConfig, + createInstaller: (lspConfig?: LspConfig) => new WorkspaceLspInstaller.WorkspaceLspInstaller(lspConfig), + targetContents: [ + { + bytes: 0, + filename: `qserver-${os.platform()}-${os.arch()}.zip`, + hashes: [], + url: 'http://fakeurl', + }, + ], + setEnv: (path: string) => { + process.env.__AMAZONQWORKSPACELSP_PATH = path + }, + resetEnv: () => { + delete process.env.__AMAZONQWORKSPACELSP_PATH + }, + }) + + it('activates', async () => { + const ok = await LspClient.instance.waitUntilReady() + if (!ok) { + assert.fail('Workspace context language server failed to become ready') + } + const serverUsage = await LspClient.instance.getLspServerUsage() + if (!serverUsage) { + assert.fail('Unable to verify that the workspace context language server has been activated') + } + }) +}) diff --git a/packages/amazonq/test/unit/amazonq/lsp/config.test.ts b/packages/amazonq/test/unit/amazonq/lsp/config.test.ts new file mode 100644 index 00000000000..9a9ba1ef348 --- /dev/null +++ b/packages/amazonq/test/unit/amazonq/lsp/config.test.ts @@ -0,0 +1,110 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { DevSettings } from 'aws-core-vscode/shared' +import sinon from 'sinon' +import { defaultAmazonQLspConfig, getAmazonQLspConfig } from '../../../../src/lsp/config' +import { LspConfig, getAmazonQWorkspaceLspConfig, defaultAmazonQWorkspaceLspConfig } from 'aws-core-vscode/amazonq' + +for (const [name, config, defaultConfig, setEnv, resetEnv] of [ + [ + 'getAmazonQLspConfig', + getAmazonQLspConfig, + defaultAmazonQLspConfig, + (envConfig: LspConfig) => { + process.env.__AMAZONQLSP_MANIFEST_URL = envConfig.manifestUrl + process.env.__AMAZONQLSP_SUPPORTED_VERSIONS = envConfig.supportedVersions + process.env.__AMAZONQLSP_ID = envConfig.id + process.env.__AMAZONQLSP_PATH = envConfig.path + }, + () => { + delete process.env.__AMAZONQLSP_MANIFEST_URL + delete process.env.__AMAZONQLSP_SUPPORTED_VERSIONS + delete process.env.__AMAZONQLSP_ID + delete process.env.__AMAZONQLSP_PATH + }, + ], + [ + 'getAmazonQWorkspaceLspConfig', + getAmazonQWorkspaceLspConfig, + defaultAmazonQWorkspaceLspConfig, + (envConfig: LspConfig) => { + process.env.__AMAZONQWORKSPACELSP_MANIFEST_URL = envConfig.manifestUrl + process.env.__AMAZONQWORKSPACELSP_SUPPORTED_VERSIONS = envConfig.supportedVersions + process.env.__AMAZONQWORKSPACELSP_ID = envConfig.id + process.env.__AMAZONQWORKSPACELSP_PATH = envConfig.path + }, + () => { + delete process.env.__AMAZONQWORKSPACELSP_MANIFEST_URL + delete process.env.__AMAZONQWORKSPACELSP_SUPPORTED_VERSIONS + delete process.env.__AMAZONQWORKSPACELSP_ID + delete process.env.__AMAZONQWORKSPACELSP_PATH + }, + ], +] as const) { + describe(name, () => { + let sandbox: sinon.SinonSandbox + let serviceConfigStub: sinon.SinonStub + const settingConfig: LspConfig = { + manifestUrl: 'https://custom.url/manifest.json', + supportedVersions: '4.0.0', + id: 'AmazonQSetting', + path: '/custom/path', + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + + serviceConfigStub = sandbox.stub() + sandbox.stub(DevSettings, 'instance').get(() => ({ + getServiceConfig: serviceConfigStub, + })) + }) + + afterEach(() => { + sandbox.restore() + resetEnv() + }) + + it('uses default config', () => { + serviceConfigStub.returns({}) + assert.deepStrictEqual(config(), defaultConfig) + }) + + it('overrides location', () => { + const path = '/custom/path/to/lsp' + serviceConfigStub.returns({ path }) + + assert.deepStrictEqual(config(), { + ...defaultConfig, + path, + }) + }) + + it('overrides default settings', () => { + serviceConfigStub.returns(settingConfig) + + assert.deepStrictEqual(config(), settingConfig) + }) + + it('environment variable takes precedence over settings', () => { + const envConfig: LspConfig = { + manifestUrl: 'https://another-custom.url/manifest.json', + supportedVersions: '5.1.1', + id: 'AmazonQEnv', + path: '/some/new/custom/path', + } + + setEnv(envConfig) + serviceConfigStub.returns(settingConfig) + + assert.deepStrictEqual(config(), { + ...defaultAmazonQLspConfig, + ...envConfig, + }) + }) + }) +} diff --git a/packages/amazonq/test/unit/amazonq/lsp/lspController.test.ts b/packages/amazonq/test/unit/amazonq/lsp/lspController.test.ts deleted file mode 100644 index d54551e433f..00000000000 --- a/packages/amazonq/test/unit/amazonq/lsp/lspController.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ -import assert from 'assert' -import sinon from 'sinon' -import { Content, LspController } from 'aws-core-vscode/amazonq' -import { createTestFile } from 'aws-core-vscode/test' -import { fs } from 'aws-core-vscode/shared' - -describe('Amazon Q LSP controller', function () { - it('Download mechanism checks against hash, when hash matches', async function () { - const content = { - filename: 'qserver-linux-x64.zip', - url: 'https://x/0.0.6/qserver-linux-x64.zip', - hashes: [ - 'sha384:768412320f7b0aa5812fce428dc4706b3cae50e02a64caa16a782249bfe8efc4b7ef1ccb126255d196047dfedf17a0a9', - ], - bytes: 512, - } as Content - const lspController = new LspController() - sinon.stub(lspController, '_download') - const mockFileName = 'test_case_1.zip' - const mockDownloadFile = await createTestFile(mockFileName) - await fs.writeFile(mockDownloadFile.fsPath, 'test') - const result = await lspController.downloadAndCheckHash(mockDownloadFile.fsPath, content) - assert.strictEqual(result, true) - }) - - it('Download mechanism checks against hash, when hash does not match', async function () { - const content = { - filename: 'qserver-linux-x64.zip', - url: 'https://x/0.0.6/qserver-linux-x64.zip', - hashes: [ - 'sha384:38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b', - ], - bytes: 512, - } as Content - const lspController = new LspController() - sinon.stub(lspController, '_download') - const mockFileName = 'test_case_2.zip' - const mockDownloadFile = await createTestFile(mockFileName) - await fs.writeFile(mockDownloadFile.fsPath, 'file_content') - const result = await lspController.downloadAndCheckHash(mockDownloadFile.fsPath, content) - assert.strictEqual(result, false) - }) - - afterEach(() => { - sinon.restore() - }) -}) diff --git a/packages/core/package.json b/packages/core/package.json index cfd56511826..a4dbf94eaee 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -439,6 +439,8 @@ "serveVue": "Local server for Vue.js code for development purposes. Provides faster iteration when updating Vue files" }, "devDependencies": { + "@aws/language-server-runtimes": "^0.2.27", + "@aws/chat-client-ui-types": "^0.0.8", "@aws-sdk/types": "^3.13.1", "@cspotcode/source-map-support": "^0.8.1", "@sinonjs/fake-timers": "^10.0.2", diff --git a/packages/core/src/amazonq/index.ts b/packages/core/src/amazonq/index.ts index a16ed4cc438..98ab00087a3 100644 --- a/packages/core/src/amazonq/index.ts +++ b/packages/core/src/amazonq/index.ts @@ -15,7 +15,7 @@ export { walkthroughInlineSuggestionsExample, walkthroughSecurityScanExample, } from './onboardingPage/walkthrough' -export { LspController, Content } from './lsp/lspController' +export { LspController } from './lsp/lspController' export { LspClient } from './lsp/lspClient' export { api } from './extApi' export { AmazonQChatViewProvider } from './webview/webView' @@ -44,6 +44,8 @@ export { ExtensionMessage } from '../amazonq/webview/ui/commands' export { CodeReference } from '../codewhispererChat/view/connector/connector' export { extractAuthFollowUp } from './util/authUtils' export { Messenger } from './commons/connector/baseMessenger' +export * from './lsp/config' +export * as WorkspaceLspInstaller from './lsp/workspaceInstaller' import { FeatureContext } from '../shared/featureConfig' /** diff --git a/packages/core/src/amazonq/lsp/config.ts b/packages/core/src/amazonq/lsp/config.ts new file mode 100644 index 00000000000..8557d3cff63 --- /dev/null +++ b/packages/core/src/amazonq/lsp/config.ts @@ -0,0 +1,29 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DevSettings } from '../../shared/settings' +import { getServiceEnvVarConfig } from '../../shared/vscode/env' + +export interface LspConfig { + manifestUrl: string + supportedVersions: string + id: string + path?: string +} + +export const defaultAmazonQWorkspaceLspConfig: LspConfig = { + manifestUrl: 'https://aws-toolkit-language-servers.amazonaws.com/q-context/manifest.json', + supportedVersions: '0.1.42', + id: 'AmazonQ-Workspace', // used for identification in global storage/local disk location. Do not change. + path: undefined, +} + +export function getAmazonQWorkspaceLspConfig(): LspConfig { + return { + ...defaultAmazonQWorkspaceLspConfig, + ...(DevSettings.instance.getServiceConfig('amazonqWorkspaceLsp', {}) as LspConfig), + ...getServiceEnvVarConfig('amazonqWorkspaceLsp', Object.keys(defaultAmazonQWorkspaceLspConfig)), + } +} diff --git a/packages/core/src/amazonq/lsp/lspClient.ts b/packages/core/src/amazonq/lsp/lspClient.ts index 5d96650ebf8..e895f0fa8c7 100644 --- a/packages/core/src/amazonq/lsp/lspClient.ts +++ b/packages/core/src/amazonq/lsp/lspClient.ts @@ -10,7 +10,6 @@ import * as vscode from 'vscode' import * as path from 'path' import * as nls from 'vscode-nls' -import { spawn } from 'child_process' // eslint-disable-line no-restricted-imports import * as crypto from 'crypto' import * as jose from 'jose' @@ -35,30 +34,18 @@ import { GetContextCommandPromptRequestType, AdditionalContextPrompt, } from './types' -import { Writable } from 'stream' import { CodeWhispererSettings } from '../../codewhisperer/util/codewhispererSettings' import { fs } from '../../shared/fs/fs' import { getLogger } from '../../shared/logger/logger' import globals from '../../shared/extensionGlobals' +import { ResourcePaths } from '../../shared/lsp/types' +import { createServerOptions } from '../../shared/lsp/utils/platform' import { waitUntil } from '../../shared/utilities/timeoutUtils' const localize = nls.loadMessageBundle() const key = crypto.randomBytes(32) -/** - * Sends a json payload to the language server, who is waiting to know what the encryption key is. - * Code reference: https://github.com/aws/language-servers/blob/7da212185a5da75a72ce49a1a7982983f438651a/client/vscode/src/credentialsActivation.ts#L77 - */ -export function writeEncryptionInit(stream: Writable): void { - const request = { - version: '1.0', - mode: 'JWT', - key: key.toString('base64'), - } - stream.write(JSON.stringify(request)) - stream.write('\n') -} /** * LspClient manages the API call between VS Code extension and LSP server * It encryptes the payload of API call. @@ -240,13 +227,11 @@ export class LspClient { * It will create a output channel named Amazon Q Language Server. * This function assumes the LSP server has already been downloaded. */ -export async function activate(extensionContext: ExtensionContext) { +export async function activate(extensionContext: ExtensionContext, resourcePaths: ResourcePaths) { LspClient.instance const toDispose = extensionContext.subscriptions let rangeFormatting: Disposable | undefined - // The server is implemented in node - const serverModule = path.join(extensionContext.extensionPath, 'resources/qserver/lspServer.js') // The debug options for the server // --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging const debugOptions = { execArgv: ['--nolazy', '--preserve-symlinks', '--stdio'] } @@ -265,15 +250,7 @@ export async function activate(extensionContext: ExtensionContext) { delete process.env.Q_WORKER_THREADS } - const nodename = process.platform === 'win32' ? 'node.exe' : 'node' - - const child = spawn(extensionContext.asAbsolutePath(path.join('resources', nodename)), [ - serverModule, - ...debugOptions.execArgv, - ]) - // share an encryption key using stdin - // follow same practice of DEXP LSP server - writeEncryptionInit(child.stdin) + const serverModule = resourcePaths.lsp // If the extension is launch in debug mode the debug server options are use // Otherwise the run options are used @@ -282,7 +259,12 @@ export async function activate(extensionContext: ExtensionContext) { debug: { module: serverModule, transport: TransportKind.ipc, options: debugOptions }, } - serverOptions = () => Promise.resolve(child!) + serverOptions = createServerOptions({ + encryptionKey: key, + executable: resourcePaths.node, + serverModule, + execArgv: debugOptions.execArgv, + }) const documentSelector = [{ scheme: 'file', language: '*' }] diff --git a/packages/core/src/amazonq/lsp/lspController.ts b/packages/core/src/amazonq/lsp/lspController.ts index 5fb4d3668a3..a789d1dd5cb 100644 --- a/packages/core/src/amazonq/lsp/lspController.ts +++ b/packages/core/src/amazonq/lsp/lspController.ts @@ -5,24 +5,15 @@ import * as vscode from 'vscode' import * as path from 'path' -import * as crypto from 'crypto' -import { createWriteStream } from 'fs' // eslint-disable-line no-restricted-imports import { getLogger } from '../../shared/logger/logger' import { CurrentWsFolders, collectFilesForIndex } from '../../shared/utilities/workspaceUtils' -import fetch from 'node-fetch' -import request from '../../shared/request' -import { LspClient } from './lspClient' -import AdmZip from 'adm-zip' -import { makeTemporaryToolkitFolder, tryRemoveFolder } from '../../shared/filesystemUtilities' -import { activate as activateLsp } from './lspClient' +import { activate as activateLsp, LspClient } from './lspClient' import { telemetry } from '../../shared/telemetry/telemetry' import { isCloud9 } from '../../shared/extensionUtilities' -import { fs } from '../../shared/fs/fs' -import globals from '../../shared/extensionGlobals' -import { ToolkitError } from '../../shared/errors' -import { isWeb } from '../../shared/extensionGlobals' -import { getUserAgent } from '../../shared/telemetry/util' +import globals, { isWeb } from '../../shared/extensionGlobals' import { isAmazonInternalOs } from '../../shared/vscode/env' +import { WorkspaceLspInstaller } from './workspaceInstaller' +import { lspSetupStage } from '../../shared/lsp/utils/setupStage' import { RelevantTextDocumentAddition } from '../../codewhispererChat/controllers/chat/model' export interface Chunk { @@ -34,38 +25,6 @@ export interface Chunk { readonly startLine?: number readonly endLine?: number } - -export interface Content { - filename: string - url: string - hashes: string[] - bytes: number - serverVersion?: string -} - -export interface Target { - platform: string - arch: string - contents: Content[] -} - -export interface Manifest { - manifestSchemaVersion: string - artifactId: string - artifactDescription: string - isManifestDeprecated: boolean - versions: { - serverVersion: string - isDelisted: boolean - targets: Target[] - }[] -} -const manifestUrl = 'https://aws-toolkit-language-servers.amazonaws.com/q-context/manifest.json' -// this LSP client in Q extension is only going to work with these LSP server versions -const supportedLspServerVersions = ['0.1.42'] - -const nodeBinName = process.platform === 'win32' ? 'node.exe' : 'node' - export interface BuildIndexConfig { startUrl?: string maxIndexSize: number @@ -73,7 +32,7 @@ export interface BuildIndexConfig { } /* - * LSP Controller manages the status of Amazon Q LSP: + * LSP Controller manages the status of Amazon Q Workspace Indexing LSP: * 1. Downloading, verifying and installing LSP using DEXP LSP manifest and CDN. * 2. Managing the LSP states. There are a couple of possible LSP states: * Not installed. Installed. Running. Indexing. Indexing Done. @@ -86,201 +45,16 @@ export interface BuildIndexConfig { export class LspController { static #instance: LspController private _isIndexingInProgress = false + private logger = getLogger('amazonqWorkspaceLsp') public static get instance() { return (this.#instance ??= new this()) } - constructor() {} isIndexingInProgress() { return this._isIndexingInProgress } - async _download(localFile: string, remoteUrl: string) { - const res = await fetch(remoteUrl, { - headers: { - 'User-Agent': getUserAgent({ includePlatform: true, includeClientId: true }), - }, - }) - if (!res.ok) { - throw new ToolkitError(`Failed to download. Error: ${JSON.stringify(res)}`) - } - return new Promise((resolve, reject) => { - const file = createWriteStream(localFile) - res.body.pipe(file) - res.body.on('error', (err) => { - reject(err) - }) - file.on('finish', () => { - file.close(resolve) - }) - }) - } - - async fetchManifest() { - try { - const resp = await request.fetch('GET', manifestUrl, { - headers: { - 'User-Agent': getUserAgent({ includePlatform: true, includeClientId: true }), - }, - }).response - if (!resp.ok) { - throw new ToolkitError(`Failed to fetch manifest. Error: ${resp.statusText}`) - } - return resp.json() - } catch (e: any) { - throw new ToolkitError(`Failed to fetch manifest. Error: ${JSON.stringify(e)}`) - } - } - - async getFileSha384(filePath: string): Promise { - const fileBuffer = await fs.readFileBytes(filePath) - const hash = crypto.createHash('sha384') - hash.update(fileBuffer) - return hash.digest('hex') - } - - async isLspInstalled(context: vscode.ExtensionContext) { - const localQServer = context.asAbsolutePath(path.join('resources', 'qserver')) - const localNodeRuntime = context.asAbsolutePath(path.join('resources', nodeBinName)) - return (await fs.exists(localQServer)) && (await fs.exists(localNodeRuntime)) - } - - getQserverFromManifest(manifest: Manifest): Content | undefined { - if (manifest.isManifestDeprecated) { - return undefined - } - for (const version of manifest.versions) { - if (version.isDelisted) { - continue - } - if (!supportedLspServerVersions.includes(version.serverVersion)) { - continue - } - for (const t of version.targets) { - if ( - (t.platform === process.platform || (t.platform === 'windows' && process.platform === 'win32')) && - t.arch === process.arch - ) { - for (const content of t.contents) { - if (content.filename.startsWith('qserver') && content.hashes.length > 0) { - content.serverVersion = version.serverVersion - return content - } - } - } - } - } - return undefined - } - - getNodeRuntimeFromManifest(manifest: Manifest): Content | undefined { - if (manifest.isManifestDeprecated) { - return undefined - } - for (const version of manifest.versions) { - if (version.isDelisted) { - continue - } - if (!supportedLspServerVersions.includes(version.serverVersion)) { - continue - } - for (const t of version.targets) { - if ( - (t.platform === process.platform || (t.platform === 'windows' && process.platform === 'win32')) && - t.arch === process.arch - ) { - for (const content of t.contents) { - if (content.filename.startsWith('node') && content.hashes.length > 0) { - content.serverVersion = version.serverVersion - return content - } - } - } - } - } - return undefined - } - - private async hashMatch(filePath: string, content: Content) { - const sha384 = await this.getFileSha384(filePath) - if ('sha384:' + sha384 !== content.hashes[0]) { - getLogger().error( - `LspController: Downloaded file sha ${sha384} does not match manifest ${content.hashes[0]}.` - ) - await fs.delete(filePath) - return false - } - return true - } - - async downloadAndCheckHash(filePath: string, content: Content) { - await this._download(filePath, content.url) - const match = await this.hashMatch(filePath, content) - if (!match) { - return false - } - return true - } - - async tryInstallLsp(context: vscode.ExtensionContext): Promise { - let tempFolder = undefined - try { - if (await this.isLspInstalled(context)) { - getLogger().info(`LspController: LSP already installed`) - return true - } - // clean up previous downloaded LSP - const qserverPath = context.asAbsolutePath(path.join('resources', 'qserver')) - if (await fs.exists(qserverPath)) { - await tryRemoveFolder(qserverPath) - } - // clean up previous downloaded node runtime - const nodeRuntimePath = context.asAbsolutePath(path.join('resources', nodeBinName)) - if (await fs.exists(nodeRuntimePath)) { - await fs.delete(nodeRuntimePath) - } - // fetch download url for qserver and node runtime - const manifest: Manifest = (await this.fetchManifest()) as Manifest - const qserverContent = this.getQserverFromManifest(manifest) - const nodeRuntimeContent = this.getNodeRuntimeFromManifest(manifest) - if (!qserverContent || !nodeRuntimeContent) { - getLogger().info(`LspController: Did not find LSP URL for ${process.platform} ${process.arch}`) - return false - } - - tempFolder = await makeTemporaryToolkitFolder() - - // download lsp to temp folder - const qserverZipTempPath = path.join(tempFolder, 'qserver.zip') - const downloadOk = await this.downloadAndCheckHash(qserverZipTempPath, qserverContent) - if (!downloadOk) { - return false - } - const zip = new AdmZip(qserverZipTempPath) - zip.extractAllTo(tempFolder) - await fs.rename(path.join(tempFolder, 'qserver'), qserverPath) - - // download node runtime to temp folder - const nodeRuntimeTempPath = path.join(tempFolder, nodeBinName) - const downloadNodeOk = await this.downloadAndCheckHash(nodeRuntimeTempPath, nodeRuntimeContent) - if (!downloadNodeOk) { - return false - } - await fs.chmod(nodeRuntimeTempPath, 0o755) - await fs.rename(nodeRuntimeTempPath, nodeRuntimePath) - return true - } catch (e) { - getLogger().error(`LspController: Failed to setup LSP server ${e}`) - return false - } finally { - // clean up temp folder - if (tempFolder) { - await tryRemoveFolder(tempFolder) - } - } - } - async query(s: string): Promise { const chunks: Chunk[] | undefined = await LspClient.instance.queryVectorIndex(s) const resp: RelevantTextDocumentAddition[] = [] @@ -315,18 +89,18 @@ export class LspController { return await LspClient.instance.queryInlineProjectContext(query, path, target) } catch (e) { if (e instanceof Error) { - getLogger().error(`unexpected error while querying inline project context, e=${e.message}`) + this.logger.error(`unexpected error while querying inline project context, e=${e.message}`) } return [] } } async buildIndex(buildIndexConfig: BuildIndexConfig) { - getLogger().info(`LspController: Starting to build index of project`) + this.logger.info(`LspController: Starting to build index of project`) const start = performance.now() const projPaths = (vscode.workspace.workspaceFolders ?? []).map((folder) => folder.uri.fsPath) if (projPaths.length === 0) { - getLogger().info(`LspController: Skipping building index. No projects found in workspace`) + this.logger.info(`LspController: Skipping building index. No projects found in workspace`) return } projPaths.sort() @@ -343,12 +117,12 @@ export class LspController { (accumulator, currentFile) => accumulator + currentFile.fileSizeBytes, 0 ) - getLogger().info(`LspController: Found ${files.length} files in current project ${projPaths}`) + this.logger.info(`LspController: Found ${files.length} files in current project ${projPaths}`) const config = buildIndexConfig.isVectorIndexEnabled ? 'all' : 'default' const r = files.map((f) => f.fileUri.fsPath) const resp = await LspClient.instance.buildIndex(r, projRoot, config) if (resp) { - getLogger().debug(`LspController: Finish building index of project`) + this.logger.debug(`LspController: Finish building index of project`) const usage = await LspClient.instance.getLspServerUsage() telemetry.amazonq_indexWorkspace.emit({ duration: performance.now() - start, @@ -361,7 +135,7 @@ export class LspController { credentialStartUrl: buildIndexConfig.startUrl, }) } else { - getLogger().error(`LspController: Failed to build index of project`) + this.logger.error(`LspController: Failed to build index of project`) telemetry.amazonq_indexWorkspace.emit({ duration: performance.now() - start, result: 'Failed', @@ -373,7 +147,7 @@ export class LspController { } } catch (error) { // TODO: use telemetry.run() - getLogger().error(`LspController: Failed to build index of project`) + this.logger.error(`LspController: Failed to build index of project`) telemetry.amazonq_indexWorkspace.emit({ duration: performance.now() - start, result: 'Failed', @@ -390,18 +164,13 @@ export class LspController { async trySetupLsp(context: vscode.ExtensionContext, buildIndexConfig: BuildIndexConfig) { if (isCloud9() || isWeb() || isAmazonInternalOs()) { - getLogger().warn('LspController: Skipping LSP setup. LSP is not compatible with the current environment. ') + this.logger.warn('LspController: Skipping LSP setup. LSP is not compatible with the current environment. ') // do not do anything if in Cloud9 or Web mode or in AL2 (AL2 does not support node v18+) return } setImmediate(async () => { - const ok = await LspController.instance.tryInstallLsp(context) - if (!ok) { - return - } try { - await activateLsp(context) - getLogger().info('LspController: LSP activated') + await this.setupLsp(context) await vscode.commands.executeCommand(`aws.amazonq.updateContextCommandItems`) void LspController.instance.buildIndex(buildIndexConfig) // log the LSP server CPU and Memory usage per 30 minutes. @@ -409,7 +178,7 @@ export class LspController { async () => { const usage = await LspClient.instance.getLspServerUsage() if (usage) { - getLogger().info( + this.logger.info( `LspController: LSP server CPU ${usage.cpuUsage}%, LSP server Memory ${ usage.memoryUsage / (1024 * 1024) }MB ` @@ -419,8 +188,16 @@ export class LspController { 30 * 60 * 1000 ) } catch (e) { - getLogger().error(`LspController: LSP failed to activate ${e}`) + this.logger.error(`LspController: LSP failed to activate ${e}`) } }) } + + private async setupLsp(context: vscode.ExtensionContext) { + await lspSetupStage('all', async () => { + const installResult = await new WorkspaceLspInstaller().resolve() + await lspSetupStage('launch', async () => activateLsp(context, installResult.resourcePaths)) + this.logger.info('LspController: LSP activated') + }) + } } diff --git a/packages/core/src/amazonq/lsp/workspaceInstaller.ts b/packages/core/src/amazonq/lsp/workspaceInstaller.ts new file mode 100644 index 00000000000..99e70f20cbf --- /dev/null +++ b/packages/core/src/amazonq/lsp/workspaceInstaller.ts @@ -0,0 +1,39 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path' +import { ResourcePaths } from '../../shared/lsp/types' +import { getNodeExecutableName } from '../../shared/lsp/utils/platform' +import { fs } from '../../shared/fs/fs' +import { BaseLspInstaller } from '../../shared/lsp/baseLspInstaller' +import { getAmazonQWorkspaceLspConfig, LspConfig } from './config' + +export class WorkspaceLspInstaller extends BaseLspInstaller { + constructor(lspConfig: LspConfig = getAmazonQWorkspaceLspConfig()) { + super(lspConfig, 'amazonqWorkspaceLsp') + } + + protected override async postInstall(assetDirectory: string): Promise { + const resourcePaths = this.resourcePaths(assetDirectory) + await fs.chmod(resourcePaths.node, 0o755) + } + + protected override resourcePaths(assetDirectory?: string): ResourcePaths { + // local version + if (!assetDirectory) { + return { + lsp: this.config.path ?? '', + node: getNodeExecutableName(), + } + } + + const lspNodeName = + process.platform === 'win32' ? getNodeExecutableName() : `node-${process.platform}-${process.arch}` + return { + lsp: path.join(assetDirectory, `qserver-${process.platform}-${process.arch}/qserver/lspServer.js`), + node: path.join(assetDirectory, lspNodeName), + } + } +} diff --git a/packages/core/src/auth/sso/ssoAccessTokenProvider.ts b/packages/core/src/auth/sso/ssoAccessTokenProvider.ts index 5ee8513cbb5..e753fb2ef90 100644 --- a/packages/core/src/auth/sso/ssoAccessTokenProvider.ts +++ b/packages/core/src/auth/sso/ssoAccessTokenProvider.ts @@ -26,7 +26,7 @@ import { AwsLoginWithBrowser, AwsRefreshCredentials, telemetry } from '../../sha import { indent, toBase64URL } from '../../shared/utilities/textUtilities' import { AuthSSOServer } from './server' import { CancellationError, sleep } from '../../shared/utilities/timeoutUtils' -import { getIdeProperties, isAmazonQ, isCloud9 } from '../../shared/extensionUtilities' +import { oidcClientName, isAmazonQ } from '../../shared/extensionUtilities' import { randomBytes, createHash } from 'crypto' import { localize } from '../../shared/utilities/vsCodeUtils' import { randomUUID } from '../../shared/crypto' @@ -438,10 +438,9 @@ function getSessionDuration(id: string) { */ export class DeviceFlowAuthorization extends SsoAccessTokenProvider { override async registerClient(): Promise { - const companyName = getIdeProperties().company return this.oidc.registerClient( { - clientName: isCloud9() ? `${companyName} Cloud9` : `${companyName} IDE Extensions for VSCode`, + clientName: oidcClientName(), clientType: clientRegistrationType, scopes: this.profile.scopes, }, @@ -543,11 +542,10 @@ export class DeviceFlowAuthorization extends SsoAccessTokenProvider { */ class AuthFlowAuthorization extends SsoAccessTokenProvider { override async registerClient(): Promise { - const companyName = getIdeProperties().company return this.oidc.registerClient( { // All AWS extensions (Q, Toolkit) for a given IDE use the same client name. - clientName: isCloud9() ? `${companyName} Cloud9` : `${companyName} IDE Extensions for VSCode`, + clientName: oidcClientName(), clientType: clientRegistrationType, scopes: this.profile.scopes, grantTypes: [authorizationGrantType, refreshGrantType], @@ -653,11 +651,10 @@ class WebAuthorization extends SsoAccessTokenProvider { private redirectUri = 'http://127.0.0.1:54321/oauth/callback' override async registerClient(): Promise { - const companyName = getIdeProperties().company return this.oidc.registerClient( { // All AWS extensions (Q, Toolkit) for a given IDE use the same client name. - clientName: isCloud9() ? `${companyName} Cloud9` : `${companyName} IDE Extensions for VSCode`, + clientName: oidcClientName(), clientType: clientRegistrationType, scopes: this.profile.scopes, grantTypes: [authorizationGrantType, refreshGrantType], diff --git a/packages/core/src/codewhisperer/activation.ts b/packages/core/src/codewhisperer/activation.ts index 1ad3d4f0089..73f65ca4ada 100644 --- a/packages/core/src/codewhisperer/activation.ts +++ b/packages/core/src/codewhisperer/activation.ts @@ -6,12 +6,9 @@ import * as vscode from 'vscode' import * as nls from 'vscode-nls' import { getTabSizeSetting } from '../shared/utilities/editorUtilities' -import { KeyStrokeHandler } from './service/keyStrokeHandler' import * as EditorContext from './util/editorContext' import * as CodeWhispererConstants from './models/constants' import { - vsCodeState, - ConfigurationEntry, CodeSuggestionsState, CodeScansState, SecurityTreeViewFilterState, @@ -19,13 +16,11 @@ import { CodeScanIssue, CodeIssueGroupingStrategyState, } from './models/model' -import { invokeRecommendation } from './commands/invokeRecommendation' import { acceptSuggestion } from './commands/onInlineAcceptance' import { CodeWhispererSettings } from './util/codewhispererSettings' import { ExtContext } from '../shared/extensions' import { CodeWhispererTracker } from './tracker/codewhispererTracker' import * as codewhispererClient from './client/codewhisperer' -import { runtimeLanguageContext } from './util/runtimeLanguageContext' import { getLogger } from '../shared/logger/logger' import { enableCodeSuggestions, @@ -59,7 +54,6 @@ import { showExploreAgentsView, showCodeIssueGroupingQuickPick, } from './commands/basicCommands' -import { sleep } from '../shared/utilities/timeoutUtils' import { ReferenceLogViewProvider } from './service/referenceLogViewProvider' import { ReferenceHoverProvider } from './service/referenceHoverProvider' import { ReferenceInlineProvider } from './service/referenceInlineProvider' @@ -73,7 +67,6 @@ import { RecommendationHandler } from './service/recommendationHandler' import { Commands, registerCommandErrorHandler, registerDeclaredCommands } from '../shared/vscode/commands2' import { InlineCompletionService, refreshStatusBar } from './service/inlineCompletionService' import { isInlineCompletionEnabled } from './util/commonUtil' -import { CodeWhispererCodeCoverageTracker } from './tracker/codewhispererCodeCoverageTracker' import { AuthUtil } from './util/authUtil' import { ImportAdderProvider } from './service/importAdderProvider' import { TelemetryHelper } from './util/telemetryHelper' @@ -96,13 +89,11 @@ import { SecurityIssueTreeViewProvider } from './service/securityIssueTreeViewPr import { setContext } from '../shared/vscode/setContext' import { syncSecurityIssueWebview } from './views/securityIssue/securityIssueWebview' import { detectCommentAboveLine } from '../shared/utilities/commentUtils' -import { UserWrittenCodeTracker } from './tracker/userWrittenCodeTracker' let localize: nls.LocalizeFunc export async function activate(context: ExtContext): Promise { localize = nls.loadMessageBundle() - const codewhispererSettings = CodeWhispererSettings.instance // Import old CodeWhisperer settings into Amazon Q await CodeWhispererSettings.instance.importSettings() @@ -301,16 +292,6 @@ export async function activate(context: ExtContext): Promise { SecurityIssueProvider.instance.issues.some((group) => group.issues.some((issue) => issue.visible)) void setContext('aws.amazonq.security.noMatches', noMatches) }), - // manual trigger - Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => { - invokeRecommendation( - vscode.window.activeTextEditor as vscode.TextEditor, - client, - await getConfigEntry() - ).catch((e) => { - getLogger().error('invokeRecommendation failed: %s', (e as Error).message) - }) - }), // select customization selectCustomizationPrompt.register(), // notify new customizations @@ -478,90 +459,6 @@ export async function activate(context: ExtContext): Promise { }) } - function getAutoTriggerStatus(): boolean { - return CodeSuggestionsState.instance.isSuggestionsEnabled() - } - - async function getConfigEntry(): Promise { - const isShowMethodsEnabled: boolean = - vscode.workspace.getConfiguration('editor').get('suggest.showMethods') || false - const isAutomatedTriggerEnabled: boolean = getAutoTriggerStatus() - const isManualTriggerEnabled: boolean = true - const isSuggestionsWithCodeReferencesEnabled = codewhispererSettings.isSuggestionsWithCodeReferencesEnabled() - - // TODO:remove isManualTriggerEnabled - return { - isShowMethodsEnabled, - isManualTriggerEnabled, - isAutomatedTriggerEnabled, - isSuggestionsWithCodeReferencesEnabled, - } - } - - if (isInlineCompletionEnabled()) { - await setSubscriptionsforInlineCompletion() - await AuthUtil.instance.setVscodeContextProps() - } - - async function setSubscriptionsforInlineCompletion() { - RecommendationHandler.instance.subscribeSuggestionCommands() - /** - * Automated trigger - */ - context.extensionContext.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor(async (editor) => { - await RecommendationHandler.instance.onEditorChange() - }), - vscode.window.onDidChangeWindowState(async (e) => { - await RecommendationHandler.instance.onFocusChange() - }), - vscode.window.onDidChangeTextEditorSelection(async (e) => { - await RecommendationHandler.instance.onCursorChange(e) - }), - vscode.workspace.onDidChangeTextDocument(async (e) => { - const editor = vscode.window.activeTextEditor - if (!editor) { - return - } - if (e.document !== editor.document) { - return - } - if (!runtimeLanguageContext.isLanguageSupported(e.document)) { - return - } - - CodeWhispererCodeCoverageTracker.getTracker(e.document.languageId)?.countTotalTokens(e) - UserWrittenCodeTracker.instance.onTextDocumentChange(e) - /** - * Handle this keystroke event only when - * 1. It is not a backspace - * 2. It is not caused by CodeWhisperer editing - * 3. It is not from undo/redo. - */ - if (e.contentChanges.length === 0 || vsCodeState.isCodeWhispererEditing) { - return - } - - if (vsCodeState.lastUserModificationTime) { - TelemetryHelper.instance.setTimeSinceLastModification( - performance.now() - vsCodeState.lastUserModificationTime - ) - } - vsCodeState.lastUserModificationTime = performance.now() - /** - * Important: Doing this sleep(10) is to make sure - * 1. this event is processed by vs code first - * 2. editor.selection.active has been successfully updated by VS Code - * Then this event can be processed by our code. - */ - await sleep(CodeWhispererConstants.vsCodeCursorUpdateDelay) - if (!RecommendationHandler.instance.isSuggestionVisible()) { - await KeyStrokeHandler.instance.processKeyStroke(e, editor, client, await getConfigEntry()) - } - }) - ) - } - void FeatureConfigProvider.instance.fetchFeatureConfigs().catch((error) => { getLogger().error('Failed to fetch feature configs - %s', error) }) diff --git a/packages/core/src/codewhisperer/service/recommendationHandler.ts b/packages/core/src/codewhisperer/service/recommendationHandler.ts index 99baf680d0a..00d1f3254a5 100644 --- a/packages/core/src/codewhisperer/service/recommendationHandler.ts +++ b/packages/core/src/codewhisperer/service/recommendationHandler.ts @@ -50,23 +50,38 @@ import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' * It does not contain UI/UX related logic */ -// below commands override VS Code inline completion commands -const prevCommand = Commands.declare('editor.action.inlineSuggest.showPrevious', () => async () => { - await RecommendationHandler.instance.showRecommendation(-1) -}) -const nextCommand = Commands.declare('editor.action.inlineSuggest.showNext', () => async () => { - await RecommendationHandler.instance.showRecommendation(1) -}) - -const rejectCommand = Commands.declare('aws.amazonq.rejectCodeSuggestion', () => async () => { - telemetry.record({ - traceId: TelemetryHelper.instance.traceId, +/** + * Commands as a level of indirection so that declare doesn't intercept any registrations for the + * language server implementation. + * + * Otherwise you'll get: + * "Unable to launch amazonq language server: Command "aws.amazonq.rejectCodeSuggestion" has already been declared by the Toolkit" + */ +function createCommands() { + // below commands override VS Code inline completion commands + const prevCommand = Commands.declare('editor.action.inlineSuggest.showPrevious', () => async () => { + await RecommendationHandler.instance.showRecommendation(-1) + }) + const nextCommand = Commands.declare('editor.action.inlineSuggest.showNext', () => async () => { + await RecommendationHandler.instance.showRecommendation(1) }) - await vscode.commands.executeCommand('editor.action.inlineSuggest.hide') - RecommendationHandler.instance.reportUserDecisions(-1) - await Commands.tryExecute('aws.amazonq.refreshAnnotation') -}) + const rejectCommand = Commands.declare('aws.amazonq.rejectCodeSuggestion', () => async () => { + telemetry.record({ + traceId: TelemetryHelper.instance.traceId, + }) + + await vscode.commands.executeCommand('editor.action.inlineSuggest.hide') + RecommendationHandler.instance.reportUserDecisions(-1) + await Commands.tryExecute('aws.amazonq.refreshAnnotation') + }) + + return { + prevCommand, + nextCommand, + rejectCommand, + } +} const lock = new AsyncLock({ maxPending: 1 }) @@ -579,6 +594,7 @@ export class RecommendationHandler { // They are subscribed when suggestion starts and disposed when suggestion is accepted/rejected // to avoid impacting other plugins or user who uses this API private registerCommandOverrides() { + const { prevCommand, nextCommand, rejectCommand } = createCommands() this.prev = prevCommand.register() this.next = nextCommand.register() this.reject = rejectCommand.register() diff --git a/packages/core/src/dev/activation.ts b/packages/core/src/dev/activation.ts index 16b5d7e53ad..8ce0f6aab11 100644 --- a/packages/core/src/dev/activation.ts +++ b/packages/core/src/dev/activation.ts @@ -25,6 +25,7 @@ import { NotificationsController } from '../notifications/controller' import { DevNotificationsState } from '../notifications/types' import { QuickPickItem } from 'vscode' import { ChildProcess } from '../shared/utilities/processUtils' +import { WorkspaceLspInstaller } from '../amazonq/lsp/workspaceInstaller' interface MenuOption { readonly label: string @@ -450,6 +451,12 @@ const resettableFeatures: readonly ResettableFeature[] = [ detail: 'Resets memory/global state for the notifications panel (includes dismissed, onReceive).', executor: resetNotificationsState, }, + { + name: 'workspace lsp', + label: 'Download Lsp ', + detail: 'Resets workspace LSP', + executor: resetWorkspaceLspDownload, + }, ] as const // TODO this is *somewhat* similar to `openStorageFromInput`. If we need another @@ -538,6 +545,10 @@ async function resetNotificationsState() { await targetNotificationsController.reset() } +async function resetWorkspaceLspDownload() { + await new WorkspaceLspInstaller().resolve() +} + async function editNotifications() { const storageKey = 'aws.notifications.dev' const current = globalState.get(storageKey) ?? {} diff --git a/packages/core/src/shared/crypto.ts b/packages/core/src/shared/crypto.ts index 808f5730d81..647525098a7 100644 --- a/packages/core/src/shared/crypto.ts +++ b/packages/core/src/shared/crypto.ts @@ -24,11 +24,15 @@ import { isWeb } from './extensionGlobals' export function randomUUID(): `${string}-${string}-${string}-${string}-${string}` { + return getCrypto().randomUUID() +} + +function getCrypto() { if (isWeb()) { - return globalThis.crypto.randomUUID() + return globalThis.crypto } - return require('crypto').randomUUID() + return require('crypto') } /** @@ -54,3 +58,10 @@ export function truncateUuid(uuid: string) { const cleanedUUID = uuid.replace(/-/g, '') return `${cleanedUUID.substring(0, 4)}...${cleanedUUID.substring(cleanedUUID.length - 4)}` } + +export function createHash(algorithm: string, contents: string | Buffer): string { + const crypto = getCrypto() + const hash = crypto.createHash(algorithm) + hash.update(contents) + return `${algorithm}:${hash.digest('hex')}` +} diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index 26fc0490ee8..7cec122acaf 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -16,6 +16,7 @@ import { CodeWhispererStreamingServiceException } from '@amzn/codewhisperer-stre import { driveLetterRegex } from './utilities/pathUtils' import { getLogger } from './logger/logger' import { crashMonitoringDirName } from './constants' +import { RequestCancelledError } from './request' let _username = 'unknown-user' let _isAutomation = false @@ -618,7 +619,11 @@ function hasTime(error: Error): error is typeof error & { time: Date } { } export function isUserCancelledError(error: unknown): boolean { - return CancellationError.isUserCancelled(error) || (error instanceof ToolkitError && error.cancelled) + return ( + CancellationError.isUserCancelled(error) || + (error instanceof ToolkitError && error.cancelled) || + error instanceof RequestCancelledError + ) } /** diff --git a/packages/core/src/shared/extensionUtilities.ts b/packages/core/src/shared/extensionUtilities.ts index 1e134aaa18f..184ae028cc1 100644 --- a/packages/core/src/shared/extensionUtilities.ts +++ b/packages/core/src/shared/extensionUtilities.ts @@ -48,6 +48,13 @@ export function productName() { return isAmazonQ() ? 'Amazon Q' : `${getIdeProperties().company} Toolkit` } +/** Gets the client name stored in oidc */ +export const oidcClientName = once(_oidcClientName) +function _oidcClientName() { + const companyName = getIdeProperties().company + return isCloud9() ? `${companyName} Cloud9` : `${companyName} IDE Extensions for VSCode` +} + export const getExtensionId = () => { return isAmazonQ() ? VSCODE_EXTENSION_ID.amazonq : VSCODE_EXTENSION_ID.awstoolkit } diff --git a/packages/core/src/shared/fs/fs.ts b/packages/core/src/shared/fs/fs.ts index 1c7132648bc..ce317a513df 100644 --- a/packages/core/src/shared/fs/fs.ts +++ b/packages/core/src/shared/fs/fs.ts @@ -16,7 +16,7 @@ import { isPermissionsError, scrubNames, } from '../errors' -import globals from '../extensionGlobals' +import globals, { isWeb } from '../extensionGlobals' import { isWin } from '../vscode/env' import { resolvePath } from '../utilities/pathUtils' import crypto from 'crypto' @@ -543,6 +543,43 @@ export class FileSystem { return this.#homeDir } + /** + * Gets the application cache folder for the current platform + * + * Follows the cache_dir convention outlined in https://crates.io/crates/dirs + */ + getCacheDir(): string { + if (isWeb()) { + const homeDir = this.#homeDir + if (!homeDir) { + throw new ToolkitError('Web home directory not found', { + code: 'WebHomeDirectoryNotFound', + }) + } + return homeDir + } + switch (process.platform) { + case 'darwin': { + return _path.join(this.getUserHomeDir(), 'Library/Caches') + } + case 'win32': { + const localAppData = process.env.LOCALAPPDATA + if (!localAppData) { + throw new ToolkitError('LOCALAPPDATA environment variable not set', { + code: 'LocalAppDataNotFound', + }) + } + return localAppData + } + case 'linux': { + return _path.join(this.getUserHomeDir(), '.cache') + } + default: { + throw new Error(`Unsupported platform: ${process.platform}. Expected 'darwin', 'win32', 'linux'.`) + } + } + } + /** * Gets the (cached) username for this session, or "webuser" in web-mode, or "unknown-user" if * a username could not be resolved. diff --git a/packages/core/src/shared/globalState.ts b/packages/core/src/shared/globalState.ts index 80f4148d435..26b183de3be 100644 --- a/packages/core/src/shared/globalState.ts +++ b/packages/core/src/shared/globalState.ts @@ -45,6 +45,8 @@ export type globalKey = | 'aws.toolkit.amazonq.dismissed' | 'aws.toolkit.amazonqInstall.dismissed' | 'aws.amazonq.workspaceIndexToggleOn' + | 'aws.toolkit.lsp.versions' + | 'aws.toolkit.lsp.manifest' | 'aws.amazonq.customization.overrideV2' // Deprecated/legacy names. New keys should start with "aws.". | '#sessionCreationDates' // Legacy name from `ssoAccessTokenProvider.ts`. diff --git a/packages/core/src/shared/index.ts b/packages/core/src/shared/index.ts index 4e2b770c1c7..c713be1fba8 100644 --- a/packages/core/src/shared/index.ts +++ b/packages/core/src/shared/index.ts @@ -13,12 +13,12 @@ export { activate as activateLogger } from './logger/activation' export { activate as activateTelemetry } from './telemetry/activation' export { DefaultAwsContext } from './awsContext' export { DefaultAWSClientBuilder, ServiceOptions } from './awsClientBuilder' -export { Settings, DevSettings } from './settings' +export { Settings, Experiments, DevSettings } from './settings' export * from './extensionUtilities' export * from './extensionStartup' export { RegionProvider } from './regions/regionProvider' export { Commands } from './vscode/commands2' -export { getMachineId } from './vscode/env' +export { getMachineId, getServiceEnvVarConfig } from './vscode/env' export { getLogger } from './logger/logger' export { activateExtension, openUrl } from './utilities/vsCodeUtils' export { waitUntil, sleep, Timeout } from './utilities/timeoutUtils' @@ -62,4 +62,13 @@ export { i18n } from './i18n-helper' export * from './icons' export * as textDocumentUtil from './utilities/textDocumentUtilities' export { TabTypeDataMap } from '../amazonq/webview/ui/tabs/constants' +export * from './lsp/manifestResolver' +export * from './lsp/lspResolver' +export * from './lsp/types' +export * from './lsp/utils/setupStage' +export * from './lsp/utils/cleanup' +export { default as request } from './request' +export * from './lsp/utils/platform' +export * as processUtils from './utilities/processUtils' +export * as BaseLspInstaller from './lsp/baseLspInstaller' export * as collectionUtil from './utilities/collectionUtils' diff --git a/packages/core/src/shared/logger/logger.ts b/packages/core/src/shared/logger/logger.ts index 5dab76ea6e3..3338602685a 100644 --- a/packages/core/src/shared/logger/logger.ts +++ b/packages/core/src/shared/logger/logger.ts @@ -11,9 +11,12 @@ export type LogTopic = | 'notifications' | 'test' | 'childProcess' - | 'unknown' + | 'lsp' + | 'amazonqWorkspaceLsp' + | 'amazonqLsp' | 'chat' | 'stepfunctions' + | 'unknown' class ErrorLog { constructor( diff --git a/packages/core/src/shared/lsp/baseLspInstaller.ts b/packages/core/src/shared/lsp/baseLspInstaller.ts new file mode 100644 index 00000000000..4f3bcdf57e7 --- /dev/null +++ b/packages/core/src/shared/lsp/baseLspInstaller.ts @@ -0,0 +1,65 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as nodePath from 'path' +import vscode from 'vscode' +import { LspConfig } from '../../amazonq/lsp/config' +import { LanguageServerResolver } from './lspResolver' +import { ManifestResolver } from './manifestResolver' +import { LspResolution, ResourcePaths } from './types' +import { cleanLspDownloads } from './utils/cleanup' +import { Range } from 'semver' +import { getLogger } from '../logger/logger' +import type { Logger, LogTopic } from '../logger/logger' + +export abstract class BaseLspInstaller { + private logger: Logger + + constructor( + protected config: LspConfig, + loggerName: Extract + ) { + this.logger = getLogger(loggerName) + } + + async resolve(): Promise> { + const { id, manifestUrl, supportedVersions, path } = this.config + if (path) { + const overrideMsg = `Using language server override location: ${path}` + this.logger.info(overrideMsg) + void vscode.window.showInformationMessage(overrideMsg) + return { + assetDirectory: path, + location: 'override', + version: '0.0.0', + resourcePaths: this.resourcePaths(), + } + } + + const manifest = await new ManifestResolver(manifestUrl, id).resolve() + const installationResult = await new LanguageServerResolver( + manifest, + id, + new Range(supportedVersions, { + includePrerelease: true, + }) + ).resolve() + + const assetDirectory = installationResult.assetDirectory + + await this.postInstall(assetDirectory) + + const deletedVersions = await cleanLspDownloads(manifest.versions, nodePath.dirname(assetDirectory)) + this.logger.debug(`cleaning old LSP versions deleted ${deletedVersions.length} versions`) + + return { + ...installationResult, + resourcePaths: this.resourcePaths(assetDirectory), + } + } + + protected abstract postInstall(assetDirectory: string): Promise + protected abstract resourcePaths(assetDirectory?: string): T +} diff --git a/packages/core/src/shared/languageServer/languageModelCache.ts b/packages/core/src/shared/lsp/languageModelCache.ts similarity index 100% rename from packages/core/src/shared/languageServer/languageModelCache.ts rename to packages/core/src/shared/lsp/languageModelCache.ts diff --git a/packages/core/src/shared/lsp/lspResolver.ts b/packages/core/src/shared/lsp/lspResolver.ts new file mode 100644 index 00000000000..5d19e35f836 --- /dev/null +++ b/packages/core/src/shared/lsp/lspResolver.ts @@ -0,0 +1,433 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from '../fs/fs' +import { ToolkitError } from '../errors' +import * as semver from 'semver' +import * as path from 'path' +import { FileType } from 'vscode' +import AdmZip from 'adm-zip' +import { TargetContent, logger, LspResult, LspVersion, Manifest } from './types' +import { createHash } from '../crypto' +import { lspSetupStage, StageResolver, tryStageResolvers } from './utils/setupStage' +import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher' +import { showMessageWithCancel } from '../../shared/utilities/messages' +import { Timeout } from '../utilities/timeoutUtils' + +// max timeout for downloading remote LSP assets progress, the lowest possible is 3000, bounded by httpResourceFetcher's waitUntil +const remoteDownloadTimeout = 5000 + +export class LanguageServerResolver { + constructor( + private readonly manifest: Manifest, + private readonly lsName: string, + private readonly versionRange: semver.Range, + private readonly _defaultDownloadFolder?: string + ) {} + + /** + * Downloads and sets up the Language Server, attempting different locations in order: + * 1. Local cache + * 2. Remote download + * 3. Fallback version + * @throws ToolkitError if no compatible version can be found + */ + async resolve() { + function getServerVersion(result: LspResult) { + return { + languageServerVersion: result.version, + } + } + try { + const latestVersion = this.latestCompatibleLspVersion() + const targetContents = this.getLSPTargetContents(latestVersion) + const cacheDirectory = this.getDownloadDirectory(latestVersion.serverVersion) + + const serverResolvers: StageResolver[] = [ + { + resolve: async () => await this.getLocalServer(cacheDirectory, latestVersion, targetContents), + telemetryMetadata: { id: this.lsName, languageServerLocation: 'cache' }, + }, + { + resolve: async () => await this.fetchRemoteServer(cacheDirectory, latestVersion, targetContents), + telemetryMetadata: { id: this.lsName, languageServerLocation: 'remote' }, + }, + { + resolve: async () => await this.getFallbackServer(latestVersion), + telemetryMetadata: { id: this.lsName, languageServerLocation: 'fallback' }, + }, + ] + + return await tryStageResolvers('getServer', serverResolvers, getServerVersion) + } finally { + logger.info(`Finished setting up LSP server`) + } + } + + private async getFallbackServer(latestVersion: LspVersion): Promise { + const fallbackDirectory = await this.getFallbackDir(latestVersion.serverVersion) + if (!fallbackDirectory) { + throw new ToolkitError('Unable to find a compatible version of the Language Server', { + code: 'IncompatibleVersion', + }) + } + + const version = path.basename(fallbackDirectory) + logger.info( + `Unable to install ${this.lsName} language server v${latestVersion.serverVersion}. Launching a previous version from ${fallbackDirectory}` + ) + + return { + location: 'fallback', + version: version, + assetDirectory: fallbackDirectory, + } + } + + /** + * Show a toast notification with progress bar for lsp remote downlaod + * Returns a timeout to be passed down into httpFetcher to handle user cancellation + */ + private async showDownloadProgress() { + const timeout = new Timeout(remoteDownloadTimeout) + await showMessageWithCancel(`Downloading '${this.lsName}' language server`, timeout) + return timeout + } + + private async fetchRemoteServer( + cacheDirectory: string, + latestVersion: LspVersion, + targetContents: TargetContent[] + ): Promise { + const timeout = await this.showDownloadProgress() + try { + if (await this.downloadRemoteTargetContent(targetContents, latestVersion.serverVersion, timeout)) { + return { + location: 'remote', + version: latestVersion.serverVersion, + assetDirectory: cacheDirectory, + } + } else { + throw new ToolkitError('Failed to download server from remote', { code: 'RemoteDownloadFailed' }) + } + } finally { + timeout.dispose() + } + } + + private async getLocalServer( + cacheDirectory: string, + latestVersion: LspVersion, + targetContents: TargetContent[] + ): Promise { + if (await this.hasValidLocalCache(cacheDirectory, targetContents)) { + return { + location: 'cache', + version: latestVersion.serverVersion, + assetDirectory: cacheDirectory, + } + } else { + // Delete the cached directory since it's invalid + if (await fs.existsDir(cacheDirectory)) { + await fs.delete(cacheDirectory, { + recursive: true, + }) + } + throw new ToolkitError('Failed to retrieve server from cache', { code: 'InvalidCache' }) + } + } + + /** + * Get all of the compatible language server versions from the manifest + */ + private compatibleManifestLspVersion() { + return this.manifest.versions.filter((x) => this.isCompatibleVersion(x)) + } + + /** + * Returns the path to the most compatible cached LSP version that can serve as a fallback + **/ + private async getFallbackDir(version: string) { + const compatibleLspVersions = this.compatibleManifestLspVersion() + + // determine all folders containing lsp versions in the fallback parent folder + const cachedVersions = (await fs.readdir(this.defaultDownloadFolder())) + .filter(([_, filetype]) => filetype === FileType.Directory) + .map(([pathName, _]) => semver.parse(pathName)) + .filter((ver): ver is semver.SemVer => ver !== null) + .map((x) => x.version) + + const expectedVersion = semver.parse(version) + if (!expectedVersion) { + return undefined + } + + const sortedCachedLspVersions = compatibleLspVersions + .filter((v) => this.isValidCachedVersion(v, cachedVersions, expectedVersion)) + .sort((a, b) => semver.compare(b.serverVersion, a.serverVersion)) + + const fallbackDir = ( + await Promise.all(sortedCachedLspVersions.map((ver) => this.getValidLocalCacheDirectory(ver))) + ).filter((v) => v !== undefined) + return fallbackDir.length > 0 ? fallbackDir[0] : undefined + } + + /** + * Validate the local cache directory of the given lsp version (matches expected hash) + * If valid return cache directory, else return undefined + */ + private async getValidLocalCacheDirectory(version: LspVersion) { + const targetContents = this.getTargetContents(version) + if (targetContents === undefined || targetContents.length === 0) { + return undefined + } + + const cacheDir = this.getDownloadDirectory(version.serverVersion) + const hasValidCache = await this.hasValidLocalCache(cacheDir, targetContents) + + return hasValidCache ? cacheDir : undefined + } + + /** + * Determines if a cached LSP version is valid for use as a fallback. + * A version is considered valid if it exists in the cache and is less than + * or equal to the expected version. + */ + private isValidCachedVersion(version: LspVersion, cachedVersions: string[], expectedVersion: semver.SemVer) { + const serverVersion = semver.parse(version.serverVersion) as semver.SemVer + return cachedVersions.includes(serverVersion.version) && semver.lte(serverVersion, expectedVersion) + } + + /** + * Download and unzip all of the contents into the download directory + * + * @returns + * true, if all of the contents were successfully downloaded and unzipped + * false, if any of the contents failed to download or unzip + */ + private async downloadRemoteTargetContent(contents: TargetContent[], version: string, timeout: Timeout) { + const downloadDirectory = this.getDownloadDirectory(version) + + if (!(await fs.existsDir(downloadDirectory))) { + await fs.mkdir(downloadDirectory) + } + + const fetchTasks = contents.map(async (content) => { + return { + res: await new HttpResourceFetcher(content.url, { + showUrl: true, + timeout: timeout, + throwOnError: true, + }).get(), + hash: content.hashes[0], + filename: content.filename, + } + }) + const fetchResults = await Promise.all(fetchTasks) + const verifyTasks = fetchResults + .filter((fetchResult) => fetchResult.res && fetchResult.res.ok && fetchResult.res.body) + .flatMap(async (fetchResult) => { + const arrBuffer = await fetchResult.res!.arrayBuffer() + const data = Buffer.from(arrBuffer) + + const hash = createHash('sha384', data) + if (hash === fetchResult.hash) { + return [{ filename: fetchResult.filename, data }] + } + return [] + }) + if (verifyTasks.length !== contents.length) { + return false + } + + const filesToDownload = await lspSetupStage('validate', async () => (await Promise.all(verifyTasks)).flat()) + + for (const file of filesToDownload) { + await fs.writeFile(`${downloadDirectory}/${file.filename}`, file.data) + } + + return this.extractZipFilesFromRemote(downloadDirectory) + } + + private async extractZipFilesFromRemote(downloadDirectory: string) { + // Find all the zips + const zips = (await fs.readdir(downloadDirectory)) + .filter(([fileName, _]) => fileName.endsWith('.zip')) + .map(([fileName, _]) => `${downloadDirectory}/${fileName}`) + + if (zips.length === 0) { + return true + } + + return this.copyZipContents(zips) + } + + private async hasValidLocalCache(localCacheDirectory: string, targetContents: TargetContent[]) { + // check if the zips are still at the present location + const results = await Promise.all( + targetContents.map((content) => { + const path = `${localCacheDirectory}/${content.filename}` + return fs.existsFile(path) + }) + ) + + const allFilesExist = results.every(Boolean) + return allFilesExist && this.ensureUnzippedFoldersMatchZip(localCacheDirectory, targetContents) + } + + /** + * Ensures zip files in cache have an unzipped folder of the same name + * with the same content files (by name) + * + * @returns + * false, if any of the unzipped folder don't match zip contents (by name) + */ + private ensureUnzippedFoldersMatchZip(localCacheDirectory: string, targetContents: TargetContent[]) { + const zipPaths = targetContents + .filter((x) => x.filename.endsWith('.zip')) + .map((y) => `${localCacheDirectory}/${y.filename}`) + + if (zipPaths.length === 0) { + return true + } + + return this.copyZipContents(zipPaths) + } + + /** + * Copies all the contents from zip into the directory + * + * @returns + * false, if any of the unzips fails + */ + private copyZipContents(zips: string[]) { + const unzips = zips.map((zip) => { + try { + // attempt to unzip + const zipFile = new AdmZip(zip) + const extractPath = zip.replace('.zip', '') + + /** + * Avoid overwriting existing files during extraction to prevent file corruption. + * On Mac ARM64 when a language server is already running in one VS Code window, + * attempting to extract and overwrite its files from another window can cause + * the newly started language server to crash with 'EXC_CRASH (SIGKILL (Code Signature Invalid))'. + */ + zipFile.extractAllTo(extractPath, false) + } catch (e) { + return false + } + return true + }) + + // make sure every one completed successfully + return unzips.every(Boolean) + } + + /** + * Parses the toolkit lsp version object retrieved from the version manifest to determine + * lsp contents + */ + private getLSPTargetContents(version: LspVersion) { + const lspTarget = this.getCompatibleLspTarget(version) + if (!lspTarget) { + throw new ToolkitError("No language server target found matching the system's architecture and platform") + } + + const targetContents = lspTarget.contents + if (!targetContents) { + throw new ToolkitError('No matching target contents found') + } + return targetContents + } + + /** + * Get the latest language server version matching the toolkit compatible version range, + * not de-listed and contains the required target contents: + * architecture, platform and files + */ + private latestCompatibleLspVersion() { + if (this.manifest === null) { + throw new ToolkitError('No valid manifest') + } + + const latestCompatibleVersion = + this.manifest.versions + .filter((ver) => this.isCompatibleVersion(ver) && this.hasRequiredTargetContent(ver)) + .sort((a, b) => semver.compare(b.serverVersion, a.serverVersion))[0] ?? undefined + + if (latestCompatibleVersion === undefined) { + // TODO fix these error range names + throw new ToolkitError( + `Unable to find a language server that satifies one or more of these conditions: version in range [${this.versionRange.range}], matching system's architecture and platform` + ) + } + + return latestCompatibleVersion + } + + /** + * Determine if the given lsp version is toolkit compatible + * i.e. in version range and not de-listed + */ + private isCompatibleVersion(version: LspVersion) { + // invalid version + if (semver.parse(version.serverVersion) === null) { + return false + } + + return ( + semver.satisfies(version.serverVersion, this.versionRange, { + includePrerelease: true, + }) && !version.isDelisted + ) + } + + /** + * Validates the lsp version contains the required toolkit compatible contents: + * architecture, platform and file + */ + private hasRequiredTargetContent(version: LspVersion) { + const targetContents = this.getTargetContents(version) + return targetContents !== undefined && targetContents.length > 0 + } + + /** + * Returns the target contents of the lsp version that contains the required + * toolkit compatible contents: architecture, platform and file + */ + private getTargetContents(version: LspVersion) { + const target = this.getCompatibleLspTarget(version) + return target?.contents + } + + /** + * Retrives the lsp target matching the user's system architecture and platform + * from the language server version object + */ + private getCompatibleLspTarget(version: LspVersion) { + // TODO make this web friendly + // TODO make this fully support windows + + // Workaround: Manifest platform field is `windows`, whereas node returns win32 + const platform = process.platform === 'win32' ? 'windows' : process.platform + const arch = process.arch + return version.targets.find((x) => x.arch === arch && x.platform === platform) + } + + // lazy calls to `getApplicationSupportFolder()` to avoid failure on windows. + public static get defaultDir() { + return path.join(fs.getCacheDir(), `aws/toolkits/language-servers`) + } + + defaultDownloadFolder() { + return path.join(LanguageServerResolver.defaultDir, `${this.lsName}`) + } + + private getDownloadDirectory(version: string) { + const directory = this._defaultDownloadFolder ?? this.defaultDownloadFolder() + return `${directory}/${version}` + } +} diff --git a/packages/core/src/shared/lsp/manifestResolver.ts b/packages/core/src/shared/lsp/manifestResolver.ts new file mode 100644 index 00000000000..e2b89a0120b --- /dev/null +++ b/packages/core/src/shared/lsp/manifestResolver.ts @@ -0,0 +1,159 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { getLogger } from '../logger/logger' +import { ToolkitError } from '../errors' +import { Timeout } from '../utilities/timeoutUtils' +import globals from '../extensionGlobals' +import { Manifest } from './types' +import { StageResolver, tryStageResolvers } from './utils/setupStage' +import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher' +import * as localizedText from '../localizedText' +import { AmazonQPromptSettings, amazonQPrompts } from '../settings' + +const logger = getLogger('lsp') + +interface StorageManifest { + etag: string + content: string +} + +type ManifestStorage = Record + +export const manifestStorageKey = 'aws.toolkit.lsp.manifest' +const manifestTimeoutMs = 15000 + +export class ManifestResolver { + constructor( + private readonly manifestURL: string, + private readonly lsName: string + ) {} + + /** + * Fetches the latest manifest, falling back to local cache on failure + */ + async resolve(): Promise { + const resolvers: StageResolver[] = [ + { + resolve: async () => await this.fetchRemoteManifest(), + telemetryMetadata: { id: this.lsName, manifestLocation: 'remote' }, + }, + { + resolve: async () => await this.getLocalManifest(), + telemetryMetadata: { id: this.lsName, manifestLocation: 'cache' }, + }, + ] + + return await tryStageResolvers('getManifest', resolvers, extractMetadata) + + function extractMetadata(r: Manifest) { + return { + manifestSchemaVersion: r.manifestSchemaVersion, + } + } + } + + private async fetchRemoteManifest(): Promise { + const resp = await new HttpResourceFetcher(this.manifestURL, { + showUrl: true, + timeout: new Timeout(manifestTimeoutMs), + }).getNewETagContent(this.getEtag()) + + if (!resp.content) { + throw new ToolkitError( + `New content was not downloaded; fallback to the locally stored "${this.lsName}" manifest` + ) + } + + const manifest = this.parseManifest(resp.content) + await this.saveManifest(resp.eTag, resp.content) + await this.checkDeprecation(manifest) + manifest.location = 'remote' + return manifest + } + + private async getLocalManifest(): Promise { + logger.info(`Failed to download latest "${this.lsName}" manifest. Falling back to local manifest.`) + const storage = this.getStorage() + const manifestData = storage[this.lsName] + + if (!manifestData?.content) { + throw new ToolkitError(`Failed to download "${this.lsName}" manifest and no local manifest found.`) + } + + const manifest = this.parseManifest(manifestData.content) + await this.checkDeprecation(manifest) + manifest.location = 'cache' + return manifest + } + + private parseManifest(content: string): Manifest { + try { + return JSON.parse(content) as Manifest + } catch (error) { + throw new ToolkitError( + `Failed to parse "${this.lsName}" manifest: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } + + /** + * Check if the current manifest is deprecated. + * If yes and user hasn't muted this notification, shows a toast message with two buttons: + * - OK: close and do nothing + * - Don't Show Again: Update suppressed prompt setting so the deprecation message is never shown for this manifest. + * @param manifest + */ + private async checkDeprecation(manifest: Manifest): Promise { + const prompts = AmazonQPromptSettings.instance + const lspId = `${this.lsName}LspManifestMessage` as keyof typeof amazonQPrompts + + // Sanity check, if the lsName is changed then we also need to update the prompt keys in settings-amazonq.gen + if (!(lspId in amazonQPrompts)) { + logger.error(`LSP ID "${lspId}" not found in amazonQPrompts.`) + return + } + + if (!manifest.isManifestDeprecated) { + // In case we got an new url, make sure the prompt is re-enabled for active manifests + await prompts.enablePrompt(lspId) + return + } + + const deprecationMessage = `"${this.lsName}" manifest is deprecated. No future updates will be available.` + logger.info(deprecationMessage) + + if (prompts.isPromptEnabled(lspId)) { + void vscode.window + .showInformationMessage(deprecationMessage, localizedText.ok, localizedText.dontShow) + .then(async (button) => { + if (button === localizedText.dontShow) { + await prompts.disablePrompt(lspId) + } + }) + } + } + + private async saveManifest(etag: string, content: string): Promise { + const storage = this.getStorage() + + globals.globalState.tryUpdate(manifestStorageKey, { + ...storage, + [this.lsName]: { + etag, + content, + }, + }) + } + + private getEtag(): string | undefined { + return this.getStorage()[this.lsName]?.etag + } + + private getStorage(): ManifestStorage { + return globals.globalState.tryGet(manifestStorageKey, Object, {}) + } +} diff --git a/packages/core/src/shared/lsp/types.ts b/packages/core/src/shared/lsp/types.ts new file mode 100644 index 00000000000..21bd8ff4e77 --- /dev/null +++ b/packages/core/src/shared/lsp/types.ts @@ -0,0 +1,58 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLogger } from '../logger/logger' +import { LanguageServerLocation, ManifestLocation } from '../telemetry/telemetry' + +export const logger = getLogger('lsp') + +export interface LspResult { + location: LanguageServerLocation + version: string + assetDirectory: string +} + +export interface ResourcePaths { + lsp: string + node: string +} + +export interface LspResolution extends LspResult { + resourcePaths: T +} + +export interface TargetContent { + filename: string + url: string + hashes: string[] + bytes: number + serverVersion?: string +} + +export interface Target { + platform: string + arch: string + contents: TargetContent[] +} + +export interface LspVersion { + serverVersion: string + isDelisted: boolean + targets: Target[] +} + +export interface Manifest { + manifestSchemaVersion: string + artifactId: string + artifactDescription: string + isManifestDeprecated: boolean + versions: LspVersion[] + location?: ManifestLocation +} + +export interface VersionRange { + start: number + end: number +} diff --git a/packages/core/src/shared/lsp/utils/cleanup.ts b/packages/core/src/shared/lsp/utils/cleanup.ts new file mode 100644 index 00000000000..83b58e2bb8f --- /dev/null +++ b/packages/core/src/shared/lsp/utils/cleanup.ts @@ -0,0 +1,48 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path' +import { LspVersion } from '../types' +import { fs } from '../../../shared/fs/fs' +import { partition } from '../../../shared/utilities/tsUtils' +import { parse, sort } from 'semver' + +export async function getDownloadedVersions(installLocation: string) { + return (await fs.readdir(installLocation)).filter((x) => parse(x[0]) !== null).map(([f, _], __) => f) +} + +function isDelisted(manifestVersions: LspVersion[], targetVersion: string): boolean { + return manifestVersions.find((v) => v.serverVersion === targetVersion)?.isDelisted ?? false +} + +/** + * Delete all delisted versions and keep the two newest versions that remain + * @param manifestVersions + * @param downloadDirectory + * @returns array of deleted versions. + */ +export async function cleanLspDownloads(manifestVersions: LspVersion[], downloadDirectory: string): Promise { + const downloadedVersions = await getDownloadedVersions(downloadDirectory) + const [delistedVersions, remainingVersions] = partition(downloadedVersions, (v: string) => + isDelisted(manifestVersions, v) + ) + const deletedVersions: string[] = [] + + for (const v of delistedVersions) { + await fs.delete(path.join(downloadDirectory, v), { force: true, recursive: true }) + deletedVersions.push(v) + } + + if (remainingVersions.length <= 2) { + return deletedVersions + } + + for (const v of sort(remainingVersions).slice(0, -2)) { + await fs.delete(path.join(downloadDirectory, v), { force: true, recursive: true }) + deletedVersions.push(v) + } + + return deletedVersions +} diff --git a/packages/core/src/shared/lsp/utils/platform.ts b/packages/core/src/shared/lsp/utils/platform.ts new file mode 100644 index 00000000000..4d242c3833e --- /dev/null +++ b/packages/core/src/shared/lsp/utils/platform.ts @@ -0,0 +1,58 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ToolkitError } from '../../errors' +import { ChildProcess } from '../../utilities/processUtils' +import { isDebugInstance } from '../../vscode/env' + +export function getNodeExecutableName(): string { + return process.platform === 'win32' ? 'node.exe' : 'node' +} + +/** + * Get a json payload that will be sent to the language server, who is waiting to know what the encryption key is. + * Code reference: https://github.com/aws/language-servers/blob/7da212185a5da75a72ce49a1a7982983f438651a/client/vscode/src/credentialsActivation.ts#L77 + */ +function getEncryptionInit(key: Buffer): string { + const request = { + version: '1.0', + mode: 'JWT', + key: key.toString('base64'), + } + return JSON.stringify(request) + '\n' +} + +export function createServerOptions({ + encryptionKey, + executable, + serverModule, + execArgv, +}: { + encryptionKey: Buffer + executable: string + serverModule: string + execArgv: string[] +}) { + return async () => { + const args = [serverModule, ...execArgv] + if (isDebugInstance()) { + args.unshift('--inspect=6080') + } + const lspProcess = new ChildProcess(executable, args) + + // this is a long running process, awaiting it will never resolve + void lspProcess.run() + + // share an encryption key using stdin + // follow same practice of DEXP LSP server + await lspProcess.send(getEncryptionInit(encryptionKey)) + + const proc = lspProcess.proc() + if (!proc) { + throw new ToolkitError('Language Server process was not started') + } + return proc + } +} diff --git a/packages/core/src/shared/languageServer/utils/runner.ts b/packages/core/src/shared/lsp/utils/runner.ts similarity index 100% rename from packages/core/src/shared/languageServer/utils/runner.ts rename to packages/core/src/shared/lsp/utils/runner.ts diff --git a/packages/core/src/shared/lsp/utils/setupStage.ts b/packages/core/src/shared/lsp/utils/setupStage.ts new file mode 100644 index 00000000000..cd9dcfa319a --- /dev/null +++ b/packages/core/src/shared/lsp/utils/setupStage.ts @@ -0,0 +1,66 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { LanguageServerSetup, LanguageServerSetupStage, telemetry } from '../../telemetry/telemetry' +import { tryFunctions } from '../../utilities/tsUtils' + +/** + * Runs the designated stage within a telemetry span and optionally uses the getMetadata extractor to record metadata from the result of the stage. + * @param stageName name of stage for telemetry. + * @param runStage stage to be run. + * @param getMetadata metadata extractor to be applied to result. + * @returns result of stage + */ +export async function lspSetupStage( + stageName: LanguageServerSetupStage, + runStage: () => Promise, + getMetadata?: MetadataExtractor +) { + return await telemetry.languageServer_setup.run(async (span) => { + span.record({ languageServerSetupStage: stageName }) + const result = await runStage() + if (getMetadata) { + span.record(getMetadata(result)) + } + return result + }) +} +/** + * Tries to resolve the result of a stage using the resolvers provided in order. The first one to succceed + * has its result returned, but all intermediate will emit telemetry. + * @param stageName name of stage to resolve. + * @param resolvers stage resolvers to try IN ORDER + * @param getMetadata function to be applied to result to extract necessary metadata for telemetry. + * @returns result of the first succesful resolver. + */ +export async function tryStageResolvers( + stageName: LanguageServerSetupStage, + resolvers: StageResolver[], + getMetadata: MetadataExtractor +) { + const fs = resolvers.map((resolver) => async () => { + return await lspSetupStage( + stageName, + async () => { + telemetry.record(resolver.telemetryMetadata) + const result = await resolver.resolve() + return result + }, + getMetadata + ) + }) + + return await tryFunctions(fs) +} + +/** + * A method that returns the result of a stage along with the default telemetry metadata to attach to the stage metric. + */ +export interface StageResolver { + resolve: () => Promise + telemetryMetadata: Partial +} + +type MetadataExtractor = (r: R) => Partial diff --git a/packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts b/packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts index 1a2300cdcb9..18cc696770f 100644 --- a/packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts +++ b/packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts @@ -8,6 +8,7 @@ import { getLogger, Logger } from '../logger/logger' import { ResourceFetcher } from './resourcefetcher' import { Timeout, CancelEvent, waitUntil } from '../utilities/timeoutUtils' import request, { RequestError } from '../request' +import { isUserCancelledError } from '../errors' type RequestHeaders = { eTag?: string; gZip?: boolean } @@ -22,6 +23,7 @@ export class HttpResourceFetcher implements ResourceFetcher { * @param {string} params.friendlyName If URL is not shown, replaces the URL with this text. * @param {Timeout} params.timeout Timeout token to abort/cancel the request. Similar to `AbortSignal`. * @param {number} params.retries The number of retries a get request should make if one fails + * @param {boolean} params.throwOnError True if we want to throw if there's request error, defaul to false */ public constructor( private readonly url: string, @@ -29,8 +31,11 @@ export class HttpResourceFetcher implements ResourceFetcher { showUrl: boolean friendlyName?: string timeout?: Timeout + throwOnError?: boolean } - ) {} + ) { + this.params.throwOnError = this.params.throwOnError ?? false + } /** * Returns the response of the resource, or undefined if the response failed could not be retrieved. @@ -82,6 +87,9 @@ export class HttpResourceFetcher implements ResourceFetcher { `Error downloading ${this.logText()}: %s`, error.message ?? error.code ?? error.response.statusText ?? error.response.status ) + if (this.params.throwOnError) { + throw error + } return undefined } } @@ -112,7 +120,10 @@ export class HttpResourceFetcher implements ResourceFetcher { timeout: 3000, interval: 100, backoff: 2, - retryOnFail: true, + retryOnFail: (error: Error) => { + // Retry unless the user intentionally canceled the operation. + return !isUserCancelledError(error) + }, } ) } diff --git a/packages/core/src/shared/settings-amazonq.gen.ts b/packages/core/src/shared/settings-amazonq.gen.ts index 25d99235c52..9447f43d12b 100644 --- a/packages/core/src/shared/settings-amazonq.gen.ts +++ b/packages/core/src/shared/settings-amazonq.gen.ts @@ -18,7 +18,9 @@ export const amazonqSettings = { "amazonQWelcomePage": {}, "amazonQSessionConfigurationMessage": {}, "minIdeVersion": {}, - "ssoCacheError": {} + "ssoCacheError": {}, + "AmazonQLspManifestMessage": {}, + "AmazonQ-WorkspaceLspManifestMessage":{} }, "amazonQ.showCodeWithReferences": {}, "amazonQ.allowFeatureDevelopmentToRunCodeAndTests": {}, diff --git a/packages/core/src/shared/settings-toolkit.gen.ts b/packages/core/src/shared/settings-toolkit.gen.ts index ea291352701..5cd9854b1fc 100644 --- a/packages/core/src/shared/settings-toolkit.gen.ts +++ b/packages/core/src/shared/settings-toolkit.gen.ts @@ -41,7 +41,9 @@ export const toolkitSettings = { "ssoCacheError": {} }, "aws.experiments": { - "jsonResourceModification": {} + "jsonResourceModification": {}, + "amazonqLSP": {}, + "amazonqChatLSP": {} }, "aws.resources.enabledResources": {}, "aws.lambda.recentlyUploaded": {}, diff --git a/packages/core/src/shared/settings.ts b/packages/core/src/shared/settings.ts index a486784fe14..273c61cabe0 100644 --- a/packages/core/src/shared/settings.ts +++ b/packages/core/src/shared/settings.ts @@ -671,6 +671,12 @@ export class AmazonQPromptSettings } } + public async enablePrompt(promptName: amazonQPromptName): Promise { + if (!this.isPromptEnabled(promptName)) { + await this.update(promptName, false) + } + } + public async disablePrompt(promptName: amazonQPromptName): Promise { if (this.isPromptEnabled(promptName)) { await this.update(promptName, true) @@ -757,6 +763,8 @@ const devSettings = { endpoints: Record(String, String), codecatalystService: Record(String, String), codewhispererService: Record(String, String), + amazonqLsp: Record(String, String), + amazonqWorkspaceLsp: Record(String, String), ssoCacheDirectory: String, autofillStartUrl: String, webAuth: Boolean, @@ -769,6 +777,8 @@ type ServiceClients = keyof ServiceTypeMap interface ServiceTypeMap { codecatalystService: codecatalyst.CodeCatalystConfig codewhispererService: codewhisperer.CodeWhispererConfig + amazonqLsp: object // type is provided inside of amazon q + amazonqWorkspaceLsp: object // type is provided inside of amazon q } /** diff --git a/packages/core/src/shared/utilities/processUtils.ts b/packages/core/src/shared/utilities/processUtils.ts index e7af78eaaea..c851cefb376 100644 --- a/packages/core/src/shared/utilities/processUtils.ts +++ b/packages/core/src/shared/utilities/processUtils.ts @@ -388,6 +388,10 @@ export class ChildProcess { return this.#processResult } + public proc(): proc.ChildProcess | undefined { + return this.#childProcess + } + public pid(): number { return this.#childProcess?.pid ?? -1 } diff --git a/packages/core/src/shared/utilities/timeoutUtils.ts b/packages/core/src/shared/utilities/timeoutUtils.ts index 7c1a6e521ca..8a8e0226879 100644 --- a/packages/core/src/shared/utilities/timeoutUtils.ts +++ b/packages/core/src/shared/utilities/timeoutUtils.ts @@ -223,11 +223,12 @@ interface WaitUntilOptions { readonly backoff?: number /** * Only retries when an error is thrown, otherwise returning the immediate result. + * Can also be a callback for conditional retry based on errors * - 'truthy' arg is ignored * - If the timeout is reached it throws the last error * - default: false */ - readonly retryOnFail?: boolean + readonly retryOnFail?: boolean | ((error: Error) => boolean) } export const waitUntilDefaultTimeout = 2000 @@ -247,6 +248,11 @@ export async function waitUntil( fn: () => Promise, options: WaitUntilOptions & { retryOnFail: false } ): Promise +export async function waitUntil( + fn: () => Promise, + options: WaitUntilOptions & { retryOnFail: (error: Error) => boolean } +): Promise + export async function waitUntil( fn: () => Promise, options: Omit @@ -267,6 +273,17 @@ export async function waitUntil(fn: () => Promise, options: WaitUntilOptio let elapsed: number = 0 let remaining = opt.timeout + // Internal helper to determine if we should retry + function shouldRetry(error: Error | undefined): boolean { + if (error === undefined) { + return typeof opt.retryOnFail === 'boolean' ? opt.retryOnFail : true + } + if (typeof opt.retryOnFail === 'function') { + return opt.retryOnFail(error) + } + return opt.retryOnFail + } + for (let i = 0; true; i++) { const start: number = globals.clock.Date.now() let result: T @@ -279,16 +296,16 @@ export async function waitUntil(fn: () => Promise, options: WaitUntilOptio result = await fn() } - if (opt.retryOnFail || (opt.truthy && result) || (!opt.truthy && result !== undefined)) { + if (shouldRetry(lastError) || (opt.truthy && result) || (!opt.truthy && result !== undefined)) { return result } } catch (e) { - if (!opt.retryOnFail) { + // Unlikely to hit this, but exists for typing + if (!(e instanceof Error)) { throw e } - // Unlikely to hit this, but exists for typing - if (!(e instanceof Error)) { + if (!shouldRetry(e)) { throw e } @@ -300,10 +317,9 @@ export async function waitUntil(fn: () => Promise, options: WaitUntilOptio // If the sleep will exceed the timeout, abort early if (elapsed + interval >= remaining) { - if (!opt.retryOnFail) { + if (!shouldRetry(lastError)) { return undefined } - throw lastError } diff --git a/packages/core/src/shared/utilities/tsUtils.ts b/packages/core/src/shared/utilities/tsUtils.ts index a76ee170c2e..9c89c406a6d 100644 --- a/packages/core/src/shared/utilities/tsUtils.ts +++ b/packages/core/src/shared/utilities/tsUtils.ts @@ -94,6 +94,38 @@ export function createFactoryFunction any>(cto return (...args) => new ctor(...args) } +/** + * Try functions in the order presented and return the first returned result. If none return, throw the final error. + * @param functions non-empty list of functions to try. + * @returns + */ +export async function tryFunctions(functions: (() => Promise)[]): Promise { + let currentError: Error = new Error('No functions provided') + for (const func of functions) { + try { + return await func() + } catch (e) { + currentError = e as Error + } + } + throw currentError +} + +/** + * Split a list into two sublists based on the result of a predicate. + * @param lst list to split + * @param pred predicate to apply to each element + * @returns two nested lists, where for all items x in the left sublist, pred(x) returns true. The remaining elements are in the right sublist. + */ +export function partition(lst: T[], pred: (arg: T) => boolean): [T[], T[]] { + return lst.reduce( + ([leftAcc, rightAcc], item) => { + return pred(item) ? [[...leftAcc, item], rightAcc] : [leftAcc, [...rightAcc, item]] + }, + [[], []] as [T[], T[]] + ) +} + type NoSymbols = { [Property in keyof T]: Property extends symbol ? never : Property }[keyof T] export type InterfaceNoSymbol = Pick> /** diff --git a/packages/core/src/stepFunctions/asl/aslServer.ts b/packages/core/src/stepFunctions/asl/aslServer.ts index 8da1363969c..2d4c4fadde3 100644 --- a/packages/core/src/stepFunctions/asl/aslServer.ts +++ b/packages/core/src/stepFunctions/asl/aslServer.ts @@ -37,8 +37,8 @@ import { import { posix } from 'path' import * as URL from 'url' -import { getLanguageModelCache } from '../../shared/languageServer/languageModelCache' -import { formatError, runSafe, runSafeAsync } from '../../shared/languageServer/utils/runner' +import { getLanguageModelCache } from '../../shared/lsp/languageModelCache' +import { formatError, runSafe, runSafeAsync } from '../../shared/lsp/utils/runner' import { YAML_ASL, JSON_ASL } from '../constants/aslFormats' export const ResultLimitReached: NotificationType = new NotificationType('asl/resultLimitReached') diff --git a/packages/core/src/test/shared/lsp/manifestResolver.test.ts b/packages/core/src/test/shared/lsp/manifestResolver.test.ts new file mode 100644 index 00000000000..eb5f8e90893 --- /dev/null +++ b/packages/core/src/test/shared/lsp/manifestResolver.test.ts @@ -0,0 +1,101 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import { Manifest, ManifestResolver } from '../../../shared' +import { assertTelemetry } from '../../testUtil' +import { ManifestLocation } from '../../../shared/telemetry' + +const manifestSchemaVersion = '1.0.0' +const serverName = 'myLS' + +/** + * Helper function generating valid manifest results for tests. + * @param location + * @returns + */ +function manifestResult(location: ManifestLocation): Manifest { + return { + location, + manifestSchemaVersion, + artifactId: 'artifact-id', + artifactDescription: 'artifact-description', + isManifestDeprecated: false, + versions: [], + } +} + +describe('manifestResolver', function () { + let remoteStub: sinon.SinonStub + let localStub: sinon.SinonStub + + before(function () { + remoteStub = sinon.stub(ManifestResolver.prototype, 'fetchRemoteManifest' as any) + localStub = sinon.stub(ManifestResolver.prototype, 'getLocalManifest' as any) + }) + + after(function () { + sinon.restore() + }) + + it('attempts to fetch from remote first', async function () { + remoteStub.resolves(manifestResult('remote')) + + const r = await new ManifestResolver('remote-manifest.com', serverName).resolve() + assert.strictEqual(r.location, 'remote') + assertTelemetry('languageServer_setup', { + manifestLocation: 'remote', + manifestSchemaVersion, + languageServerSetupStage: 'getManifest', + id: serverName, + result: 'Succeeded', + }) + }) + + it('uses local cache when remote fails', async function () { + remoteStub.rejects(new Error('failed to fetch')) + localStub.resolves(manifestResult('cache')) + + const r = await new ManifestResolver('remote-manifest.com', serverName).resolve() + assert.strictEqual(r.location, 'cache') + assertTelemetry('languageServer_setup', [ + { + manifestLocation: 'remote', + languageServerSetupStage: 'getManifest', + id: serverName, + result: 'Failed', + }, + { + manifestLocation: 'cache', + manifestSchemaVersion, + languageServerSetupStage: 'getManifest', + id: serverName, + result: 'Succeeded', + }, + ]) + }) + + it('fails if both local and remote fail', async function () { + remoteStub.rejects(new Error('failed to fetch')) + localStub.rejects(new Error('failed to fetch')) + + await assert.rejects(new ManifestResolver('remote-manifest.com', serverName).resolve(), /failed to fetch/) + assertTelemetry('languageServer_setup', [ + { + manifestLocation: 'remote', + languageServerSetupStage: 'getManifest', + id: serverName, + result: 'Failed', + }, + { + manifestLocation: 'cache', + languageServerSetupStage: 'getManifest', + id: serverName, + result: 'Failed', + }, + ]) + }) +}) diff --git a/packages/core/src/test/shared/lsp/utils/cleanup.test.ts b/packages/core/src/test/shared/lsp/utils/cleanup.test.ts new file mode 100644 index 00000000000..98f37fff28f --- /dev/null +++ b/packages/core/src/test/shared/lsp/utils/cleanup.test.ts @@ -0,0 +1,116 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Uri } from 'vscode' +import { cleanLspDownloads, fs, getDownloadedVersions } from '../../../../shared' +import { createTestWorkspaceFolder } from '../../../testUtil' +import path from 'path' +import assert from 'assert' + +async function fakeInstallVersion(version: string, installationDir: string): Promise { + const versionDir = path.join(installationDir, version) + await fs.mkdir(versionDir) + await fs.writeFile(path.join(versionDir, 'file.txt'), 'content') +} + +async function fakeInstallVersions(versions: string[], installationDir: string): Promise { + for (const v of versions) { + await fakeInstallVersion(v, installationDir) + } +} + +describe('cleanLSPDownloads', function () { + let installationDir: Uri + + before(async function () { + installationDir = (await createTestWorkspaceFolder()).uri + }) + + afterEach(async function () { + const files = await fs.readdir(installationDir.fsPath) + for (const [name, _type] of files) { + await fs.delete(path.join(installationDir.fsPath, name), { force: true, recursive: true }) + } + }) + + after(async function () { + await fs.delete(installationDir, { force: true, recursive: true }) + }) + + it('keeps two newest versions', async function () { + await fakeInstallVersions(['1.0.0', '1.0.1', '1.1.1', '2.1.1'], installationDir.fsPath) + const deleted = await cleanLspDownloads([], installationDir.fsPath) + + const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename) + assert.strictEqual(result.length, 2) + assert.ok(result.includes('2.1.1')) + assert.ok(result.includes('1.1.1')) + assert.strictEqual(deleted.length, 2) + }) + + it('deletes delisted versions', async function () { + await fakeInstallVersions(['1.0.0', '1.0.1', '1.1.1', '2.1.1'], installationDir.fsPath) + const deleted = await cleanLspDownloads( + [{ serverVersion: '1.1.1', isDelisted: true, targets: [] }], + installationDir.fsPath + ) + + const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename) + assert.strictEqual(result.length, 2) + assert.ok(result.includes('2.1.1')) + assert.ok(result.includes('1.0.1')) + assert.strictEqual(deleted.length, 2) + }) + + it('handles case where less than 2 versions are not delisted', async function () { + await fakeInstallVersions(['1.0.0', '1.0.1', '1.1.1', '2.1.1'], installationDir.fsPath) + const deleted = await cleanLspDownloads( + [ + { serverVersion: '1.1.1', isDelisted: true, targets: [] }, + { serverVersion: '2.1.1', isDelisted: true, targets: [] }, + { serverVersion: '1.0.0', isDelisted: true, targets: [] }, + ], + installationDir.fsPath + ) + + const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename) + assert.strictEqual(result.length, 1) + assert.ok(result.includes('1.0.1')) + assert.strictEqual(deleted.length, 3) + }) + + it('handles case where less than 2 versions exist', async function () { + await fakeInstallVersions(['1.0.0'], installationDir.fsPath) + const deleted = await cleanLspDownloads([], installationDir.fsPath) + + const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename) + assert.strictEqual(result.length, 1) + assert.strictEqual(deleted.length, 0) + }) + + it('does not install delisted version when no other option exists', async function () { + await fakeInstallVersions(['1.0.0'], installationDir.fsPath) + const deleted = await cleanLspDownloads( + [{ serverVersion: '1.0.0', isDelisted: true, targets: [] }], + installationDir.fsPath + ) + + const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename) + assert.strictEqual(result.length, 0) + assert.strictEqual(deleted.length, 1) + }) + + it('ignores invalid versions', async function () { + await fakeInstallVersions(['1.0.0', '.DS_STORE'], installationDir.fsPath) + const deleted = await cleanLspDownloads( + [{ serverVersion: '1.0.0', isDelisted: true, targets: [] }], + installationDir.fsPath + ) + + const result = await getDownloadedVersions(installationDir.fsPath) + assert.strictEqual(result.length, 0) + assert.strictEqual(deleted.length, 1) + }) +}) diff --git a/packages/core/src/test/shared/utilities/timeoutUtils.test.ts b/packages/core/src/test/shared/utilities/timeoutUtils.test.ts index 3ee5968132b..71d854d3cc2 100644 --- a/packages/core/src/test/shared/utilities/timeoutUtils.test.ts +++ b/packages/core/src/test/shared/utilities/timeoutUtils.test.ts @@ -394,6 +394,46 @@ export const timeoutUtilsDescribe = describe('timeoutUtils', async function () { fn = sandbox.stub() }) + it('should retry when retryOnFail callback returns true', async function () { + fn.onCall(0).throws(new Error('Retry error')) + fn.onCall(1).throws(new Error('Retry error')) + fn.onCall(2).resolves('success') + + const res = waitUntil(fn, { + retryOnFail: (error: Error) => { + return error.message === 'Retry error' + }, + }) + + await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval) + assert.strictEqual(fn.callCount, 2) + await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval) + assert.strictEqual(fn.callCount, 3) + assert.strictEqual(await res, 'success') + }) + + it('should not retry when retryOnFail callback returns false', async function () { + fn.onCall(0).throws(new Error('Retry error')) + fn.onCall(1).throws(new Error('Retry error')) + fn.onCall(2).throws(new Error('Last error')) + fn.onCall(3).resolves('this is not hit') + + const res = assert.rejects( + waitUntil(fn, { + retryOnFail: (error: Error) => { + return error.message === 'Retry error' + }, + }), + (e) => e instanceof Error && e.message === 'Last error' + ) + + await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval) + assert.strictEqual(fn.callCount, 2) + await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval) + assert.strictEqual(fn.callCount, 3) + await res + }) + it('retries the function until it succeeds', async function () { fn.onCall(0).throws() fn.onCall(1).throws() diff --git a/packages/core/src/test/shared/utilities/tsUtils.test.ts b/packages/core/src/test/shared/utilities/tsUtils.test.ts new file mode 100644 index 00000000000..384d47df6ec --- /dev/null +++ b/packages/core/src/test/shared/utilities/tsUtils.test.ts @@ -0,0 +1,37 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'assert' +import { tryFunctions } from '../../../shared/utilities/tsUtils' +import { partition } from '../../../shared/utilities/tsUtils' + +describe('tryFunctions', function () { + it('should return the result of the first function that returns', async function () { + const f1 = () => Promise.reject('f1') + const f2 = () => Promise.resolve('f2') + const f3 = () => Promise.reject('f3') + + assert.strictEqual(await tryFunctions([f1, f2, f3]), 'f2') + }) + + it('if all reject, then should throw final error', async function () { + const f1 = () => Promise.reject('f1') + const f2 = () => Promise.reject('f2') + const f3 = () => Promise.reject('f3') + + await assert.rejects( + async () => await tryFunctions([f1, f2, f3]), + (e) => e === 'f3' + ) + }) +}) + +describe('partition', function () { + it('should split the list according to predicate', function () { + const items = [1, 2, 3, 4, 5, 6, 7, 8] + const [even, odd] = partition(items, (i) => i % 2 === 0) + assert.deepStrictEqual(even, [2, 4, 6, 8]) + assert.deepStrictEqual(odd, [1, 3, 5, 7]) + }) +}) diff --git a/packages/core/src/testInteg/perf/getFileSha384.test.ts b/packages/core/src/testInteg/perf/getFileSha384.test.ts deleted file mode 100644 index 4f12fbfeeb8..00000000000 --- a/packages/core/src/testInteg/perf/getFileSha384.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ -import assert from 'assert' -import path from 'path' -import sinon from 'sinon' -import { getTestWorkspaceFolder } from '../integrationTestsUtilities' -import { fs, getRandomString } from '../../shared' -import { LspController } from '../../amazonq' -import { getEqualOSTestOptions, performanceTest } from '../../shared/performance/performance' -import { FileSystem } from '../../shared/fs/fs' -import { getFsCallsUpperBound } from './utilities' - -interface SetupResult { - testFile: string - fsSpy: sinon.SinonSpiedInstance -} - -function performanceTestWrapper(label: string, fileSize: number) { - return performanceTest( - getEqualOSTestOptions({ - userCpuUsage: 500, - systemCpuUsage: 35, - heapTotal: 4, - }), - label, - function () { - return { - setup: async () => { - const workspace = getTestWorkspaceFolder() - const fileContent = getRandomString(fileSize) - const testFile = path.join(workspace, 'test-file') - await fs.writeFile(testFile, fileContent) - const fsSpy = sinon.spy(fs) - return { testFile, fsSpy } - }, - execute: async (setup: SetupResult) => { - return await LspController.instance.getFileSha384(setup.testFile) - }, - verify: async (setup: SetupResult, result: string) => { - assert.strictEqual(result.length, 96) - assert.ok(getFsCallsUpperBound(setup.fsSpy) <= 1, 'makes a single call to fs') - }, - } - } - ) -} - -describe('getFileSha384', function () { - describe('performance tests', function () { - afterEach(function () { - sinon.restore() - }) - performanceTestWrapper('1MB', 1000) - performanceTestWrapper('2MB', 2000) - performanceTestWrapper('4MB', 4000) - performanceTestWrapper('8MB', 8000) - }) -}) diff --git a/packages/core/src/testInteg/perf/tryInstallLsp.test.ts b/packages/core/src/testInteg/perf/tryInstallLsp.test.ts deleted file mode 100644 index d84da8626d3..00000000000 --- a/packages/core/src/testInteg/perf/tryInstallLsp.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ -import assert from 'assert' -import sinon from 'sinon' -import { Content } from 'aws-sdk/clients/codecommit' -import AdmZip from 'adm-zip' -import path from 'path' -import { LspController } from '../../amazonq' -import { fs, getRandomString, globals } from '../../shared' -import { createTestWorkspace } from '../../test/testUtil' -import { getEqualOSTestOptions, performanceTest } from '../../shared/performance/performance' -import { getFsCallsUpperBound } from './utilities' -import { FileSystem } from '../../shared/fs/fs' - -// fakeFileContent is matched to fakeQServerContent based on hash. -const fakeHash = '4eb2865c8f40a322aa04e17d8d83bdaa605d6f1cb363af615240a5442a010e0aef66e21bcf4c88f20fabff06efe8a214' - -const fakeQServerContent = { - filename: 'qserver-fake.zip', - url: 'https://aws-language-servers/fake.zip', - hashes: [`sha384:${fakeHash}`], - bytes: 93610849, - serverVersion: '1.1.1', -} - -const fakeNodeContent = { - filename: 'fake-file', - url: 'https://aws-language-servers.fake-file', - hashes: [`sha384:${fakeHash}`], - bytes: 94144448, - serverVersion: '1.1.1', -} - -function createStubs(numberOfFiles: number, fileSize: number): sinon.SinonSpiedInstance { - // Avoid making HTTP request or mocking giant manifest, stub what we need directly from request. - sinon.stub(LspController.prototype, 'fetchManifest') - // Directly feed the runtime specifications. - sinon.stub(LspController.prototype, 'getQserverFromManifest').returns(fakeQServerContent) - sinon.stub(LspController.prototype, 'getNodeRuntimeFromManifest').returns(fakeNodeContent) - // avoid fetch call. - sinon.stub(LspController.prototype, '_download').callsFake(getFakeDownload(numberOfFiles, fileSize)) - // Hard code the hash since we are creating files on the spot, whose hashes can't be predicted. - sinon.stub(LspController.prototype, 'getFileSha384').resolves(fakeHash) - const fsSpy = sinon.spy(fs) - fsSpy.rename.restore() - // Don't allow tryInstallLsp to move runtimes out of temporary folder - sinon.stub(fsSpy, 'rename') - return fsSpy -} - -/** - * Creates a fake zip with some files in it. - * @param filepath where to write the zip to. - * @param _content unused parameter, for compatability with real function. - */ -const getFakeDownload = function (numberOfFiles: number, fileSize: number) { - return async function (filepath: string, _content: Content) { - const dummyFilesPath = ( - await createTestWorkspace(numberOfFiles, { - fileNamePrefix: 'fakeFile', - fileContent: getRandomString(fileSize), - workspaceName: 'workspace', - }) - ).uri.fsPath - await fs.writeFile(path.join(dummyFilesPath, 'qserver'), 'this value shouldnt matter') - const zip = new AdmZip() - zip.addLocalFolder(dummyFilesPath) - zip.writeZip(filepath) - } -} - -function performanceTestWrapper(numFiles: number, fileSize: number, message: string) { - return performanceTest( - getEqualOSTestOptions({ - userCpuUsage: 150, - systemCpuUsage: 35, - heapTotal: 6, - duration: 15, - }), - message, - function () { - return { - setup: async () => { - return createStubs(numFiles, fileSize) - }, - execute: async () => { - return await LspController.instance.tryInstallLsp(globals.context) - }, - verify: async (fsSpy: sinon.SinonSpiedInstance, result: boolean) => { - assert.ok(result) - assert.ok(getFsCallsUpperBound(fsSpy) <= 6 * numFiles) - }, - } - } - ) -} - -describe('tryInstallLsp', function () { - afterEach(function () { - sinon.restore() - }) - describe('performance tests', function () { - performanceTestWrapper(250, 10, '250x10') - performanceTestWrapper(10, 1000, '10x1000') - }) -}) diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index daf54fd2771..1be6c628ae6 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -246,6 +246,14 @@ "jsonResourceModification": { "type": "boolean", "default": false + }, + "amazonqLSP": { + "type": "boolean", + "default": false + }, + "amazonqChatLSP": { + "type": "boolean", + "default": false } }, "additionalProperties": false diff --git a/packages/webpack.web.config.js b/packages/webpack.web.config.js index fc6ca86d1d9..0f82af67389 100644 --- a/packages/webpack.web.config.js +++ b/packages/webpack.web.config.js @@ -41,7 +41,7 @@ module.exports = (env, argv) => { * environments. The following allows compilation to pass in Web mode by never bundling the module in the final output for web mode. */ new webpack.IgnorePlugin({ - resourceRegExp: /httpResourceFetcher/, // matches the path in the require() statement + resourceRegExp: /node\/httpResourceFetcher/, // matches the path in the require() statement }), /** * HACK: the ps-list module breaks Web mode if imported, BUT we still dynamically import this module for non web mode