From 007c79455f69e5d6c6ff93adfd4aaaef891e86a4 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 10:54:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(logging):=20=E6=8E=A5=E5=85=A5=20litheLogg?= =?UTF-8?q?ing=201=20=E7=BB=93=E6=9E=84=E5=8C=96=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vendor 的 Node SDK 更新为 lithe-plugin-docs 29b245c 加本轮日志改动, sdk.lock.json 分开记录 sdkSource(SDK 来源)与 coreSource(集成测试宿主) - provider 去掉 context.log 的重复错误日志;失败只由 SDK 请求边界记录一次, 错误信息不再拼接 Cargo stderr 或异常原文 - prepare-core 支持 --source/--ref/--working-tree/--target-dir,校验宿主 origin、ref、源码摘要和 litheLogging 目录前提,构建期间源码漂移即拒绝; 不再往宿主检出里复制 example - CI 与 release 改为检出宿主 preview;README 补充诊断日志与 Core 集成来源说明 --- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 6 +- NOTICE | 3 +- README.md | 90 +++- .../backend/provider.mjs | 11 +- scripts/core-source.mjs | 44 ++ scripts/prepare-core.mjs | 69 ++- scripts/verify-sdk.mjs | 6 +- sdk.lock.json | 70 ++- test/core-source.test.mjs | 32 ++ test/integration.test.mjs | 2 +- test/provider.test.mjs | 50 +- test/support/core.mjs | 11 +- vendor/lithe-plugin-sdk/src/backend.mjs | 443 ++++++++++++++-- vendor/lithe-plugin-sdk/src/diagnostics.mjs | 33 ++ vendor/lithe-plugin-sdk/src/errors.mjs | 47 +- vendor/lithe-plugin-sdk/src/host.mjs | 369 +++++++++++++ vendor/lithe-plugin-sdk/src/index.d.ts | 483 +++++++++++++++++- vendor/lithe-plugin-sdk/src/index.mjs | 6 +- vendor/lithe-plugin-sdk/src/protocol.mjs | 180 ++++++- vendor/lithe-plugin-sdk/test/harness.mjs | 2 +- 21 files changed, 1803 insertions(+), 160 deletions(-) create mode 100644 scripts/core-source.mjs create mode 100644 test/core-source.test.mjs create mode 100644 vendor/lithe-plugin-sdk/src/diagnostics.mjs create mode 100644 vendor/lithe-plugin-sdk/src/host.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fc6311..5422afb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - name: Read pinned host revision + - name: Resolve latest host preview with structured logging id: core - run: node -e 'const s=require("./sdk.lock.json").source; console.log(`repository=${new URL(s.repository).pathname.slice(1)}\nrevision=${s.revision}`)' >> "$GITHUB_OUTPUT" + run: node -e 'const s=require("./sdk.lock.json").coreSource; console.log(`repository=${new URL(s.repository).pathname.slice(1)}\nrevision=${s.ref}`)' >> "$GITHUB_OUTPUT" - uses: actions/checkout@v4 with: repository: ${{ steps.core.outputs.repository }} @@ -26,7 +26,7 @@ jobs: rust shared persist-credentials: false - - name: Build test-only bridge to the pinned Core + - name: Verify host prerequisite and build Core bridge run: node scripts/prepare-core.mjs - name: Verify SDK and run offline integration env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efee75a..15dfa78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,10 +23,10 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - name: Read pinned host revision + - name: Resolve latest host preview with structured logging id: core if: hashFiles('scripts/prepare-core.mjs') != '' - run: node -e 'const s=require("./sdk.lock.json").source; console.log(`repository=${new URL(s.repository).pathname.slice(1)}\nrevision=${s.revision}`)' >> "$GITHUB_OUTPUT" + run: node -e 'const s=require("./sdk.lock.json").coreSource; console.log(`repository=${new URL(s.repository).pathname.slice(1)}\nrevision=${s.ref}`)' >> "$GITHUB_OUTPUT" - uses: actions/checkout@v4 if: steps.core.outputs.revision != '' with: @@ -37,7 +37,7 @@ jobs: rust shared persist-credentials: false - - name: Build test-only bridge to the pinned Core + - name: Verify host prerequisite and build Core bridge if: steps.core.outputs.revision != '' run: node scripts/prepare-core.mjs - name: Verify, test and package diff --git a/NOTICE b/NOTICE index d01af6f..9579fdd 100644 --- a/NOTICE +++ b/NOTICE @@ -2,7 +2,8 @@ Cargo build plugin for Lithe Copyright 2026 Lithe contributors This project includes the Apache-2.0 licensed Lithe Node plugin SDK and -test support from 1lck/Lithe-IDEA. Exact upstream paths, Git revision +test support. The SDK comes from Lithe-IDEA/lithe-plugin-docs; historical +test support comes from 1lck/Lithe-IDEA. Exact upstream paths, Git revisions and SHA-256 hashes are recorded in sdk.lock.json. The SDK is copied into the plugin at build time; test support is never shipped in the package. diff --git a/README.md b/README.md index c005a8c..12d7d61 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,25 @@ Cargo enforces toolchain and required-feature rules. Open the workspace root; members or target sources outside the selected build root are refused. The host's `outermost` detection does not list independent nested workspaces separately. +## Diagnostic logs + +This branch requires a host offering `litheLogging: 1`. The vendored official +Node SDK sends typed `lithe/log` notifications to the host's v2 catalog and +writer, in the current session's +`plugins/io.github.lithe-idea.cargo-build/host` channel. The SDK records one +INFO terminal on a successful metadata request and one ERROR on a failed +request, with monotonic duration and a controlled failure code. Cancellation +does not become an error. The provider does not log and throw the same failure +twice, and does not copy metadata JSON, Cargo stderr, source, environment values +or raw exceptions into logs. + +Build, check, test and run process outcomes use the host's `run` channel; +their output stays in Build Output. Use Lithe's log window and diagnostic +export for host evidence. No plugin writer, log directory or idle task is +added. The SDK source base revision and local logging changes are identified +by `sdk.lock.json`, including exact file hashes; they do not claim a released +SDK artifact. Windows still uses v1 logging; no Windows v2 acceptance is claimed. + ## Develop and verify Requires Node 20.3+, `zip`, and Cargo for integration tests. Unit tests and @@ -69,26 +88,16 @@ node scripts/test.mjs node scripts/package.mjs ``` -Full integration also compiles a test-only bridge to Lithe's public -`execute_json` API. CI checks out the exact host repository/revision from -`sdk.lock.json` into `.artifacts/lithe-core`; it never follows a moving branch. -For the same local procedure, clone the pinned host once (network required): +Full integration compiles a test-only bridge to Lithe's public `execute_json` +API. CI uses the host repository and `preview` ref in `sdk.lock.json.coreSource`, +then records the exact resolved commit and source digest. The companion host +logging change must land first. See **Core integration source** below for +verified local working-tree preparation and the complete command. -```sh -gh repo clone 1lck/Lithe-IDEA .artifacts/lithe-core -- --filter=blob:none --no-checkout -git -C .artifacts/lithe-core sparse-checkout set rust shared -git -C .artifacts/lithe-core checkout --detach "$(node -p 'require("./sdk.lock.json").source.revision')" -node scripts/prepare-core.mjs -LITHE_CARGO_INTEGRATION=1 node scripts/test.mjs -``` - -Cargo downloads the host's locked Rust dependencies on the first build; only -the tiny Cargo fixture runs offline. Preparation rejects a different revision, -origin or tracked modifications, builds with `--locked`, and records bridge -and binary hashes. `.artifacts/core-target` is private mutable build state for -this checkout; it is not a shared worktree cache and never enters the plugin ZIP. -The copied example in the disposable host checkout contains only the public API -call; Cargo-specific assertions, manifests and golden models remain here. +Cargo downloads the host's locked Rust dependencies on the first build; +the Cargo fixture itself runs offline. `.artifacts/core-target` contains only +private test build state and never enters the plugin ZIP. The harness links +the public Core library without copying files into the source checkout. The three Core compatibility tests cover install review and capability refusal, outermost workspace detection/model decoding, and task/test argument selection. @@ -104,14 +113,13 @@ is `dist/io.github.lithe-idea.cargo-build/`. The archive serves both Apple Silic and Intel Macs. CI builds on Linux to conserve native runner usage; the local integration suite was also run on macOS. -The zero-dependency Node SDK and test helpers are vendored at the exact source -revision in `sdk.lock.json`. Packaging refuses changed/unpinned vendor files and -symbolic links. To update the SDK, copy the upstream files and deliberately -update the source revision and every SHA-256; do not download anything at runtime. -The main Lithe repository retains generic plugin contract tests. This repository -owns Cargo-specific compatibility tests and uses the same host pin as the SDK; -updating that pin deliberately tests a new supported host baseline. A fixed -baseline does not by itself detect regressions on future Lithe preview commits. +The zero-dependency Node SDK comes from `Lithe-IDEA/lithe-plugin-docs` at the +base revision and local structured logging changes recorded in `sdkSource`. +Historical test helpers retain their original source identity. Every vendor +file has an exact SHA-256; packaging refuses changed or unpinned inputs and +symbolic links. The separate `coreSource` chooses the integration host preview, +so SDK provenance is never confused with the host's current source identity. +No SDK or runtime is downloaded when the installed plugin starts. ## Runtime and build ownership @@ -123,7 +131,7 @@ the package's complete file listing and SHA-256 hashes before and after use. Cargo's user cache, Cargo.lock and target directory remain owned by Cargo and the user's project configuration. This plugin creates no extra runtime cache. -`dist/`, packaging staging, the pinned host checkout, Core compilation and +`dist/`, packaging staging, the selected host checkout, Core compilation and `.artifacts/` test reports belong to one checkout and must not be copied as shared build caches. Packaging removes its own staging on success/failure; developers own final artifact cleanup. No installation step @@ -145,3 +153,29 @@ candidates. Published 0.1.0/0.1.1 preview archives remain immutable. A compatibl host must advertise `executableResolution: 1`; the setting is a candidate ahead of automatic PATH discovery, with empty values skipped and explicit failures reported by the host. Maven/Gradle-style automatic candidates need no path setting. + +## Core integration source + +CI and release verification explicitly check out `sdk.lock.json.coreSource.ref` +(`preview`) from the verified host repository. **Merge the companion host +structured logging change into preview before this plugin change**; preparation +rejects a host missing the typed operation catalog rather than attempting an +old-protocol fallback. It records the resolved commit and exact source SHA-256; +this moving test ref is not presented as a pinned release artifact. SDK vendor +hashes and the historical test-support source remain independently pinned. + +Local validation can select a particular real host checkout and revision: + +```sh +node scripts/prepare-core.mjs --source /path/to/Lithe-IDEA --ref HEAD --working-tree --target-dir /path/to/Lithe-IDEA/rust/target +LITHE_CARGO_INTEGRATION=1 node scripts/test.mjs +``` + +`--working-tree` is a deliberate opt-in for local unpublished host changes; +without it, dirty sources are rejected. The origin must still match the host +repository and HEAD must resolve to the selected ref. The source remains +unchanged: a test-only Rust executable links the built Core library in the +plugin's private `.artifacts/core-target/`. Preparation records tracked and +untracked `rust/` and `shared/` bytes, the harness and executable hashes; +tests reject any drift after preparation. A caller may reuse an existing +Cargo target with `--target-dir`, but never shares plugin state or log files. diff --git a/io.github.lithe-idea.cargo-build/backend/provider.mjs b/io.github.lithe-idea.cargo-build/backend/provider.mjs index a442659..e9c2e41 100644 --- a/io.github.lithe-idea.cargo-build/backend/provider.mjs +++ b/io.github.lithe-idea.cargo-build/backend/provider.mjs @@ -62,7 +62,7 @@ export function registerCargoProvider(backend, PluginError) { directory = await realpath(resolve(workspaceRoot, root)); if (!inside(workspaceRoot, directory)) throw new Error('The Cargo build root leaves the workspace through a symbolic link.'); } catch (error) { - throw new PluginError('cargo.invalidRoot', error.message); + throw new PluginError('cargo.invalidRoot', 'The Cargo build root could not be read inside the open workspace. Check the workspace directory and symbolic links.'); } const executable = tool?.executable ?? 'cargo'; if (tool && (typeof executable !== 'string' || !isAbsolute(executable))) { @@ -77,13 +77,13 @@ export function registerCargoProvider(backend, PluginError) { result = await context.run(executable, args, { cwd: directory, env: processEnvironment, maxOutputBytes: 8 * 1024 * 1024 }); } catch (error) { context.throwIfCancelled(); - context.log(error); throw new PluginError('cargo.unavailable', 'Cargo could not be started. Install Rust/Cargo and check the build tool path.'); } context.throwIfCancelled(); if (result.exitCode !== 0) { - context.log(result.stderr); - throw new PluginError('cargo.metadataFailed', `Cargo could not load this workspace. Check Cargo.toml and the Offline option. ${result.stderr.trim().slice(0, 2048)}`); + // The SDK records this failure once. Cargo output can contain project + // contents and environment values, so only the exit status crosses it. + throw new PluginError('cargo.metadataFailed', `Cargo could not load this workspace (exit status ${result.exitCode}). Check Cargo.toml and the Offline option.`); } try { const model = await modelFromMetadata(JSON.parse(result.stdout), directory); @@ -91,8 +91,7 @@ export function registerCargoProvider(backend, PluginError) { return model; } catch (error) { context.throwIfCancelled(); - context.log(error); - throw new PluginError('cargo.invalidMetadata', error.message); + throw new PluginError('cargo.invalidMetadata', 'Cargo returned an invalid workspace model. Check Cargo.toml and that every workspace member is inside the build root.'); } }); } diff --git a/scripts/core-source.mjs b/scripts/core-source.mjs new file mode 100644 index 0000000..3305b5d --- /dev/null +++ b/scripts/core-source.mjs @@ -0,0 +1,44 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { runProcess } from '../vendor/lithe-plugin-sdk/src/index.mjs'; +import { repository } from './verify-sdk.mjs'; + +export const target = join(repository, '.artifacts/core-target'); +export const digest = async (file) => createHash('sha256').update(await readFile(file)).digest('hex'); +export async function git(source, args) { + const result = await runProcess('git', ['-C', source, ...args], { signal: AbortSignal.timeout(15000), maxOutputBytes: 8 * 1024 * 1024 }); + if (result.exitCode !== 0 || result.truncated) throw new Error(`Core source verification failed: ${result.stderr}`); + return result.stdout.trim(); +} + +/** A source identity includes dirty bytes only when the caller explicitly opted in. */ +export async function identifyCore(source, ref, workingTree, expectedRepository) { + const remote = (await git(source, ['remote', 'get-url', 'origin'])).replace(/\.git$/, ''); + if (remote !== expectedRepository) throw new Error('Core source has an unexpected origin repository.'); + const revision = await git(source, ['rev-parse', 'HEAD']); + if (revision !== await git(source, ['rev-parse', `${ref}^{commit}`])) throw new Error('Core source HEAD does not match the explicitly selected ref.'); + const dirty = Boolean(await git(source, ['status', '--porcelain', '--untracked-files=normal', '--', 'rust', 'shared'])); + if (dirty && !workingTree) throw new Error('Core source is dirty; explicitly select --working-tree for local validation.'); + const paths = (await git(source, ['ls-files', '--cached', '--others', '--exclude-standard', '--', 'rust', 'shared'])).split('\n').filter(Boolean).sort(); + const hash = createHash('sha256'); + for (const path of [...new Set(paths)]) { + hash.update(path + '\n'); + try { hash.update(await readFile(join(source, path))); } + catch (error) { if (error.code === 'ENOENT') hash.update(''); else throw error; } + } + const catalog = JSON.parse(await readFile(join(source, 'shared/logging/events/plugins.json'), 'utf8')); + if (!catalog.events.some((event) => event.id === 'plugins.operation.finished')) { + throw new Error('Selected Core lacks litheLogging:1. Merge the companion host logging change into preview first.'); + } + return { source: resolve(source), ref, revision, workingTree, dirty, sourceSha256: hash.digest('hex'), repository: remote }; +} + +/** Revalidate source and binary identity before any integration request. */ +export async function verifyPreparedCore(binary, harness) { + const prepared = JSON.parse(await readFile(join(target, 'prepared.json'), 'utf8')); + const lock = JSON.parse(await readFile(join(repository, 'sdk.lock.json'), 'utf8')); + const current = await identifyCore(prepared.source, prepared.ref, prepared.workingTree, lock.coreSource.repository); + if (JSON.stringify(current) !== JSON.stringify(prepared.identity)) throw new Error('Core source changed after preparation; run prepare-core again.'); + if (prepared.harnessSha256 !== await digest(harness) || prepared.binarySha256 !== await digest(binary)) throw new Error('Core harness or binary changed after preparation.'); +} diff --git a/scripts/prepare-core.mjs b/scripts/prepare-core.mjs index b163a6a..1a76d49 100644 --- a/scripts/prepare-core.mjs +++ b/scripts/prepare-core.mjs @@ -1,41 +1,40 @@ #!/usr/bin/env node -import { createHash } from 'node:crypto'; -import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { repository } from './verify-sdk.mjs'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { repository, verifySdk } from './verify-sdk.mjs'; import { runProcess } from '../vendor/lithe-plugin-sdk/src/index.mjs'; +import { digest, identifyCore, target } from './core-source.mjs'; -const checkout = join(repository, '.artifacts/lithe-core'); -const target = join(repository, '.artifacts/core-target'); -const stamp = join(target, 'prepared.json'); +await verifySdk(); const lock = JSON.parse(await readFile(join(repository, 'sdk.lock.json'), 'utf8')); -const harness = join(repository, 'test/core-harness.rs'); -const digest = async (file) => createHash('sha256').update(await readFile(file)).digest('hex'); +let source = join(repository, '.artifacts/lithe-core'); +let ref = lock.coreSource.ref; +let workingTree = false; +let buildTarget = join(target, 'build'); +for (let index = 2; index < process.argv.length; index++) { + const option = process.argv[index]; + if (option === '--source') { const value = process.argv[++index]; if (!value || value.startsWith('--')) throw new Error('--source requires a path.'); source = resolve(value); } + else if (option === '--ref') ref = process.argv[++index]; + else if (option === '--target-dir') { const value = process.argv[++index]; if (!value || value.startsWith('--')) throw new Error('--target-dir requires a path.'); buildTarget = resolve(value); } + else if (option === '--working-tree') workingTree = true; + else throw new Error(`Unknown preparation option: ${option}`); +} +if (!ref || ref.startsWith('-')) throw new Error('A real source ref is required.'); +const before = await identifyCore(source, ref, workingTree, lock.coreSource.repository); await mkdir(target, { recursive: true }); -await rm(stamp, { force: true }); - -async function git(args) { - const result = await runProcess('git', ['-C', checkout, ...args], { signal: AbortSignal.timeout(15000) }); - if (result.exitCode !== 0 || result.truncated) throw new Error(`Core checkout verification failed: ${result.stderr}`); - return result.stdout.trim(); +await rm(join(target, 'prepared.json'), { force: true }); +const harness = join(repository, 'test/core-harness.rs'); +const binary = join(target, 'cargo-plugin-harness'); +const logs = []; +async function run(command, args) { + const result = await runProcess(command, args, { signal: AbortSignal.timeout(600000), maxOutputBytes: 4 * 1024 * 1024 }); + logs.push(result.stdout + result.stderr); + await writeFile(join(target, 'build.log'), logs.join('\n')); + if (result.exitCode !== 0 || result.truncated) throw new Error(`Core bridge build failed; see ${join(target, 'build.log')}`); } - -if (await git(['rev-parse', 'HEAD']) !== lock.source.revision) throw new Error('Core checkout must match sdk.lock.json source revision.'); -const remote = (await git(['remote', 'get-url', 'origin'])).replace(/\.git$/, ''); -if (remote !== lock.source.repository) throw new Error('Core checkout must originate from sdk.lock.json source repository.'); -if (await git(['status', '--porcelain', '--untracked-files=no'])) throw new Error('Core checkout has tracked modifications.'); -const examples = join(checkout, 'rust/lithe-core/examples'); -await mkdir(examples, { recursive: true }); -await copyFile(harness, join(examples, 'cargo-plugin-harness.rs')); -const result = await runProcess('cargo', [ - 'build', '--locked', '--manifest-path', join(checkout, 'rust/Cargo.toml'), - '-p', 'lithe-core', '--example', 'cargo-plugin-harness', '--target-dir', target -], { signal: AbortSignal.timeout(600000), maxOutputBytes: 4 * 1024 * 1024 }); -await writeFile(join(target, 'build.log'), result.stdout + result.stderr); -if (result.exitCode !== 0 || result.truncated) throw new Error(`Core bridge build failed; see ${join(target, 'build.log')}`); -await writeFile(stamp, JSON.stringify({ - revision: lock.source.revision, - harnessSha256: await digest(harness), - binarySha256: await digest(join(target, 'debug/examples/cargo-plugin-harness')) -}, null, 2) + '\n'); -console.log(`Prepared Core ${lock.source.revision}`); +await run('cargo', ['build', '--locked', '--manifest-path', join(source, 'rust/Cargo.toml'), '-p', 'lithe-core', '--lib', '--target-dir', buildTarget]); +await run('rustc', ['--edition=2021', harness, '--extern', `lithe_core=${join(buildTarget, 'debug/liblithe_core.rlib')}`, '-L', `dependency=${join(buildTarget, 'debug/deps')}`, '-o', binary]); +const after = await identifyCore(source, ref, workingTree, lock.coreSource.repository); +if (JSON.stringify(before) !== JSON.stringify(after)) throw new Error('Core source changed while building; prepare it again.'); +await writeFile(join(target, 'prepared.json'), JSON.stringify({ ...before, identity: before, harnessSha256: await digest(harness), binarySha256: await digest(binary) }, null, 2) + '\n'); +console.log(`Prepared Core ${before.revision} ref=${ref} dirty=${before.dirty} sha256=${before.sourceSha256}`); diff --git a/scripts/verify-sdk.mjs b/scripts/verify-sdk.mjs index c245e31..9ec368a 100644 --- a/scripts/verify-sdk.mjs +++ b/scripts/verify-sdk.mjs @@ -8,13 +8,15 @@ export const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..') /** Verify vendored upstream inputs before testing or assembling a release. */ export async function verifySdk(root = repository) { const lock = JSON.parse(await readFile(join(root, 'sdk.lock.json'), 'utf8')); - if (lock.schemaVersion !== 1 || !/^[0-9a-f]{40}$/.test(lock.source?.revision) || !Array.isArray(lock.files)) { + if (lock.schemaVersion !== 1 || !/^[0-9a-f]{40}$/.test(lock.source?.revision) || !Array.isArray(lock.files) || !/^[0-9a-f]{40}$/.test(lock.sdkSource?.revision) || lock.sdkSource?.repository !== 'https://github.com/Lithe-IDEA/lithe-plugin-docs') { throw new Error('sdk.lock.json must pin an exact upstream Git revision and file hashes.'); } const expected = new Set(); for (const entry of lock.files) { if (!entry.path?.startsWith('vendor/') || isAbsolute(entry.path) || entry.path.split('/').some(part => !part || part === '.' || part === '..') - || entry.path.includes('\\') || !/^[0-9a-f]{64}$/.test(entry.sha256) || expected.has(entry.path)) { + || entry.path.includes('\\') || !/^[0-9a-f]{64}$/.test(entry.sha256) || expected.has(entry.path) + || (entry.path.startsWith('vendor/lithe-plugin-sdk/src/') && entry.source !== 'sdkSource') + || (entry.localChanges === true && (!Object.hasOwn(entry, 'upstreamSha256') || (entry.upstreamSha256 !== null && !/^[0-9a-f]{64}$/.test(entry.upstreamSha256))))) { throw new Error('Invalid or duplicate SDK lock entry.'); } expected.add(entry.path); diff --git a/sdk.lock.json b/sdk.lock.json index 5cadbc8..c0d0f6d 100644 --- a/sdk.lock.json +++ b/sdk.lock.json @@ -12,58 +12,104 @@ }, { "upstream": "sdk/node/src/backend.mjs", + "source": "sdkSource", "path": "vendor/lithe-plugin-sdk/src/backend.mjs", - "sha256": "7cf6e07d65ad91b30532fd31f0b83120b9dbadc71ecb05cbbe0c641d0e24bac5" + "sha256": "bc31d80064c9dc72e850e5b4f5ea739068da343c0628562088fa00d866e3e807", + "localChanges": true, + "upstreamSha256": "a69780df4b6fba6c9535d56a9647aaf1b39faa866bf95cc8e52377ef619ef108" + }, + { + "upstream": "sdk/node/src/diagnostics.mjs", + "source": "sdkSource", + "path": "vendor/lithe-plugin-sdk/src/diagnostics.mjs", + "sha256": "2ecac45d5f5dadcc0b9ea47a5fb6e1f148a4df48eac31a46b580c5c0fd8ac619", + "localChanges": true, + "upstreamSha256": null }, { "upstream": "sdk/node/src/errors.mjs", + "source": "sdkSource", "path": "vendor/lithe-plugin-sdk/src/errors.mjs", - "sha256": "92190d518602beb944305c2b15011d7abca0d44058fd92afe0663f6ea05d749b" + "sha256": "e756aa0a88f1807ffdc985f3aed307f283c2a59902a287e22dd408dae5179bd2", + "localChanges": true, + "upstreamSha256": "785cfe4f63e42e82285860050c9251b96a6c8cddcb36579292178b8d21289e47" }, { "upstream": "sdk/node/src/framing.mjs", + "source": "sdkSource", "path": "vendor/lithe-plugin-sdk/src/framing.mjs", "sha256": "ec745be1ecf4cf24d521c5aa3108da2bbbfb9156f2197fcb5158de1f1bc9cfd1" }, + { + "upstream": "sdk/node/src/host.mjs", + "source": "sdkSource", + "path": "vendor/lithe-plugin-sdk/src/host.mjs", + "sha256": "5fbb3a03292fe72bb6b8c7169cecdf0263008526a1252e46e9f0afe98610e4ec", + "localChanges": true, + "upstreamSha256": "0bdf879d7ac97a04981798842a5d87ba1e11776c5abd08f88082ee25038dbef0" + }, { "upstream": "sdk/node/src/index.d.ts", + "source": "sdkSource", "path": "vendor/lithe-plugin-sdk/src/index.d.ts", - "sha256": "7625a5d8f8234333a5e716282a6c25c28ef5108e650e6e165312e3a5b6c1b0ba" + "sha256": "e6ec8c1467f695330214efafe7a670dae2f843e61266868a348a0673ea4137be", + "localChanges": true, + "upstreamSha256": "560d2691da3a6f859e992a092e0b2d4cc921f16c53b10a0246e620bc10070a1b" }, { "upstream": "sdk/node/src/index.mjs", + "source": "sdkSource", "path": "vendor/lithe-plugin-sdk/src/index.mjs", - "sha256": "8e29eb6d1125505b66f41f3f9e230cf3a398e75d5943e6bac9a889acd7bc307a" + "sha256": "79042db2ac6e0ac896ff0a1643ee540217b7b84fab4d1b1b82ad4246263d16af" }, { "upstream": "sdk/node/src/process.mjs", + "source": "sdkSource", "path": "vendor/lithe-plugin-sdk/src/process.mjs", "sha256": "a72f78cbfbb8d98b2d44ebea7f869038a3e34ebe7ee000f63cdd36fb2aaece66" }, { "upstream": "sdk/node/src/protocol.mjs", + "source": "sdkSource", "path": "vendor/lithe-plugin-sdk/src/protocol.mjs", - "sha256": "2d62f1eb7addf7b796d1b740f0964d5800d844877fbf59e3e4d79278b9023263" + "sha256": "31de386e386e8ec276657f151f62b2d0e7a4ae70700072eefeac624a6cde70a6" }, { "upstream": "sdk/node/src/state.mjs", + "source": "sdkSource", "path": "vendor/lithe-plugin-sdk/src/state.mjs", "sha256": "946618214fbcb736c76ee85c87e0b969fe853fbab215f7c192990f2edf2035a4" }, { "upstream": "sdk/node/test/harness.mjs", + "source": "sdkSource", "path": "vendor/lithe-plugin-sdk/test/harness.mjs", - "sha256": "ae636e3236c40b9bb957748fbf7622914a87d5c6aaf5a358ea80b2d4628af53c" - }, - { - "upstream": ".agents/skills/write-stable-tests/scripts/test-timing-lib.mjs", - "path": "vendor/lithe-testing/test-timing-lib.mjs", - "sha256": "6475279234c07cb8d420e03dbfc5e87aa64fd1703c872798684ee56cd2bc3425" + "sha256": "2a7624a70d8fd46b41c16f2904f1783ce26f7770ec3b45081fbd6ff197884559", + "localChanges": true, + "upstreamSha256": "ae636e3236c40b9bb957748fbf7622914a87d5c6aaf5a358ea80b2d4628af53c" }, { "upstream": ".agents/skills/write-stable-tests/scripts/generate-test-report.mjs", "path": "vendor/lithe-testing/generate-test-report.mjs", "sha256": "ef4170b6380077d48dcf11a54faa423c2071291b2202a7f89df86d4b93853824" + }, + { + "upstream": ".agents/skills/write-stable-tests/scripts/test-timing-lib.mjs", + "path": "vendor/lithe-testing/test-timing-lib.mjs", + "sha256": "6475279234c07cb8d420e03dbfc5e87aa64fd1703c872798684ee56cd2bc3425" } - ] + ], + "sdkSource": { + "repository": "https://github.com/Lithe-IDEA/lithe-plugin-docs", + "revision": "29b245c0e1d77c761d9032a3144a301d1818bfc7", + "localChanges": "Structured logging lithe/log implementation from the accompanying SDK working tree; exact reviewed source hashes below. No published release is claimed." + }, + "coreSource": { + "repository": "https://github.com/1lck/Lithe-IDEA", + "ref": "preview", + "requiredCapabilities": [ + "litheLogging:1" + ], + "prerequisite": "Merge the companion structured plugin logging host change into preview before plugin CI/release. The resolved revision and exact source digest are recorded by prepare-core." + } } diff --git a/test/core-source.test.mjs b/test/core-source.test.mjs new file mode 100644 index 0000000..35812f1 --- /dev/null +++ b/test/core-source.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { identifyCore } from '../scripts/core-source.mjs'; + +test('Core identity enforces origin/ref and explicit dirty-source consent', { timeout: 10000 }, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'lithe-core-identity-')); + t.after(() => rm(root, { recursive: true, force: true })); + const git = (...args) => execFileSync('git', ['-C', root, ...args], { timeout: 2000, stdio: ['ignore', 'pipe', 'pipe'] }); + git('init', '-q'); + git('remote', 'add', 'origin', 'https://example.invalid/core.git'); + await mkdir(join(root, 'shared/logging/events'), { recursive: true }); + await writeFile(join(root, 'shared/logging/events/plugins.json'), JSON.stringify({ events: [{ id: 'plugins.operation.finished' }] })); + git('add', '.'); + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-qm', 'fixture'); + const clean = await identifyCore(root, 'HEAD', false, 'https://example.invalid/core'); + assert.equal(clean.dirty, false); + await assert.rejects(identifyCore(root, 'HEAD', false, 'https://example.invalid/other'), /origin/); + await writeFile(join(root, 'shared/new-input.json'), '{}'); + await assert.rejects(identifyCore(root, 'HEAD', false, 'https://example.invalid/core'), /dirty/); + const dirty = await identifyCore(root, 'HEAD', true, 'https://example.invalid/core'); + assert.equal(dirty.dirty, true); + assert.notEqual(dirty.sourceSha256, clean.sourceSha256); + await writeFile(join(root, 'shared/new-input.json'), '{"changed":true}'); + const changed = await identifyCore(root, 'HEAD', true, 'https://example.invalid/core'); + assert.notEqual(changed.sourceSha256, dirty.sourceSha256); + await writeFile(join(root, 'shared/logging/events/plugins.json'), '{"events":[]}'); + await assert.rejects(identifyCore(root, 'HEAD', true, 'https://example.invalid/core'), /litheLogging/); +}); diff --git a/test/integration.test.mjs b/test/integration.test.mjs index 249f6dd..bd05ace 100644 --- a/test/integration.test.mjs +++ b/test/integration.test.mjs @@ -60,7 +60,7 @@ test('packaged provider loads a real Cargo workspace; build/test/run/clean leave try { await host.close(); } finally { if (backend.exitCode === null) await terminateProcessTree(backend); } }); - const initialized = await host.initialize({ litheBuild: { versions: [1] } }, pathToFileURL(root).href + '/'); + const initialized = await host.initialize({ litheBuild: { versions: [1] }, litheLogging: { versions: [1] } }, pathToFileURL(root).href + '/'); assert.equal(initialized.result.capabilities.experimental.litheBuild.version, 1); const modelPlan = await build('build.modelPlan', { root: '.', options: { offline: true }, tool: { executable: configured.invocation.executables[0].path } }); const loaded = await host.request(modelPlan.plan.provider.method, modelPlan.plan.provider.params); diff --git a/test/provider.test.mjs b/test/provider.test.mjs index b19178d..ff805f5 100644 --- a/test/provider.test.mjs +++ b/test/provider.test.mjs @@ -4,6 +4,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import test from 'node:test'; +import { createBackend, PluginError as SDKPluginError } from '../vendor/lithe-plugin-sdk/src/index.mjs'; +import { TestHost } from '../vendor/lithe-plugin-sdk/test/harness.mjs'; import { modelFromMetadata, registerCargoProvider } from '../io.github.lithe-idea.cargo-build/backend/provider.mjs'; async function fixture(t) { @@ -27,12 +29,12 @@ async function fixture(t) { } class PluginError extends Error { constructor(code, message) { super(message); this.code = code; } } -async function provider(root, run) { +async function provider(root, run, log = () => {}) { const handlers = {}; const backend = { onInitialize: (fn) => { handlers.initialize = fn; }, build: { model: (fn) => { handlers.model = fn; } } }; registerCargoProvider(backend, PluginError); await handlers.initialize({ params: { rootUri: pathToFileURL(root).href + '/' } }); - return (params) => handlers.model(params, { run, throwIfCancelled() {}, log() {} }); + return (params) => handlers.model(params, { run, throwIfCancelled() {}, log }); } test('Cargo membership and same-name target kinds survive deterministic model conversion', { timeout: 5000 }, async (t) => { @@ -103,3 +105,47 @@ test('failed tools, malformed output and cancellation keep distinct outcomes', { log() { assert.fail('Cancellation must not be logged as a tool failure'); } }), (error) => error === cancelled); }); + + +test('metadata failures leave one safe SDK error boundary without copying output into logs', { timeout: 5000 }, async (t) => { + const { root } = await fixture(t); + const logs = []; + const privateOutput = 'private workspace source and environment output'; + const failed = await provider(root, async () => ({ exitCode: 7, stderr: privateOutput }), (...args) => logs.push(args)); + await assert.rejects(failed({ root: '.' }), (error) => { + assert.equal(error.code, 'cargo.metadataFailed'); + assert.ok(!error.message.includes(privateOutput)); + assert.match(error.message, /7/); + return true; + }); + const missing = await provider(root, async () => { throw new Error(privateOutput); }, (...args) => logs.push(args)); + await assert.rejects(missing({ root: '.' }), { code: 'cargo.unavailable' }); + const invalid = await provider(root, async () => ({ exitCode: 0, stdout: privateOutput }), (...args) => logs.push(args)); + await assert.rejects(invalid({ root: '.' }), (error) => { + assert.equal(error.code, 'cargo.invalidMetadata'); + assert.ok(!error.message.includes(privateOutput)); + return true; + }); + assert.deepEqual(logs, [], 'the SDK owns the failure record; the provider must not log and rethrow'); +}); + + +test('the real SDK sends typed metadata failures without raw stderr or duplicate diagnostics', { timeout: 5000 }, async (t) => { + const { root } = await fixture(t); + const backend = createBackend({ name: 'cargo-build' }); + registerCargoProvider(backend, SDKPluginError); + const host = new TestHost(backend); + t.after(() => host.close()); + const initialized = await host.initialize({ litheBuild: { versions: [1] }, litheLogging: { versions: [1] } }, pathToFileURL(root).href); + assert.equal(initialized.result.capabilities.experimental.litheLogging.version, 1); + const response = await host.request('lithe/build/model', { root: '.', tool: { executable: join(root, 'missing-private-tool') } }); + assert.equal(response.error.data.code, 'cargo.unavailable'); + const facts = host.messages.filter((message) => message.method === 'lithe/log' && message.params.operation === 'lithe/build/model'); + assert.equal(facts.length, 1); + assert.equal(facts[0].params.kind, 'operationFailed'); + assert.equal(facts[0].params.code, 'cargo.unavailable'); + assert.ok(Number.isSafeInteger(facts[0].params.durationMs) && facts[0].params.durationMs >= 0); + assert.ok(!JSON.stringify(facts).includes(root)); + assert.deepEqual(Object.keys(facts[0].params).sort(), ['code', 'durationMs', 'kind', 'operation', 'operationId']); + assert.equal(facts[0].params.operationId, '2'); +}); diff --git a/test/support/core.mjs b/test/support/core.mjs index 4b649f0..f45ae98 100644 --- a/test/support/core.mjs +++ b/test/support/core.mjs @@ -1,23 +1,18 @@ import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; import { cp, mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { repository, verifySdk } from '../../scripts/verify-sdk.mjs'; +import { verifyPreparedCore as verifyCoreIdentity } from '../../scripts/core-source.mjs'; import { runProcess } from '../../vendor/lithe-plugin-sdk/src/index.mjs'; export const integration = process.env.LITHE_CARGO_INTEGRATION === '1'; export const integrationSkip = integration ? false : 'Prepare Core and set LITHE_CARGO_INTEGRATION=1 for integration tests.'; -const binary = join(repository, '.artifacts/core-target/debug/examples/cargo-plugin-harness'); +const binary = join(repository, '.artifacts/core-target/cargo-plugin-harness'); let verified; async function verifyPreparedCore() { await verifySdk(); - const lock = JSON.parse(await readFile(join(repository, 'sdk.lock.json'), 'utf8')); - const prepared = JSON.parse(await readFile(join(repository, '.artifacts/core-target/prepared.json'), 'utf8')); - const digest = async (path) => createHash('sha256').update(await readFile(path)).digest('hex'); - assert.equal(prepared.revision, lock.source.revision, 'Run node scripts/prepare-core.mjs after updating the SDK.'); - assert.equal(prepared.harnessSha256, await digest(join(repository, 'test/core-harness.rs'))); - assert.equal(prepared.binarySha256, await digest(binary), 'Core bridge changed after preparation.'); + await verifyCoreIdentity(binary, join(repository, 'test/core-harness.rs')); } /** Call the real public host boundary, with a deadline for every subprocess. */ diff --git a/vendor/lithe-plugin-sdk/src/backend.mjs b/vendor/lithe-plugin-sdk/src/backend.mjs index e9eb1eb..9c28138 100644 --- a/vendor/lithe-plugin-sdk/src/backend.mjs +++ b/vendor/lithe-plugin-sdk/src/backend.mjs @@ -4,30 +4,48 @@ // cancellation, error codes, and the `lithe/*` namespace -- and leaves the // plugin free to decide what its handlers do. // -// Contract: shared/contracts/plugins/v1/README.md ("Build providers" and -// "Plugin functions"). +// Contracts: contract/v1/README.md ("Build providers", "Plugin functions", +// "Host services" and "VCS providers"), and Lithe's +// shared/contracts/vcs-provider-v1.md. import { format } from 'node:util'; +import { performance } from 'node:perf_hooks'; +import { diagnostics, KNOWN_OPERATIONS, requestOperationId } from './diagnostics.mjs'; import { encodeFrame, FrameDecoder, FramingError, MALFORMED } from './framing.mjs'; -import { PluginSdkError, toResponseError } from './errors.mjs'; +import { HostError, PluginError, PluginSdkError, toResponseError } from './errors.mjs'; +import { createHost, HostChannel } from './host.mjs'; import { killAllProcesses, runProcess } from './process.mjs'; import { BUILD_VERSION, + BUILD_METHOD_VERSIONS, boundedText, ERROR_CODES, FUNCTIONS_VERSION, + HOST_NOTIFICATIONS, + isContextReference, isFunctionId, isReservedMethod, + isVcsId, LIMITS, METHODS, + VCS_OPERATIONS, + VCS_REQUIRED_OPERATIONS, + VCS_VERSION, } from './protocol.mjs'; /** Canonical result format of each typed build request. */ const BUILD_FORMATS = Object.freeze({ [METHODS.buildModel]: 'litheBuildModel', [METHODS.buildDependencies]: 'litheDependencies', + [METHODS.buildDiscoverTests]: 'litheTestDiscovery', }); +/** Host notifications the SDK routes itself; see `onCommand`, `onContextsChanged`, and `host.subscribe`. */ +const ROUTED_NOTIFICATIONS = Object.freeze(Object.values(HOST_NOTIFICATIONS)); + +/** VCS capabilities that describe the VCS rather than a request it answers. */ +const VCS_TRAITS = Object.freeze(['push', 'staging']); + /** * Creates a backend. Declare handlers, then call `listen()`. * @@ -35,26 +53,39 @@ const BUILD_FORMATS = Object.freeze({ * @param {string} [options.name] Reported as `serverInfo.name` and used as the log prefix. * @param {string} [options.version] Reported as `serverInfo.version`. * @param {object} [options.capabilities] Standard LSP server capabilities, for a - * backend that is also a language server. `experimental.litheBuild` and - * `experimental.litheFunctions` are negotiated by the SDK and cannot be set here. + * backend that is also a language server. `experimental.litheBuild`, + * `experimental.litheFunctions`, and `experimental.litheVcs` are negotiated by + * the SDK and cannot be set here. */ export function createBackend(options = {}) { return new Backend(options); } class Backend { + #logging = false; + #loggingReady = false; + #earlyDiagnostics = []; + #quietSuccess = new Set(); #name; #version; #capabilities; #functions = new Map(); #buildHandlers = new Map(); + #buildVersion = null; + #functionsVersion = null; + /** The declared VCS provider: `{ id, handlers: Map, capabilities }`. */ + #vcs = null; #requestHandlers = new Map(); #notificationHandlers = new Map(); + #commandHandlers = new Map(); + #contextsHandler = null; + #hostNotificationHandlers = new Map(); + #channel; #initializeHooks = []; #shutdownHooks = []; /** `idle` → `listening` → `initializing` → `ready` → `shuttingDown` → `exited`. */ #phase = 'idle'; - #negotiated = Object.freeze({ functions: false, build: false }); + #negotiated = Object.freeze({ functions: false, build: false, vcs: false }); #initializeParams = null; /** Host requests being handled, by JSON-RPC ID. */ #incoming = new Map(); @@ -70,12 +101,19 @@ class Backend { /** Typed build-provider handlers (`lithe/build/*`). */ build; + /** + * Host services (`lithe/host/*`) of plugin-function protocols 2 and 3, + * usable once the session is ready. A handler's `context.host` is the + * same object bound to its call. + */ + host; + constructor({ name = 'lithe-plugin-backend', version, capabilities = {} } = {}) { const experimental = capabilities.experimental ?? {}; - if ('litheBuild' in experimental || 'litheFunctions' in experimental) { + if ('litheLogging' in experimental || 'litheBuild' in experimental || 'litheFunctions' in experimental || 'litheVcs' in experimental) { throw new PluginSdkError( 'sdk.capabilities.reserved', - 'litheBuild and litheFunctions are negotiated by the SDK; declare handlers instead.', + 'litheBuild, litheFunctions, and litheVcs are negotiated by the SDK; declare handlers instead.', ); } this.#name = String(name); @@ -86,14 +124,35 @@ class Backend { model: (handler) => this.#declareBuild(METHODS.buildModel, handler), /** Answers `lithe/build/dependencies` with a `litheDependencies` document. */ dependencies: (handler) => this.#declareBuild(METHODS.buildDependencies, handler), + /** Runs a bounded task in the provider; the host owns its operation. */ + execute: (handler) => this.#declareBuild(METHODS.buildExecute, handler), + test: (handler) => this.#declareBuild(METHODS.buildTest, handler), + discoverTests: (handler) => this.#declareBuild(METHODS.buildDiscoverTests, handler), + /** Prepares a plan; the application itself belongs to the host. */ + prepareRun: (handler) => this.#declareBuild(METHODS.buildPrepareRun, handler), + prepareDebug: (handler) => this.#declareBuild(METHODS.buildPrepareDebug, handler), + }); + this.#channel = new HostChannel({ + version: () => this.#functionsVersion ?? 0, + ready: () => this.#phase === 'ready', + send: (method, params) => this.#send(method, params), + notify: (method, params) => this.#write({ jsonrpc: '2.0', method, params }), + log: () => this.#diagnostics().degraded('notification', 'event_handler_failed'), }); + this.host = createHost(this.#channel); } - /** Protocols the host agreed to in `initialize`; both false before it. */ + /** Protocols the host agreed to in `initialize`; all false before it. */ get negotiated() { return this.#negotiated; } + /** Selected build protocol version, or null when unavailable. */ + get buildVersion() { return this.#buildVersion; } + + /** Selected plugin-function protocol version (1-3), or null when unavailable. */ + get functionsVersion() { return this.#functionsVersion; } + /** The host's `initialize` params, or null before it arrives. */ get initializeParams() { return this.#initializeParams; @@ -120,6 +179,53 @@ class Backend { return this; } + /** + * Declares the VCS this backend provides and the operations it answers, + * before `listen()`. Each handler gets the operation's arguments plus + * `root: { path }` and `tool: { executable } | null`, and returns the reply + * object of shared/contracts/vcs-provider-v1.md. Declaring a handler + * negotiates its capability; `status`, `baseContent`, `checkin`, and + * `rollback` are required. + * + * @param {string} id The VCS ID of the manifest's `vcsProviders` entry, such as `svn`. + * @param {Record} handlers Keyed by operation name. + * @param {object} [traits] + * @param {boolean} [traits.push] Check-in is local and publishing is a separate step. + * @param {boolean} [traits.staging] Changes carry a staged/unstaged dimension. + */ + vcs(id, handlers, traits = {}) { + if (this.#phase !== 'idle') { + throw new PluginSdkError('sdk.vcs.lateDeclaration', 'Declare the VCS provider before listen().'); + } + if (this.#vcs) throw new PluginSdkError('sdk.vcs.duplicate', 'A backend provides one VCS.'); + if (!isVcsId(id)) { + throw new PluginSdkError( + 'sdk.vcs.invalidID', + `VCS ID ${JSON.stringify(id)} must be lowercase letters, digits, and "-", start with a letter, and be at most ${LIMITS.maxVcsIdBytes} bytes.`, + ); + } + if (id === 'git') throw new PluginSdkError('sdk.vcs.reserved', 'git is provided by Lithe.'); + const methods = new Map(); + const capabilities = {}; + for (const [operation, handler] of Object.entries(handlers ?? {})) { + const declared = VCS_OPERATIONS[operation]; + if (!declared) throw new PluginSdkError('sdk.vcs.unknownOperation', `${operation} is not a VCS operation.`); + if (typeof handler !== 'function') throw new TypeError(`The ${operation} handler must be a function.`); + methods.set(declared.method, handler); + capabilities[declared.capability] = VCS_VERSION; + } + const missing = VCS_REQUIRED_OPERATIONS.filter((operation) => !methods.has(VCS_OPERATIONS[operation].method)); + if (missing.length > 0) { + throw new PluginSdkError('sdk.vcs.missingOperation', `A VCS provider must answer ${missing.join(', ')}.`); + } + for (const [trait, enabled] of Object.entries(traits)) { + if (!VCS_TRAITS.includes(trait)) throw new PluginSdkError('sdk.vcs.unknownTrait', `${trait} is not a VCS trait.`); + if (enabled) capabilities[trait] = VCS_VERSION; + } + this.#vcs = { id, methods, capabilities: sortedKeys(capabilities) }; + return this; + } + /** Handles a standard LSP request, for a backend that is also a language server. */ onRequest(method, handler) { this.#declareHandler(this.#requestHandlers, method, handler); @@ -132,6 +238,47 @@ class Backend { return this; } + /** + * Handles `lithe/host/commands/didRun` for one of the plugin's commands + * (protocol 3). The handler gets `{ commandID, workspaceContext }`, the + * context of the window it ran in or null. It is an event: the host waits + * for no result. + */ + onCommand(commandID, handler) { + if (typeof commandID !== 'string' || !/^[a-z0-9.-]{1,128}$/.test(commandID)) { + throw new PluginSdkError('sdk.command.invalidID', `Command ID ${JSON.stringify(commandID)} must be 1-128 of [a-z0-9.-].`); + } + if (typeof handler !== 'function') throw new TypeError('A command handler must be a function.'); + this.#commandHandlers.set(commandID, handler); + return this; + } + + /** + * Handles `lithe/host/workspace/didChangeContexts` (protocol 3) with + * `{ revision }`; call `host.workspace.listContexts()` for the contexts. + */ + onContextsChanged(handler) { + if (typeof handler !== 'function') throw new TypeError('A handler must be a function.'); + this.#contextsHandler = handler; + return this; + } + + /** + * Handles a `lithe/host/*` notification a later host adds. Commands, + * context changes, and subscription events have their own APIs. + */ + onHostNotification(method, handler) { + if (typeof method !== 'string' || !method.startsWith('lithe/host/')) { + throw new PluginSdkError('sdk.method.invalid', `${method} is not a host notification.`); + } + if (ROUTED_NOTIFICATIONS.includes(method)) { + throw new PluginSdkError('sdk.method.reserved', `${method} is routed by the SDK; use onCommand, onContextsChanged, or host.subscribe.`); + } + if (typeof handler !== 'function') throw new TypeError('A handler must be a function.'); + this.#hostNotificationHandlers.set(method, handler); + return this; + } + /** Runs before the `initialize` answer, with `{ params, negotiated }`. */ onInitialize(hook) { this.#initializeHooks.push(hook); @@ -166,6 +313,7 @@ class Backend { await this.#request(METHODS.functionRegister, { functions: [{ id }] }); } catch (error) { this.#functions.delete(id); + this.#quietSuccess.delete(id); throw error; } }); @@ -178,6 +326,7 @@ class Backend { if (!this.#functions.delete(id)) { throw new PluginSdkError('sdk.function.unknown', `Function ${id} is not registered.`); } + this.#quietSuccess.delete(id); }); } return this.#queueRegistration(async () => { @@ -187,6 +336,7 @@ class Backend { } await this.#request(METHODS.functionUnregister, { ids: [id] }); this.#functions.delete(id); + this.#quietSuccess.delete(id); for (const call of this.#incoming.values()) { if (call.functionID === id) call.controller.abort(); } @@ -205,9 +355,20 @@ class Backend { this.#write({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) }); } - /** Writes a diagnostic line to stderr, which the host keeps in its log. */ - log(...parts) { - this.#errorOutput.write(`[${this.#name}] ${format(...parts)}\n`); + get diagnostics() { return this.#diagnostics(); } + + suppressSuccessDiagnostics(operation) { + if (!KNOWN_OPERATIONS.has(operation) && !this.#functions.has(operation)) throw new PluginSdkError('sdk.logging.invalidOperation', 'Suppress a registered operation.'); + this.#quietSuccess.add(operation); + return this; + } + + #diagnostics(operationId) { + return diagnostics((params) => { + if (!this.#logging) return; + if (!this.#loggingReady) { if (this.#earlyDiagnostics.length < 8) this.#earlyDiagnostics.push(params); return; } + this.#write({ jsonrpc: '2.0', method: 'lithe/log', params }); + }, (id) => this.#functions.has(id), operationId); } /** @@ -236,6 +397,8 @@ class Backend { this.#phase = 'exited'; this.#abortAll(); killAllProcesses(); + // Before the pending requests fail, so a freed slot starts nothing. + this.#channel.close(); for (const pending of this.#outgoing.values()) { pending.reject(new PluginSdkError('sdk.session.closed', 'The session ended.')); } @@ -251,7 +414,7 @@ class Backend { messages = decoder.push(chunk); } catch (error) { if (!(error instanceof FramingError)) throw error; - this.log('Ending the session:', error.message); + this.#diagnostics().terminal('notification', {code:'framing_error'}, 0); this.#finish(1); return; } @@ -307,8 +470,13 @@ class Backend { return; } const build = this.#buildHandlers.get(method); - if (build && this.#negotiated.build) { - this.#run(id, null, (context) => build(params ?? {}, context), (result) => buildResult(method, result)); + if (build && this.#buildVersion >= BUILD_METHOD_VERSIONS[method]) { + this.#invokeBuild(id, method, params ?? {}, build); + return; + } + const vcs = this.#negotiated.vcs ? this.#vcs.methods.get(method) : undefined; + if (vcs) { + this.#run(id, null, (context) => vcs(params ?? {}, this.#vcsContext(context)), (result) => vcsResult(method, result), undefined, method, params?.operationID); return; } const handler = this.#requestHandlers.get(method); @@ -329,11 +497,45 @@ class Backend { return; } if (this.#phase !== 'ready' || method === METHODS.initialized) return; + if (typeof method === 'string' && method.startsWith('lithe/host/')) { + this.#hostNotification(method, params); + return; + } const handler = this.#notificationHandlers.get(method); if (!handler) return; Promise.resolve() .then(() => handler(params)) - .catch((error) => this.log(`Notification ${method} failed:`, error)); + .catch((error) => this.#diagnostics().terminal('notification', { code: failureKind(error) }, 0)); + } + + /** + * Routes a host notification. Core checked its shape and the session's + * grant; one this SDK does not know is ignored, as the contract asks. + */ + #hostNotification(method, params) { + if ((this.#functionsVersion ?? 0) < 3 || !isObject(params)) return; + if (method === HOST_NOTIFICATIONS.subscriptionNotify) { + this.#channel.deliver(params); + return; + } + let handler; + let event = params; + if (method === HOST_NOTIFICATIONS.commandDidRun) { + handler = this.#commandHandlers.get(params.commandID); + event = { commandID: params.commandID, workspaceContext: isContextReference(params.context) ? params.context : null }; + if (!handler) { + this.#diagnostics().degraded('notification', 'command_unhandled'); + return; + } + } else if (method === HOST_NOTIFICATIONS.didChangeContexts) { + handler = this.#contextsHandler; + } else { + handler = this.#hostNotificationHandlers.get(method); + } + if (!handler) return; + Promise.resolve() + .then(() => handler(event)) + .catch((error) => this.#diagnostics().terminal('notification', { code: failureKind(error) }, 0)); } async #initialize(id, params) { @@ -344,17 +546,29 @@ class Backend { this.#phase = 'initializing'; this.#initializeParams = params; const offered = params.capabilities?.experimental ?? {}; + this.#loggingReady = false; + this.#earlyDiagnostics = []; + this.#logging = offered.litheLogging?.versions?.includes(1) === true; + if (!this.#logging) { + this.#phase = 'listening'; + this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'The backend requires litheLogging version 1.' }); + return; + } const offers = (entry, version) => Array.isArray(entry?.versions) && entry.versions.includes(version); + const highest = (entry, latest) => Array.from({ length: latest }, (_, index) => latest - index) + .find((version) => offers(entry, version)) ?? null; + this.#buildVersion = this.#buildHandlers.size > 0 ? highest(offered.litheBuild, BUILD_VERSION) : null; + this.#functionsVersion = highest(offered.litheFunctions, FUNCTIONS_VERSION); this.#negotiated = Object.freeze({ - functions: offers(offered.litheFunctions, FUNCTIONS_VERSION), - build: this.#buildHandlers.size > 0 && offers(offered.litheBuild, BUILD_VERSION), + functions: this.#functionsVersion !== null, + build: this.#buildVersion !== null, + vcs: this.#vcs !== null && offers(offered.litheVcs, VCS_VERSION), }); try { for (const hook of this.#initializeHooks) { await hook({ params, negotiated: this.#negotiated }); } } catch (error) { - this.log('initialize failed:', error); if (this.#phase === 'initializing') this.#phase = 'listening'; this.#respond(id, undefined, toResponseError(error)); return; @@ -366,13 +580,18 @@ class Backend { return; } if (!this.#negotiated.functions && this.#functions.size > 0) { - this.log('The host did not offer litheFunctions version 1; functions stay unavailable.'); + this.#diagnostics().degraded('initialize', 'functions_unavailable'); + } + const experimental = { ...(this.#capabilities.experimental ?? {}), litheLogging: { version: 1 } }; + if (this.#negotiated.build) experimental.litheBuild = { version: this.#buildVersion }; + if (this.#negotiated.vcs) { + experimental.litheVcs = { version: VCS_VERSION, vcs: this.#vcs.id, capabilities: this.#vcs.capabilities }; + } else if (this.#vcs) { + this.#diagnostics().degraded('initialize', 'vcs_unavailable'); } - const experimental = { ...(this.#capabilities.experimental ?? {}) }; - if (this.#negotiated.build) experimental.litheBuild = { version: BUILD_VERSION }; if (this.#negotiated.functions) { experimental.litheFunctions = { - version: FUNCTIONS_VERSION, + version: this.#functionsVersion, functions: this.functionIDs.map((functionID) => ({ id: functionID })), }; } @@ -381,6 +600,8 @@ class Backend { capabilities: { ...this.#capabilities, experimental }, serverInfo: { name: this.#name, ...(this.#version ? { version: this.#version } : {}) }, }); + this.#loggingReady = true; + for (const params of this.#earlyDiagnostics.splice(0)) this.#write({ jsonrpc: '2.0', method: 'lithe/log', params }); } async #shutdown(id) { @@ -394,14 +615,41 @@ class Backend { try { await hook(); } catch (error) { - this.log('A shutdown hook failed:', error); + this.#diagnostics().terminal('shutdown', { code: failureKind(error) }, 0); } } killAllProcesses(); this.#respond(id, null); } - #invokeFunction(id, { functionID, operationID, params }) { + #invokeBuild(id, method, params, handler) { + if (Object.hasOwn(params ?? {}, 'modelInputs') && this.#buildVersion < 6) { + this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'Model inputs require build protocol 6.' }); + return; + } + const progressing = [METHODS.buildExecute, METHODS.buildTest, METHODS.buildPrepareRun, METHODS.buildPrepareDebug].includes(method); + if (!params || typeof params !== 'object' || Array.isArray(params) + || (progressing && typeof params.operationID !== 'string')) { + this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'A build request needs object params and its wire operationID.' }); + return; + } + const operationID = params.operationID; + let reporting = true; + this.#run(id, null, (context) => handler(params, progressing ? { + ...context, + operationID, + progress: ({ message, percentage, output } = {}) => { + if (!reporting || context.signal.aborted) return; + const report = { operationID }; + if (message !== undefined) report.message = boundedText(message, LIMITS.maxBuildMessageBytes); + if (output !== undefined) report.output = boundedText(output, LIMITS.maxBuildOutputBytes); + if (Number.isFinite(percentage)) report.percentage = Math.min(100, Math.max(0, Math.round(percentage))); + this.#write({ jsonrpc: '2.0', method: METHODS.buildProgress, params: report }); + }, + } : context), (result) => buildResult(method, result), () => { reporting = false; }, method, operationID); + } + + #invokeFunction(id, { functionID, operationID, params, context: workspaceContext }) { const handler = this.#negotiated.functions ? this.#functions.get(functionID) : undefined; if (!handler) { this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: `Function ${functionID} is not registered.` }); @@ -418,22 +666,36 @@ class Backend { this.#run( id, functionID, - (context) => handler(params ?? {}, { ...context, functionID, operationID, progress }), + (context) => handler(params ?? {}, { + ...context, + functionID, + operationID, + // The page's window, from protocol 3; null for a call without one. + workspaceContext: isContextReference(workspaceContext) ? workspaceContext : null, + progress, + }), functionResult, () => { reporting = false; }, + functionID, + operationID, ); } /** Runs one handler with its own cancellation and answers exactly once. */ - #run(id, functionID, invoke, shape, onSettled) { + #run(id, functionID, invoke, shape, onSettled, operation = functionID ?? 'function-call', operationId) { + const started = performance.now(); + const diagnostic = this.#diagnostics(requestOperationId(operationId, id)); + let quietSuccess = false; const controller = new AbortController(); const key = requestKey(id); this.#incoming.set(key, { controller, functionID }); const { signal } = controller; const context = { signal, + diagnostics: diagnostic, + suppressSuccessDiagnostics: () => { quietSuccess = true; }, throwIfCancelled: () => signal.throwIfAborted(), run: (command, args, options = {}) => { const running = runProcess(command, args, { @@ -445,7 +707,7 @@ class Backend { running.catch(() => {}); return running; }, - log: (...parts) => this.log(...parts), + host: createHost(this.#channel, signal), }; Promise.resolve() .then(() => invoke(context)) @@ -455,16 +717,15 @@ class Backend { return { result: shape(value) }; }, (error) => { - if (!signal.aborted) this.log(`${functionID ? `Function ${functionID}` : 'A request'} failed:`, error); return { error: toResponseError(error, signal) }; }, ) .catch((error) => { // Shaping the result failed, for example a build result in another format. - if (!signal.aborted) this.log('The result could not be sent:', error); return { error: toResponseError(error, signal) }; }) .then(({ result, error }) => { + if (!signal.aborted && (error || (!quietSuccess && !this.#quietSuccess.has(operation)))) diagnostic.terminal(operation, error ? { code: error.data?.code ?? 'internal_error' } : null, performance.now() - started); onSettled?.(); this.#incoming.delete(key); // Work belongs to its call: tools still running when the handler @@ -475,6 +736,44 @@ class Backend { }); } + /** + * The request context of a VCS handler. Its `run` reports every finished + * command to the host as `lithe/vcs/execution`, for the project console, + * unless the call passes `report: false`. Arguments are reported as given, + * so they must never carry a credential. + */ + #vcsContext(context) { + const report = (command, args, started, outcome) => { + const params = { + command: [String(command), ...args.map(String)], + exitCode: outcome.exitCode ?? null, + durationMilliseconds: Math.max(0, Math.round(performance.now() - started)), + stderr: boundedText(outcome.stderr ?? '', LIMITS.maxExecutionErrorOutputBytes), + }; + this.#write({ jsonrpc: '2.0', method: METHODS.vcsExecution, params }); + }; + return { + ...context, + run: (command, args = [], { report: reporting = true, ...options } = {}) => { + const started = performance.now(); + const running = context.run(command, args, options); + if (!reporting) return running; + const reported = running.then( + (result) => { + report(command, args, started, result); + return result; + }, + (error) => { + if (!context.signal.aborted) report(command, args, started, { exitCode: null, stderr: error?.message }); + throw error; + }, + ); + reported.catch(() => {}); + return reported; + }, + }; + } + #declareBuild(method, handler) { if (this.#phase !== 'idle') { throw new PluginSdkError('sdk.build.lateDeclaration', 'Declare build handlers before listen().'); @@ -538,11 +837,17 @@ class Backend { } #request(method, params) { + return this.#send(method, params).response; + } + + /** Writes a request to the host; `response` settles with its answer. */ + #send(method, params) { const id = `sdk-${this.#nextRequestId++}`; - return new Promise((resolve, reject) => { + const response = new Promise((resolve, reject) => { this.#outgoing.set(id, { resolve, reject, method }); this.#write({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) }); }); + return { id, response }; } #settleOutgoing({ id, result, error }) { @@ -550,9 +855,7 @@ class Backend { if (!pending) return; this.#outgoing.delete(id); if (error) { - const failure = new PluginSdkError(error.data?.code ?? 'sdk.host.error', error.message ?? `${pending.method} failed.`); - failure.rpcCode = error.code; - pending.reject(failure); + pending.reject(HostError.fromResponse(pending.method, error)); } else { pending.resolve(result ?? null); } @@ -569,7 +872,7 @@ class Backend { } catch (failure) { // A result JSON cannot encode, such as a BigInt or a cycle, must not // become an unhandled rejection that ends the whole session. - this.log('The result could not be encoded:', failure); + this.#diagnostics().terminal('result-encoding', { code: 'result_encoding_failed' }, 0); this.#write({ jsonrpc: '2.0', id, error: toResponseError(failure) }); } } @@ -580,6 +883,12 @@ class Backend { } } +// Errors can include request bodies, credentials and paths. Only the category +// crosses the diagnostic boundary; the response keeps its existing error contract. +function failureKind(error) { + return error instanceof PluginError ? 'plugin_error' : 'internal_error'; +} + /** Runs `change` now and reports its outcome as a promise. */ function settle(change) { try { @@ -602,16 +911,64 @@ function requestKey(id) { */ function buildResult(method, result) { const expected = BUILD_FORMATS[method]; + if (!isObject(result)) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} must return a reply object.`); + } + let document = result; + if (expected) { + if (result.format !== undefined && result.format !== expected) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} returned format ${result.format}, not ${expected}.`); + } + if (result.version !== undefined && result.version !== 1) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} returned version ${result.version}, not 1.`); + } + document = { ...result, format: expected, version: 1 }; + } + if ([METHODS.buildExecute, METHODS.buildTest].includes(method) + && (!Number.isInteger(result.exitCode) || result.exitCode < -2147483648 || result.exitCode > 2147483647 + || typeof result.output !== 'string' || Buffer.byteLength(result.output) > LIMITS.maxBuildResultBytes)) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} needs an int32 exitCode and bounded output.`); + } + if (method === METHODS.buildPrepareRun + && (!isObject(result.executable) || !['toolchain', 'command'].includes(result.executable.kind) + || typeof result.executable.value !== 'string' || typeof result.workingDirectory !== 'string' + || !Array.isArray(result.arguments) || !result.arguments.every((value) => typeof value === 'string') + || !isObject(result.environment) || !Object.values(result.environment).every((value) => typeof value === 'string'))) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} needs a portable launch plan.`); + } + if (method === METHODS.buildPrepareDebug + && (typeof result.program !== 'string' || typeof result.workingDirectory !== 'string' + || !Array.isArray(result.arguments) || !result.arguments.every((value) => typeof value === 'string') + || !isObject(result.environment) || !Object.values(result.environment).every((value) => typeof value === 'string') + || Object.keys(result).some((key) => !['program', 'arguments', 'workingDirectory', 'environment'].includes(key)))) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} needs native debug target facts, without an adapter or DAP arguments.`); + } + // Workspace containment and domain facts remain Core/provider responsibilities. + if (Buffer.byteLength(JSON.stringify(document)) > LIMITS.maxBuildResultBytes) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} exceeds the build-result size limit.`); + } + return document; +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * A VCS reply must be a JSON object; Core validates its fields and refuses + * anything else as `plugin.vcs.invalidOutput`, so it fails here with a log. + */ +function vcsResult(method, result) { + if (result === undefined) return {}; if (result === null || typeof result !== 'object' || Array.isArray(result)) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} must return a ${expected} object.`); - } - if (result.format !== undefined && result.format !== expected) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} returned format ${result.format}, not ${expected}.`); - } - if (result.version !== undefined && result.version !== 1) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} returned version ${result.version}, not 1.`); + throw new PluginSdkError('sdk.vcs.invalidResult', `${method} must return a reply object.`); } - return { ...result, format: expected, version: 1 }; + return result; +} + +/** A copy of `object` with its keys sorted, for a stable `initialize` answer. */ +function sortedKeys(object) { + return Object.fromEntries(Object.entries(object).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))); } /** diff --git a/vendor/lithe-plugin-sdk/src/diagnostics.mjs b/vendor/lithe-plugin-sdk/src/diagnostics.mjs new file mode 100644 index 0000000..c168a5c --- /dev/null +++ b/vendor/lithe-plugin-sdk/src/diagnostics.mjs @@ -0,0 +1,33 @@ +// Typed plugin diagnostics use the host's catalog and writer. No payload text crosses this API. +import { PluginSdkError } from './errors.mjs'; + +export const KNOWN_OPERATIONS = new Set([ + 'initialize', 'shutdown', 'notification', 'function-call', 'result-encoding', + ...['model', 'dependencies', 'execute', 'test', 'discoverTests', 'prepareRun', 'prepareDebug'].map((name) => `lithe/build/${name}`), + ...['status', 'baseContent', 'history', 'fileHistory', 'annotate', 'checkin', 'rollback', 'update', 'fileOperation', 'resolve', 'cleanup'].map((name) => `lithe/vcs/${name}`), +]); +const token = (value, maximum) => typeof value === 'string' && value.length > 0 && value.length <= maximum && /^[A-Za-z0-9._:/\-$]+$/.test(value); +export function requestOperationId(operationId, requestId) { + if (token(operationId, 128)) return operationId; + const fallback = typeof requestId === 'string' ? requestId : Number.isSafeInteger(requestId) ? String(requestId) : undefined; + return token(fallback, 128) ? fallback : undefined; +} +export function diagnostics(send, registered, operationId) { + const record = (kind, operation, code, count, durationMs) => { + if (!KNOWN_OPERATIONS.has(operation) && !registered(operation)) throw new PluginSdkError('sdk.logging.invalidOperation', 'Diagnostics need a registered operation.'); + if (code !== undefined && !token(code, 64)) throw new PluginSdkError('sdk.logging.invalidCode', 'Diagnostics need a controlled code.'); + if (count !== undefined && (!Number.isSafeInteger(count) || count < 0)) throw new PluginSdkError('sdk.logging.invalidCount', 'Diagnostics need a nonnegative safe count.'); + const params = { kind, operation: operation.length <= 64 ? operation : 'function-call' }; + if (code !== undefined) params.code = code; + if (count !== undefined) params.count = count; + if (durationMs !== undefined) params.durationMs = Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, Math.floor(durationMs))); + if (token(operationId, 128)) params.operationId = operationId; + send(params); + }; + return Object.freeze({ + degraded: (operation, code, count) => record('degraded', operation, code, count), + recovered: (operation, code, count) => record('recovered', operation, code, count), + terminal: (operation, error, durationMs = 0) => record(error ? 'operationFailed' : 'operationFinished', operation, + error ? (token(error.code, 64) ? error.code : 'internal_error') : undefined, undefined, durationMs), + }); +} diff --git a/vendor/lithe-plugin-sdk/src/errors.mjs b/vendor/lithe-plugin-sdk/src/errors.mjs index 7df9ace..048cae0 100644 --- a/vendor/lithe-plugin-sdk/src/errors.mjs +++ b/vendor/lithe-plugin-sdk/src/errors.mjs @@ -1,4 +1,4 @@ -import { boundedText, ERROR_CODES, isPluginErrorCode } from './protocol.mjs'; +import { boundedText, ERROR_CODES, HOST_ERRORS, isPluginErrorCode } from './protocol.mjs'; /** * A failure a handler reports on purpose. Core ends the call with the stable @@ -9,7 +9,7 @@ export class PluginError extends Error { /** * @param {string} code Identifier of at most 64 bytes, such as `maven.snapshotLocked`. * @param {string} message User-facing text; Core keeps the first 1024 bytes. - * @param {object} [options] `{ cause }`, kept for logs only. + * @param {object} [options] `{ cause }`, retained in memory and omitted from automatic diagnostics. */ constructor(code, message, options) { super(message, options); @@ -32,6 +32,47 @@ export class PluginSdkError extends Error { } } +const HOST_CODES = new Map(Object.entries(HOST_ERRORS).map(([code, rpcCode]) => [rpcCode, code])); + +/** + * The host refused or failed a request this backend sent. `code` is the + * stable `error.data.code`, such as `plugin.host.contextExpired`; `rpcCode` + * the JSON-RPC number; `details` the object a version 3 host may add, such + * as `{ option }` or `{ retryAfterMilliseconds }`. + */ +export class HostError extends PluginSdkError { + constructor(code, message, { rpcCode, details, method } = {}) { + super(code, message); + this.name = 'HostError'; + this.rpcCode = rpcCode; + this.details = details; + this.method = method; + } + + /** + * The failure for a JSON-RPC error answer. A host method without a known + * code is a host failure: from version 3 a number names one code, and an + * unknown number is `plugin.host.failed`. + */ + static fromResponse(method, error) { + const data = error?.data; + const isHost = method.startsWith('lithe/host/'); + const code = typeof data?.code === 'string' + ? data.code + : isHost + ? HOST_CODES.get(error?.code) ?? 'plugin.host.failed' + : 'sdk.host.error'; + const details = data?.details !== null && typeof data?.details === 'object' && !Array.isArray(data.details) + ? data.details + : undefined; + return new HostError(code, typeof error?.message === 'string' ? error.message : `${method} failed.`, { + rpcCode: error?.code, + details, + method, + }); + } +} + /** True for the rejection an aborted `AbortSignal` produces. */ export function isAbort(error, signal) { return Boolean(signal?.aborted) && (error === signal.reason || error?.name === 'AbortError'); @@ -55,6 +96,6 @@ export function toResponseError(error, signal) { }; } // Unexpected exceptions often embed absolute paths (ENOENT) or internals; - // the caller logs the details to stderr instead. + // the caller logs only a fixed failure category to stderr. return { code: ERROR_CODES.internalError, message: 'The plugin backend failed unexpectedly.' }; } diff --git a/vendor/lithe-plugin-sdk/src/host.mjs b/vendor/lithe-plugin-sdk/src/host.mjs new file mode 100644 index 0000000..87640c9 --- /dev/null +++ b/vendor/lithe-plugin-sdk/src/host.mjs @@ -0,0 +1,369 @@ +// Host services of plugin-function protocols 2 and 3: the `lithe/host/*` +// requests a backend sends to Lithe, the subscriptions some of them create, +// and the `host` object handlers use. The backend owns the wire; this module +// owns what Core expects of a well-behaved client -- at most 16 unanswered +// host requests, cancellation through `$/cancelRequest` from version 3, and +// subscription events routed by ID. +// +// Contract: contract/v1/README.md, "Host services (protocol version 2)" and +// the version 3 sections after it. + +import { HostError, PluginSdkError } from './errors.mjs'; +import { + CONTEXT_VERSION, + HOST_METHODS, + HOST_SERVICES_VERSION, + isContextReference, + isCredentialKey, + LIMITS, + METHODS, +} from './protocol.mjs'; + +/** Events kept for a subscription whose creating request is still unanswered. */ +const MAX_EARLY_EVENTS = 128; + +const CREDENTIAL_METHODS = Object.freeze([ + HOST_METHODS.credentialsGet, + HOST_METHODS.credentialsSet, + HOST_METHODS.credentialsDelete, +]); + +/** + * The session's host-request state. `transport` is the backend's wire: + * `{ version(), ready(), send(method, params) -> { id, response }, notify(method, params), log(...) }`. + */ +export class HostChannel { + #transport; + #inFlight = 0; + /** Requests waiting for a free slot, in call order: `{ start, fail }`. */ + #queue = []; + /** Live subscriptions by ID: `{ onEvent, lastSequence }`. */ + #subscriptions = new Map(); + /** Subscribing requests not yet answered; early events wait only while there are some. */ + #subscribing = 0; + #early = []; + #capabilities = null; + #closed = false; + + constructor(transport) { + this.#transport = transport; + } + + /** The negotiated plugin-function version, or 0 before or without one. */ + get version() { + return this.#transport.version(); + } + + request(method, params, { signal } = {}) { + if (typeof method !== 'string' || !method.startsWith('lithe/host/')) { + return Promise.reject(new PluginSdkError('sdk.host.method', `${method} is not a host method; host requests are lithe/host/*.`)); + } + if (this.#closed || !this.#transport.ready()) { + return Promise.reject(new PluginSdkError('sdk.session.notReady', 'The session is not initialized.')); + } + if (this.version < HOST_SERVICES_VERSION) { + // The answer Core would give, without the round trip. + return Promise.reject(new HostError( + 'plugin.host.unavailable', + 'The host did not negotiate plugin-function protocol 2 or later, which offers host services.', + { rpcCode: -32601, method }, + )); + } + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + // `queued` → `sent` → `done`; a request settles for its caller once. + let state = 'queued'; + let id; + const done = () => { + state = 'done'; + signal?.removeEventListener('abort', onAbort); + }; + const entry = { + start: () => { + state = 'sent'; + this.#inFlight += 1; + const sent = this.#transport.send(method, params); + id = sent.id; + sent.response + .then( + (result) => { + if (state === 'done') return; + done(); + resolve(result); + }, + (error) => { + if (state === 'done') return; + done(); + reject(error); + }, + ) + .finally(() => { + // The slot frees when Core answers, also after a cancellation: + // from version 3 Core answers it at once, before version 3 the + // host still finishes the request. + this.#inFlight -= 1; + this.#drain(); + }); + }, + fail: (error) => { + if (state === 'done') return; + done(); + reject(error); + }, + }; + const onAbort = () => { + if (state === 'done') return; + if (state === 'queued') { + this.#queue.splice(this.#queue.indexOf(entry), 1); + } else if (this.version >= CONTEXT_VERSION) { + this.#transport.notify(METHODS.cancelRequest, { id }); + } + // A cancelled mutation may already have taken effect; it is never + // sent again. + done(); + reject(signal.reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + this.#queue.push(entry); + this.#drain(); + }); + } + + /** + * Sends a request whose result is `{ subscriptionID }` and routes that + * subscription's events to `onEvent(event, { subscriptionID, sequence, gap })`. + */ + async subscribe(method, params, onEvent, options) { + if (onEvent !== undefined && typeof onEvent !== 'function') { + throw new TypeError('A subscription event handler must be a function.'); + } + this.#subscribing += 1; + try { + const result = await this.request(method, params, options); + const subscriptionID = result?.subscriptionID; + if (!isCredentialKey(subscriptionID)) { + throw new PluginSdkError('sdk.host.invalidResult', `${method} answered without a subscriptionID.`); + } + const subscription = { onEvent: onEvent ?? (() => {}), lastSequence: 0, live: true }; + this.#subscriptions.set(subscriptionID, subscription); + // Events the host sent before this answer was read. + const early = this.#early.filter((event) => event.subscriptionID === subscriptionID); + this.#early = this.#early.filter((event) => event.subscriptionID !== subscriptionID); + for (const event of early) this.#emit(subscriptionID, subscription, event); + return Object.freeze({ + subscriptionID, + get disposed() { + return !subscription.live; + }, + dispose: () => this.dispose(subscriptionID), + }); + } finally { + this.#subscribing -= 1; + if (this.#subscribing === 0) this.#early = []; + } + } + + /** Stops routing a subscription's events at once and asks the host to dispose it. */ + async dispose(subscriptionID) { + const subscription = this.#subscriptions.get(subscriptionID); + if (subscription) subscription.live = false; + this.#subscriptions.delete(subscriptionID); + if (this.#closed || !this.#transport.ready()) return; + await this.request(HOST_METHODS.subscriptionsDispose, { subscriptionID }); + } + + /** Routes one `lithe/host/subscriptions/notify`. */ + deliver({ subscriptionID, sequence, event }) { + const subscription = this.#subscriptions.get(subscriptionID); + if (subscription) { + this.#emit(subscriptionID, subscription, { sequence, event }); + } else if (this.#subscribing > 0) { + if (this.#early.length < MAX_EARLY_EVENTS) { + this.#early.push({ subscriptionID, sequence, event }); + } else { + this.#transport.log('Dropped an early subscription event: queue_full'); + } + } + // Otherwise the subscription was disposed; nothing follows a disposal. + } + + /** The cached `getCapabilities` answer; Core answers it itself, from version 3. */ + capabilities() { + if (this.version < CONTEXT_VERSION) { + return Promise.reject(new HostError( + 'plugin.host.unavailable', + 'Capability discovery needs plugin-function protocol 3.', + { rpcCode: -32601, method: HOST_METHODS.getCapabilities }, + )); + } + this.#capabilities ??= this.request(HOST_METHODS.getCapabilities).catch((error) => { + this.#capabilities = null; + throw error; + }); + return this.#capabilities; + } + + /** + * Whether this session may call `method`. From version 3 the answer comes + * from `getCapabilities`; version 2 has no discovery, so it reports the + * methods version 2 defines, and a missing grant still fails the request. + */ + async supports(method) { + if (this.version >= CONTEXT_VERSION) { + const { services } = await this.capabilities(); + return Object.values(services ?? {}).some((service) => Array.isArray(service?.methods) && service.methods.includes(method)); + } + return this.version >= HOST_SERVICES_VERSION && CREDENTIAL_METHODS.includes(method); + } + + /** Ends the channel with the session: queued requests fail, subscriptions end. */ + close() { + this.#closed = true; + for (const entry of this.#queue.splice(0)) { + entry.fail(new PluginSdkError('sdk.session.closed', 'The session ended.')); + } + for (const subscription of this.#subscriptions.values()) subscription.live = false; + this.#subscriptions.clear(); + this.#early = []; + this.#capabilities = null; + } + + #emit(subscriptionID, subscription, { sequence, event }) { + const gap = sequence !== subscription.lastSequence + 1; + subscription.lastSequence = sequence; + Promise.resolve() + .then(() => subscription.onEvent(event, { subscriptionID, sequence, gap })) + .catch(() => this.#transport.log('A subscription listener failed: internal_error')); + } + + #drain() { + while (!this.#closed && this.#inFlight < LIMITS.maxPendingHostRequests && this.#queue.length > 0) { + this.#queue.shift().start(); + } + } +} + +/** + * The `host` object: typed host services over `channel`. A handler's + * `context.host` passes the call's signal, so its unanswered host requests + * are cancelled when the call ends. + */ +export function createHost(channel, callSignal) { + const withSignal = (options = {}) => { + if (!callSignal) return options; + return { ...options, signal: options.signal ? AbortSignal.any([callSignal, options.signal]) : callSignal }; + }; + const request = (method, params, options) => channel.request(method, params, withSignal(options)); + const subscribe = (method, params, onEvent, options) => channel.subscribe(method, params, onEvent, withSignal(options)); + /** A method whose params name a workspace context. */ + const scoped = (method) => async (context, params = {}, options) => + request(method, { ...params, context: contextReference(context) }, options); + + return Object.freeze({ + get version() { + return channel.version; + }, + request: async (method, params, options) => request(method, params, options), + subscribe: async (method, params, onEvent, options) => subscribe(method, params, onEvent, options), + capabilities: () => channel.capabilities(), + supports: (method) => channel.supports(method), + credentials: Object.freeze({ + async get(key, options) { + checkCredentialKey(key); + const result = await request(HOST_METHODS.credentialsGet, { key }, options); + if (result === null) return null; + if (typeof result?.secret !== 'string') { + throw new PluginSdkError('sdk.host.invalidResult', 'The host answered credentials/get without a secret.'); + } + return result.secret; + }, + async set(key, secret, options) { + checkCredentialKey(key); + if (typeof secret !== 'string' || Buffer.byteLength(secret) > LIMITS.maxCredentialSecretBytes) { + throw new PluginSdkError('sdk.credentials.invalidSecret', 'A secret is a string of at most 16 KiB.'); + } + await request(HOST_METHODS.credentialsSet, { key, secret }, options); + }, + async delete(key, options) { + checkCredentialKey(key); + await request(HOST_METHODS.credentialsDelete, { key }, options); + }, + }), + subscriptions: Object.freeze({ + dispose: async (subscriptionID) => channel.dispose(subscriptionID), + }), + workspace: Object.freeze({ + listContexts: async (options) => request(HOST_METHODS.workspaceListContexts, {}, options), + getContext: async (context, options) => + request(HOST_METHODS.workspaceGetContext, { context: contextReference(context) }, options), + getFolders: async (context, options) => + request(HOST_METHODS.workspaceGetFolders, { context: contextReference(context) }, options), + readDirectory: scoped(HOST_METHODS.workspaceReadDirectory), + stat: scoped(HOST_METHODS.workspaceStat), + readFile: scoped(HOST_METHODS.workspaceReadFile), + watch: async (context, params, onEvent, options) => + subscribe(HOST_METHODS.workspaceWatch, { ...params, context: contextReference(context) }, onEvent, options), + findFiles: scoped(HOST_METHODS.workspaceFindFiles), + findText: scoped(HOST_METHODS.workspaceFindText), + }), + documents: Object.freeze({ + list: scoped(HOST_METHODS.documentsList), + getSnapshot: scoped(HOST_METHODS.documentsGetSnapshot), + }), + editor: Object.freeze({ + getActive: scoped(HOST_METHODS.editorGetActive), + getSelections: scoped(HOST_METHODS.editorGetSelections), + applyEdits: scoped(HOST_METHODS.editorApplyEdits), + replaceSelections: scoped(HOST_METHODS.editorReplaceSelections), + insertText: scoped(HOST_METHODS.editorInsertText), + open: scoped(HOST_METHODS.editorOpen), + reveal: scoped(HOST_METHODS.editorReveal), + registerActions: async (context, params, options) => + subscribe(HOST_METHODS.editorRegisterActions, { ...params, context: contextReference(context) }, undefined, options), + }), + preview: Object.freeze({ + openHTML: scoped(HOST_METHODS.previewOpenHTML), + }), + browser: Object.freeze({ + openExternal: scoped(HOST_METHODS.browserOpenExternal), + }), + terminal: Object.freeze({ + create: scoped(HOST_METHODS.terminalCreate), + show: scoped(HOST_METHODS.terminalShow), + close: scoped(HOST_METHODS.terminalClose), + reopen: scoped(HOST_METHODS.terminalReopen), + dispose: scoped(HOST_METHODS.terminalDispose), + onExit: async (context, terminalID, onEvent, options) => + subscribe(HOST_METHODS.terminalOnExit, { context: contextReference(context), terminalID }, onEvent, options), + }), + window: Object.freeze({ + showInformation: scoped(HOST_METHODS.windowShowInformation), + showWarning: scoped(HOST_METHODS.windowShowWarning), + showError: scoped(HOST_METHODS.windowShowError), + }), + }); +} + +/** + * `{ contextID, generation }` of a context reference or of a full context + * from `listContexts` or `getContext`, so either can be passed on. + */ +export function contextReference(value) { + const reference = { contextID: value?.contextID, generation: value?.generation }; + if (!isContextReference(reference)) { + throw new PluginSdkError( + 'sdk.host.invalidContext', + 'A context is { contextID, generation }: an ID of 1-128 ASCII letters, digits, ".", "_", ":" or "-", and a positive generation.', + ); + } + return reference; +} + +function checkCredentialKey(key) { + if (!isCredentialKey(key)) { + throw new PluginSdkError( + 'sdk.credentials.invalidKey', + 'A credential key is 1-128 ASCII letters, digits, ".", "_", ":" or "-".', + ); + } +} diff --git a/vendor/lithe-plugin-sdk/src/index.d.ts b/vendor/lithe-plugin-sdk/src/index.d.ts index 8841d24..e18c1e7 100644 --- a/vendor/lithe-plugin-sdk/src/index.d.ts +++ b/vendor/lithe-plugin-sdk/src/index.d.ts @@ -27,6 +27,12 @@ export interface ProcessResult { truncated: boolean; } +/** Typed facts for the host-owned litheLogging 1 event catalog. */ +export interface Diagnostics { + degraded(operation: string, code: string, count?: number): void; + recovered(operation: string, code: string, count?: number): void; +} + /** Shared by every handler. */ export interface RequestContext { /** Aborted when the host cancels the call, the call times out, the session ends, or the call has settled. */ @@ -34,8 +40,263 @@ export interface RequestContext { throwIfCancelled(): void; /** `runProcess` bound to this call: its tree ends when the call is cancelled or settles. */ run(command: string, args?: string[], options?: RunProcessOptions): Promise; - /** Writes a line to stderr, which the host keeps in its log. */ - log(...parts: unknown[]): void; + /** Fixed facts, correlated with the host operation; never accepts payload text. */ + readonly diagnostics: Diagnostics; + /** Omits only this call's successful fact; failures remain visible. */ + suppressSuccessDiagnostics(): void; + /** `backend.host` bound to this call: its unanswered host requests are cancelled when the call ends. */ + host: Host; +} + +/** `{ contextID, generation }`: one generation of one workspace context (protocol 3). */ +export interface ContextReference { + contextID: string; + generation: number; +} + +export interface WorkspaceFolder { + /** Stable per plugin and location; reveals nothing about the path. */ + rootID: string; + name: string; + /** Present only for a local authority. */ + path?: string; +} + +/** One open workspace in one window. */ +export interface WorkspaceContext extends ContextReference { + name: string; + authority: { kind: 'local' | 'remote'; id?: string }; + folders: WorkspaceFolder[]; + /** Private to the plugin and the workspace; only `getContext` returns, and creates, it. */ + stateDirectory?: string; + [key: string]: unknown; +} + +export interface HostCapabilities { + protocolVersion: number; + backendAuthority: 'local'; + /** Each service this session may use, with the requests it may send and the notifications it may receive. */ + services: Record; + limits: { + pendingHostRequests: number; + functionResultBytes: number; + hostRequestBytes: number; + hostResultBytes: number; + errorMessageBytes: number; + errorDetailsBytes: number; + notificationBytes: number; + [key: string]: number; + }; + [key: string]: unknown; +} + +export interface HostRequestOptions { + /** Aborting it rejects with its reason; from protocol 3 the host is also told to stop. Never retried. */ + signal?: AbortSignal; +} + +export interface SubscriptionEventInfo { + subscriptionID: string; + sequence: number; + /** True when `sequence` does not follow the previous event: events were missed. */ + gap: boolean; +} + +export type SubscriptionHandler = (event: JsonObject, info: SubscriptionEventInfo) => unknown; + +export interface Subscription { + readonly subscriptionID: string; + readonly disposed: boolean; + /** Stops routing events at once and asks the host to dispose the subscription. */ + dispose(): Promise; +} + +/** A file or folder under a root of `getFolders`; `path` uses `/` and is relative to the root. */ +export interface WorkspaceResource { + rootID: string; + path: string; +} + +/** One-based UTF-16 position. */ +export interface TextPosition { + line: number; + column: number; +} + +/** Half-open range. */ +export interface TextRange { + start: TextPosition; + end: TextPosition; +} + +export interface TextSelection { + anchor: TextPosition; + active: TextPosition; +} + +export interface DocumentSummary { + documentID: string; + version: number; + /** Workspace-relative, or null outside the root. */ + path: string | null; + languageID: string | null; + isReadOnly: boolean; + isDirty: boolean; + encoding: string; + lineEnding: string; + [key: string]: unknown; +} + +export interface EditResult { + documentID: string; + version: number; + applied: boolean; + [key: string]: unknown; +} + +export interface OpenParams { + resource: WorkspaceResource; + activation?: 'select' | 'background'; + range?: TextRange; + tab?: 'regular'; +} + +export interface FindPage { + matches: T[]; + nextCursor: string | null; + isTruncated: boolean; + source: 'disk'; + [key: string]: unknown; +} + +type Scoped = (context: ContextReference, params: P, options?: HostRequestOptions) => Promise; +type ScopedOptional = (context: ContextReference, params?: P, options?: HostRequestOptions) => Promise; + +/** + * Host services of plugin-function protocols 2 and 3. Every method rejects + * with `HostError` when the host refuses; from protocol 3 check + * `supports(method)` instead of assuming a method from the version. + * A full `WorkspaceContext` may be passed wherever a reference is expected. + */ +export interface Host { + /** The negotiated plugin-function version, or 0 before or without one. */ + readonly version: number; + /** Sends any `lithe/host/*` request, including ones a later host adds. */ + request(method: string, params?: JsonObject, options?: HostRequestOptions): Promise; + /** Sends a request answering `{ subscriptionID }` and routes that subscription's events. */ + subscribe(method: string, params: JsonObject, onEvent?: SubscriptionHandler, options?: HostRequestOptions): Promise; + /** The session's capabilities; protocol 3, cached. */ + capabilities(): Promise; + /** Whether this session may call `method`. Under protocol 2, which has no discovery, the methods it defines. */ + supports(method: string): Promise; + /** Protocol 2; permission `credentials.store`. Kept in the platform's secret store, per plugin. */ + readonly credentials: { + /** The stored secret, or null. */ + get(key: string, options?: HostRequestOptions): Promise; + set(key: string, secret: string, options?: HostRequestOptions): Promise; + delete(key: string, options?: HostRequestOptions): Promise; + }; + readonly subscriptions: { + dispose(subscriptionID: string): Promise; + }; + readonly workspace: { + /** Permission `workspace.metadata`. Entries carry no `stateDirectory`. */ + listContexts(options?: HostRequestOptions): Promise<{ revision: number; contexts: WorkspaceContext[] }>; + getContext(context: ContextReference, options?: HostRequestOptions): Promise; + getFolders(context: ContextReference, options?: HostRequestOptions): Promise<{ folders: WorkspaceFolder[] }>; + /** Permission `workspace.files.read`. */ + readDirectory: Scoped<{ resource: WorkspaceResource; cursor?: string; limit?: number }, { + entries: { name: string; resource: WorkspaceResource; kind: string }[]; + nextCursor: string | null; + }>; + stat: Scoped<{ resource: WorkspaceResource }, { resource: WorkspaceResource; kind: string; size: number; modifiedMilliseconds: number }>; + readFile: Scoped<{ resource: WorkspaceResource; maximumBytes?: number }, { + resource: WorkspaceResource; + source: 'disk'; + byteCount: number; + isTruncated: boolean; + identity: JsonValue; + text?: string; + encoding?: string; + base64?: string; + }>; + /** Permission `workspace.files.watch`. Events carry `{ rootID, changedPaths, requiresRescan }`. */ + watch( + context: ContextReference, + params: { resource: WorkspaceResource; recursive?: boolean }, + onEvent: SubscriptionHandler, + options?: HostRequestOptions, + ): Promise; + /** Permission `workspace.search`; `resource` names the root (`path: ""`). */ + findFiles: Scoped<{ resource: WorkspaceResource; query: string; fileMask?: string; cursor?: string; limit?: number }, FindPage>; + findText: Scoped<{ + resource: WorkspaceResource; + pattern: string; + fileMask?: string; + caseSensitive?: boolean; + wholeWords?: boolean; + regularExpression?: boolean; + cursor?: string; + limit?: number; + }, FindPage<{ resource: WorkspaceResource; line: number; preview: string; range: TextRange | null }>>; + }; + /** Permission `documents.read`. */ + readonly documents: { + list: ScopedOptional; + getSnapshot: Scoped<{ documentID: string }, { document: DocumentSummary; text: string }>; + }; + readonly editor: { + getActive: ScopedOptional; + getSelections: Scoped<{ documentID: string }, { documentID: string; version: number; selections: TextSelection[]; primaryIndex: number }>; + /** Permission `editor.edit`. Not replayed on cancellation or timeout: the edit may have committed. */ + applyEdits: Scoped<{ documentID: string; expectedVersion: number; edits: { range: TextRange; text: string }[] }, EditResult>; + replaceSelections: Scoped<{ documentID: string; expectedVersion: number; selections: TextSelection[]; replacements: string[] }, EditResult>; + insertText: Scoped<{ documentID: string; expectedVersion: number; at: TextPosition; text: string }, EditResult>; + /** Permission `editor.open`. */ + open: Scoped; + reveal: Scoped; + /** Permission `editor.actions`; the host later calls `function` to collect actions. */ + registerActions( + context: ContextReference, + params: { id: string; function: string; selector: { fileExtensions?: string[]; fileNames?: string[] } }, + options?: HostRequestOptions, + ): Promise; + }; + /** Permission `preview.open`. */ + readonly preview: { + openHTML: Scoped<{ resource: WorkspaceResource; presentation?: 'editorAndPreview' | 'previewOnly'; range?: TextRange }, JsonObject>; + }; + /** Permission `browser.open`; exactly one of `url` or `resource`. */ + readonly browser: { + openExternal: Scoped< + { url: string } | { resource: WorkspaceResource; unsaved?: 'openSavedFile' | 'requireSaved' }, + { opened: true; source: 'web' | 'disk'; hasUnsavedChanges?: boolean } + >; + }; + /** Permission `terminal.create`; only terminals owned by this backend session and context. */ + readonly terminal: { + create: Scoped<{ executable: string; arguments?: string[]; title?: string; cwd?: WorkspaceResource; + environment?: Record; show?: boolean; retainedContextBytes?: number }, { terminalID: string; generation: number }>; + show: Scoped<{ terminalID: string }, null>; + close: Scoped<{ terminalID: string; retainedContextBytes?: number }, { retainedContextBytes: number }>; + reopen: Scoped<{ terminalID: string; show?: boolean }, { terminalID: string; generation: number; restoredContextBytes: number }>; + dispose: Scoped<{ terminalID: string }, null>; + onExit(context: ContextReference, terminalID: string, + onEvent: (event: { type: 'exit'; terminalID: string; exitCode: number | null; reason: 'exited' | 'closed'; generation: number }, metadata: SubscriptionEventInfo) => void, + options?: HostRequestOptions): Promise; + }; + /** Permission `window.messages`. `selectedIndex` is zero-based, or null on dismissal. */ + readonly window: { + showInformation: Scoped<{ text: string; actions?: string[] }, { selectedIndex: number | null }>; + showWarning: Scoped<{ text: string; actions?: string[] }, { selectedIndex: number | null }>; + showError: Scoped<{ text: string; actions?: string[] }, { selectedIndex: number | null }>; + }; +} + +export interface CommandEvent { + commandID: string; + /** The context of the window the command ran in, or null. */ + workspaceContext: ContextReference | null; } export interface ProgressReport { @@ -49,6 +310,8 @@ export interface FunctionContext extends RequestContext { functionID: string; /** Opaque per-call ID chosen by Lithe. */ operationID: string; + /** The context of the page's window (protocol 3), or null. */ + workspaceContext: ContextReference | null; /** Reports progress; ignored once the call has ended. */ progress(report: ProgressReport): void; } @@ -58,6 +321,8 @@ export type BuildHandler = (params: BuildRequestParams, context: RequestContext) /** Params of `lithe/build/model` and `lithe/build/dependencies`. */ export interface BuildRequestParams { + /** Read-only host snapshot; missing producers remain unavailable. Requires protocol 6. */ + modelInputs?: BuildModelInputs; buildSystemID: string; root: string; options: JsonObject; @@ -72,15 +337,143 @@ export interface BuildRequestParams { [key: string]: unknown; } +/** Public normalized facts. Tasks, run plans, settings and optionValues never cross this boundary. */ +export interface BuildSharedModel { + projects: { id: string; name: string; path: string; displayName?: string; parentID?: string; properties: Record }[]; + targets: { id: string; projectID: string; name: string; path: string; tags: string[]; properties: Record }[]; + contentRoots: { projectID: string; path: string; kind: string }[]; +} +export type BuildModelInputInstance = + | { root: string; state: 'ready'; model: BuildSharedModel; configurationFingerprint: string; options: JsonObject } + | { root: string; state: 'loading' | 'stale' | 'failed' }; +export interface BuildModelInputs { + version: 1; + /** Opaque workspace-activation revision; discard cached work after it changes. */ + revision: number; + inputs: { pluginID: string; buildSystemID: string; state: 'available' | 'unavailable'; instances: BuildModelInputInstance[] }[]; +} + +/** Current upstream model selection, preserved as data rather than shell text. */ +export interface BuildSelection { + name: string; + path: string; + tags?: string[]; + properties?: JsonObject; +} + +export interface BuildExecutionParams extends BuildRequestParams { + task: string; + project?: BuildSelection | null; + target?: BuildSelection | null; + userArguments: string[]; + operationID: string; +} + +export interface BuildTestParams extends BuildRequestParams { + project?: BuildSelection | null; + target?: BuildSelection | null; + suite?: string; + case?: string; + userArguments: string[]; + operationID: string; +} + +export interface BuildRunParams extends BuildRequestParams { + run: string; + project: BuildSelection; + target: BuildSelection; + userArguments: string[]; + operationID: string; +} + +export interface BuildProgressReport extends ProgressReport { + /** Cut to 64 KiB of UTF-8 per notification. Build messages are cut to 4096 bytes. */ + output?: string; +} + +export interface BuildContext extends RequestContext { + /** The Core-assigned wire identity, never a plugin-selected value. */ + operationID: string; + /** Dropped after cancellation or completion. */ + progress(report: BuildProgressReport): void; +} + +export interface BuildExecutionResult { exitCode: number; output: string; } +export interface BuildTestDiscovery { + format?: 'litheTestDiscovery'; + version?: 1; + tests: { suite: string; case: string; project?: BuildSelection; target?: BuildSelection }[]; +} +export interface BuildRunPreparation { + executable: { kind: 'toolchain' | 'command'; value: string }; + arguments: string[]; + workingDirectory: string; + environment: Record; +} +export type BuildExecutionHandler = (params: BuildExecutionParams, context: BuildContext) => BuildExecutionResult | Promise; +export type BuildTestHandler = (params: BuildTestParams, context: BuildContext) => BuildExecutionResult | Promise; +export type BuildDiscoveryHandler = (params: BuildRequestParams, context: RequestContext) => BuildTestDiscovery | Promise; +export interface BuildDebugPreparation { + program: string; + arguments: string[]; + workingDirectory: string; + environment: Record; +} +export type BuildDebugHandler = (params: BuildRunParams, context: BuildContext) => BuildDebugPreparation | Promise; +export type BuildRunHandler = (params: BuildRunParams, context: BuildContext) => BuildRunPreparation | Promise; + +/** Params every VCS handler receives, on top of its operation's arguments. */ +export interface VcsRequestParams { + root: { path: string }; + /** The VCS client the host resolved; null when it could not, so report `vcs.tool.unavailable`. */ + tool: { executable: string } | null; + [key: string]: unknown; +} + +export interface VcsRunOptions extends RunProcessOptions { + /** Report the finished command as `lithe/vcs/execution`; default true. */ + report?: boolean; +} + +export interface VcsContext extends RequestContext { + /** `RequestContext.run` that also reports each finished command to the host's console. */ + run(command: string, args?: string[], options?: VcsRunOptions): Promise; +} + +export type VcsHandler = (params: VcsRequestParams, context: VcsContext) => JsonObject | void | Promise; + +/** Handlers keyed by operation; see shared/contracts/vcs-provider-v1.md for each reply. */ +export interface VcsHandlers { + status: VcsHandler; + baseContent: VcsHandler; + checkin: VcsHandler; + rollback: VcsHandler; + history?: VcsHandler; + fileHistory?: VcsHandler; + annotate?: VcsHandler; + update?: VcsHandler; + fileOperation?: VcsHandler; + resolve?: VcsHandler; + cleanup?: VcsHandler; +} + +export interface VcsTraits { + /** Check-in is local and publishing is a separate step. */ + push?: boolean; + /** Changes carry a staged/unstaged dimension. */ + staging?: boolean; +} + export interface Negotiated { functions: boolean; build: boolean; + vcs: boolean; } export interface BackendOptions { name?: string; version?: string; - /** Standard LSP server capabilities; `experimental.litheBuild` and `experimental.litheFunctions` are reserved. */ + /** Standard LSP server capabilities; `experimental.litheBuild`, `litheFunctions`, and `litheVcs` are reserved. */ capabilities?: JsonObject; } @@ -95,21 +488,40 @@ export interface ListenOptions { export interface Backend { readonly negotiated: Negotiated; readonly initializeParams: JsonObject | null; + /** Highest common version (1-6), or null before/without negotiation. */ + readonly buildVersion: number | null; + /** Highest common plugin-function version (1-3), or null before/without negotiation. */ + readonly functionsVersion: number | null; readonly functionIDs: string[]; + /** Host services; usable once the session is ready. */ + readonly host: Host; readonly build: { model(handler: BuildHandler): Backend; dependencies(handler: BuildHandler): Backend; + execute(handler: BuildExecutionHandler): Backend; + test(handler: BuildTestHandler): Backend; + discoverTests(handler: BuildDiscoveryHandler): Backend; + prepareRun(handler: BuildRunHandler): Backend; + prepareDebug(handler: BuildDebugHandler): Backend; }; + vcs(id: string, handlers: VcsHandlers, traits?: VcsTraits): Backend; function(id: string, handler: FunctionHandler): Backend; registerFunction(id: string, handler: FunctionHandler): Promise; unregisterFunction(id: string): Promise; onRequest(method: string, handler: (params: unknown, context: RequestContext) => unknown): Backend; onNotification(method: string, handler: (params: unknown) => unknown): Backend; + /** Handles `lithe/host/commands/didRun` for one of the plugin's commands (protocol 3). */ + onCommand(commandID: string, handler: (event: CommandEvent) => unknown): Backend; + /** Handles `lithe/host/workspace/didChangeContexts` (protocol 3). */ + onContextsChanged(handler: (event: { revision: number }) => unknown): Backend; + /** Handles a `lithe/host/*` notification a later host adds. */ + onHostNotification(method: string, handler: (params: JsonObject) => unknown): Backend; onInitialize(hook: (event: { params: JsonObject; negotiated: Negotiated }) => unknown): Backend; onShutdown(hook: () => unknown): Backend; sendRequest(method: string, params?: unknown): Promise; sendNotification(method: string, params?: unknown): void; - log(...parts: unknown[]): void; + readonly diagnostics: Diagnostics; + suppressSuccessDiagnostics(operation: string): this; listen(options?: ListenOptions): Promise; } @@ -124,6 +536,17 @@ export class PluginSdkError extends Error { readonly code: string; } +/** The host refused or failed a request this backend sent. */ +export class HostError extends PluginSdkError { + /** `error.data.code`, such as `plugin.host.contextExpired`. */ + readonly code: string; + /** The JSON-RPC error number. */ + readonly rpcCode: number | undefined; + /** Protocol 3 details, such as `{ option }` or `{ retryAfterMilliseconds }`. */ + readonly details: JsonObject | undefined; + readonly method: string | undefined; +} + export function runProcess(command: string, args?: string[], options?: RunProcessOptions): Promise; export interface StateStore { @@ -137,10 +560,62 @@ export function openStateStore(directory: string, options?: { packageDirectory?: export function isFunctionId(value: unknown): boolean; +/** Host failure codes and the JSON-RPC number each has from protocol 3. */ +export const HOST_ERRORS: Readonly<{ + 'plugin.host.unavailable': -32601; + 'plugin.host.invalidParams': -32602; + 'plugin.host.failed': -32603; + 'plugin.host.cancelled': -32800; + 'plugin.host.permissionDenied': -32010; + 'plugin.host.contextExpired': -32011; + 'plugin.host.authorityUnsupported': -32012; + 'plugin.host.versionConflict': -32013; + 'plugin.host.limitExceeded': -32014; + 'plugin.host.timeout': -32015; + 'plugin.host.notFound': -32016; + 'plugin.host.readOnly': -32017; + 'plugin.host.unsupportedOption': -32018; + 'plugin.host.preconditionFailed': -32019; + 'plugin.host.busy': -32020; + 'plugin.host.rateLimited': -32021; +}>; + +/** Host request method names of protocols 2 and 3. */ +export const HOST_METHODS: Readonly>; + +export function isVcsId(value: unknown): boolean; + +/** Provider error codes Core passes on; use them as `PluginError` codes. */ +export const VCS_ERRORS: Readonly<{ + authenticationFailed: 'vcs.authentication.failed'; + conflict: 'vcs.conflict'; + outOfDate: 'vcs.outOfDate'; + toolUnavailable: 'vcs.tool.unavailable'; + workingCopyLocked: 'vcs.workingCopy.locked'; + workingCopyUnsupported: 'vcs.workingCopy.unsupported'; +}>; + export const LIMITS: Readonly<{ maxFunctions: number; maxFunctionIdBytes: number; maxPluginErrorCodeBytes: number; maxMessageBytes: number; maxResultBytes: number; + maxBuildResultBytes: number; + maxBuildMessageBytes: number; + maxBuildOutputBytes: number; + maxVcsIdBytes: number; + maxExecutionErrorOutputBytes: number; + maxCredentialKeyBytes: number; + maxCredentialSecretBytes: number; + maxPendingHostRequests: number; }>; diff --git a/vendor/lithe-plugin-sdk/src/index.mjs b/vendor/lithe-plugin-sdk/src/index.mjs index 6263ac8..6c1d1dd 100644 --- a/vendor/lithe-plugin-sdk/src/index.mjs +++ b/vendor/lithe-plugin-sdk/src/index.mjs @@ -1,8 +1,8 @@ // Official Node.js SDK for Lithe plugin backends. -// Guide: sdk/node/README.md. Wire contract: shared/contracts/plugins/v1/README.md. +// Guide: sdk/node/README.md. Wire contract: contract/v1/README.md. export { createBackend } from './backend.mjs'; -export { PluginError, PluginSdkError } from './errors.mjs'; +export { HostError, PluginError, PluginSdkError } from './errors.mjs'; export { runProcess } from './process.mjs'; export { openStateStore } from './state.mjs'; -export { isFunctionId, LIMITS } from './protocol.mjs'; +export { HOST_ERRORS, HOST_METHODS, isFunctionId, isVcsId, LIMITS, VCS_ERRORS } from './protocol.mjs'; diff --git a/vendor/lithe-plugin-sdk/src/protocol.mjs b/vendor/lithe-plugin-sdk/src/protocol.mjs index cb13ce8..4cf8e2b 100644 --- a/vendor/lithe-plugin-sdk/src/protocol.mjs +++ b/vendor/lithe-plugin-sdk/src/protocol.mjs @@ -1,11 +1,18 @@ -// Wire constants and validation shared with Rust Core -// (rust/lithe-core/src/lsp/interface/plugin_function.rs). The SDK checks the +// Wire constants and validation shared with Lithe's Rust Core +// (rust/lithe-core/src/lsp/interface/plugin_function.rs and +// rust/lithe-core/src/vcs/ in the Lithe repository). The SDK checks the // same rules locally so a plugin author sees a mistake when registering a // handler instead of as a refused session at runtime. -/** Protocol versions this SDK can answer. */ -export const FUNCTIONS_VERSION = 1; -export const BUILD_VERSION = 1; +/** Protocol versions this SDK can answer: the highest of each. */ +export const FUNCTIONS_VERSION = 3; +export const BUILD_VERSION = 6; +export const VCS_VERSION = 1; + +/** First plugin-function version with host services (credentials). */ +export const HOST_SERVICES_VERSION = 2; +/** First plugin-function version with workspace contexts, discovery, and host notifications. */ +export const CONTEXT_VERSION = 3; export const METHODS = Object.freeze({ initialize: 'initialize', @@ -19,6 +26,135 @@ export const METHODS = Object.freeze({ functionProgress: 'lithe/function/progress', buildModel: 'lithe/build/model', buildDependencies: 'lithe/build/dependencies', + buildExecute: 'lithe/build/execute', + buildTest: 'lithe/build/test', + buildDiscoverTests: 'lithe/build/discoverTests', + buildPrepareRun: 'lithe/build/prepareRun', + buildPrepareDebug: 'lithe/build/prepareDebug', + buildProgress: 'lithe/build/progress', + vcsExecution: 'lithe/vcs/execution', +}); + +/** + * Backend-to-host requests of plugin-function protocols 2 and 3. From + * version 3 the host adds methods without a new version; `host.request` + * sends any `lithe/host/*` method, and `getCapabilities` says which ones + * this session may call. + */ +export const HOST_METHODS = Object.freeze({ + credentialsGet: 'lithe/host/credentials/get', + credentialsSet: 'lithe/host/credentials/set', + credentialsDelete: 'lithe/host/credentials/delete', + getCapabilities: 'lithe/host/getCapabilities', + subscriptionsDispose: 'lithe/host/subscriptions/dispose', + workspaceListContexts: 'lithe/host/workspace/listContexts', + workspaceGetContext: 'lithe/host/workspace/getContext', + workspaceGetFolders: 'lithe/host/workspace/getFolders', + workspaceReadDirectory: 'lithe/host/workspace/readDirectory', + workspaceStat: 'lithe/host/workspace/stat', + workspaceReadFile: 'lithe/host/workspace/readFile', + workspaceWatch: 'lithe/host/workspace/watch', + workspaceFindFiles: 'lithe/host/workspace/findFiles', + workspaceFindText: 'lithe/host/workspace/findText', + documentsList: 'lithe/host/documents/list', + documentsGetSnapshot: 'lithe/host/documents/getSnapshot', + editorRegisterActions: 'lithe/host/editor/registerActions', + editorGetActive: 'lithe/host/editor/getActive', + editorGetSelections: 'lithe/host/editor/getSelections', + editorApplyEdits: 'lithe/host/editor/applyEdits', + editorReplaceSelections: 'lithe/host/editor/replaceSelections', + editorInsertText: 'lithe/host/editor/insertText', + editorOpen: 'lithe/host/editor/open', + editorReveal: 'lithe/host/editor/reveal', + previewOpenHTML: 'lithe/host/preview/openHTML', + browserOpenExternal: 'lithe/host/browser/openExternal', + windowShowInformation: 'lithe/host/window/showInformation', + windowShowWarning: 'lithe/host/window/showWarning', + windowShowError: 'lithe/host/window/showError', + terminalCreate: 'lithe/host/terminal/create', + terminalShow: 'lithe/host/terminal/show', + terminalClose: 'lithe/host/terminal/close', + terminalReopen: 'lithe/host/terminal/reopen', + terminalDispose: 'lithe/host/terminal/dispose', + terminalOnExit: 'lithe/host/terminal/onExit', +}); + +/** Host-to-backend notifications of protocol 3. */ +export const HOST_NOTIFICATIONS = Object.freeze({ + didChangeContexts: 'lithe/host/workspace/didChangeContexts', + subscriptionNotify: 'lithe/host/subscriptions/notify', + commandDidRun: 'lithe/host/commands/didRun', +}); + +/** + * Host failure codes (`error.data.code`) and the JSON-RPC number Core sends + * with each to a version 3 backend. A number never changes meaning; a later + * host adds codes after `-32021`. + */ +export const HOST_ERRORS = Object.freeze({ + 'plugin.host.unavailable': -32601, + 'plugin.host.invalidParams': -32602, + 'plugin.host.failed': -32603, + 'plugin.host.cancelled': -32800, + 'plugin.host.permissionDenied': -32010, + 'plugin.host.contextExpired': -32011, + 'plugin.host.authorityUnsupported': -32012, + 'plugin.host.versionConflict': -32013, + 'plugin.host.limitExceeded': -32014, + 'plugin.host.timeout': -32015, + 'plugin.host.notFound': -32016, + 'plugin.host.readOnly': -32017, + 'plugin.host.unsupportedOption': -32018, + 'plugin.host.preconditionFailed': -32019, + 'plugin.host.busy': -32020, + 'plugin.host.rateLimited': -32021, +}); + +/** Minimum negotiated version of each typed build operation. */ +export const BUILD_METHOD_VERSIONS = Object.freeze({ + [METHODS.buildModel]: 1, + [METHODS.buildDependencies]: 1, + [METHODS.buildExecute]: 2, + [METHODS.buildTest]: 3, + [METHODS.buildDiscoverTests]: 3, + [METHODS.buildPrepareRun]: 4, + [METHODS.buildPrepareDebug]: 5, +}); + +/** + * VCS-provider operations: the handler name a plugin declares, the request + * Core sends for it, and the capability declaring the handler negotiates. + * Contract: shared/contracts/vcs-provider-v1.md. + */ +export const VCS_OPERATIONS = Object.freeze({ + status: Object.freeze({ method: 'lithe/vcs/status', capability: 'status' }), + baseContent: Object.freeze({ method: 'lithe/vcs/baseContent', capability: 'baseContent' }), + history: Object.freeze({ method: 'lithe/vcs/history', capability: 'history' }), + fileHistory: Object.freeze({ method: 'lithe/vcs/fileHistory', capability: 'fileHistory' }), + annotate: Object.freeze({ method: 'lithe/vcs/annotate', capability: 'annotate' }), + checkin: Object.freeze({ method: 'lithe/vcs/checkin', capability: 'checkin' }), + rollback: Object.freeze({ method: 'lithe/vcs/rollback', capability: 'rollback' }), + update: Object.freeze({ method: 'lithe/vcs/update', capability: 'update' }), + fileOperation: Object.freeze({ method: 'lithe/vcs/fileOperation', capability: 'fileOperations' }), + resolve: Object.freeze({ method: 'lithe/vcs/resolve', capability: 'conflicts' }), + cleanup: Object.freeze({ method: 'lithe/vcs/cleanup', capability: 'cleanup' }), +}); + +/** Handlers every VCS provider must declare; Core refuses a provider without them. */ +export const VCS_REQUIRED_OPERATIONS = Object.freeze(['status', 'baseContent', 'checkin', 'rollback']); + +/** + * The provider error codes Core passes on (`error.data.code`). Throw a + * `PluginError` with one of them; any other code reaches the host as + * `vcs.provider.failed`. + */ +export const VCS_ERRORS = Object.freeze({ + authenticationFailed: 'vcs.authentication.failed', + conflict: 'vcs.conflict', + outOfDate: 'vcs.outOfDate', + toolUnavailable: 'vcs.tool.unavailable', + workingCopyLocked: 'vcs.workingCopy.locked', + workingCopyUnsupported: 'vcs.workingCopy.unsupported', }); /** JSON-RPC and LSP error codes the SDK answers with. */ @@ -39,6 +175,14 @@ export const LIMITS = Object.freeze({ maxPluginErrorCodeBytes: 64, maxMessageBytes: 1024, maxResultBytes: 1 << 20, + maxBuildResultBytes: 32 * 1024 * 1024, + maxBuildMessageBytes: 4096, + maxBuildOutputBytes: 65536, + maxVcsIdBytes: 32, + maxExecutionErrorOutputBytes: 16 * 1024, + maxCredentialKeyBytes: 128, + maxCredentialSecretBytes: 16 * 1024, + maxPendingHostRequests: 16, }); /** Dot-separated lowerCamelCase segments, for example `snapshot.refresh`. */ @@ -51,6 +195,32 @@ export function isFunctionId(value) { ); } +/** A VCS ID: lowercase ASCII letters, digits, and `-`, starting with a letter, such as `svn`. */ +export function isVcsId(value) { + return typeof value === 'string' && value.length <= LIMITS.maxVcsIdBytes && /^[a-z][a-z0-9-]*$/.test(value); +} + +/** + * The key alphabet of protocols 2 and 3: 1-128 ASCII letters, digits, `.`, + * `_`, `:`, or `-`. Credential keys, context IDs, and subscription IDs use it. + */ +export function isCredentialKey(value) { + return typeof value === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(value); +} + +/** `{ contextID, generation }`, exactly, naming one generation of one workspace context. */ +export function isContextReference(value) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 2 && + isCredentialKey(value.contextID) && + Number.isSafeInteger(value.generation) && + value.generation >= 1 + ); +} + /** The plugin error code alphabet Core forwards as `pluginErrorCode`. */ export function isPluginErrorCode(value) { return ( diff --git a/vendor/lithe-plugin-sdk/test/harness.mjs b/vendor/lithe-plugin-sdk/test/harness.mjs index 4988ed5..34da7bc 100644 --- a/vendor/lithe-plugin-sdk/test/harness.mjs +++ b/vendor/lithe-plugin-sdk/test/harness.mjs @@ -58,7 +58,7 @@ export class TestHost { const response = await this.request('initialize', { processId: null, rootUri, - capabilities: { experimental }, + capabilities: { experimental: { litheLogging: { versions: [1] }, ...experimental } }, }); this.notify('initialized', {}); return response;