Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
| 跟着教程完整做一遍 | [教程](#教程) |
| 写 `plugin.json` 时有提示和校验 | [在编辑器里校验](#在编辑器里校验-pluginjson) |
| 需要自己的后端进程 | [插件后端 SDK](#插件后端-sdk可选) |
| 接入宿主日志、排查插件故障 | [插件接入统一日志](guides/plugin-logging.md) |
| 参考现成插件 | [示例插件](#示例插件) |
| 查字段的准确含义 | [插件契约](#插件契约) |

Expand All @@ -34,7 +35,7 @@ Lithe 读取并校验 `plugin.json`,然后自己启动其中声明的外部进
- `id` 用你自己的反向域名,例如 `io.github.<你的用户名>.zig-support`。`dev.lithe` 和 `dev.lithe.*` 是官方保留命名空间。
- `vendor.signatureRequirement` 写 `"none"`。社区插件不带 Lithe 签名,用户安装前会看到完整的程序、权限和来源,并确认。
- 包内路径只用 `/`,不能有 `..`、绝对路径或符号链接。
- 插件包安装后是只读的。缓存、状态和日志写到 Lithe 通过启动模板传给你的目录(`${stateDir}`、`${workspaceStateDir}`),不要写进插件包。
- 插件包安装后是只读的。缓存和状态使用 Lithe 通过启动模板传入的 `${stateDir}`、`${workspaceStateDir}`;诊断日志使用 SDK 与宿主统一入口。确需原生文件日志时,先确认目标宿主支持 `${logDir}`,详见[插件接入统一日志](guides/plugin-logging.md)。

## 五步做出第一个插件

Expand Down Expand Up @@ -128,6 +129,7 @@ SDK 的源码都在本仓库的 `sdk/` 下,发布在[本仓库的 Releases](ht
| 教程 | 内容 |
| --- | --- |
| [LSP 插件完整生命周期](guides/lsp-plugin-lifecycle.md) | 从 Lua 模板开始:清单、服务器打包、激活、状态目录、停止、升级与卸载 |
| [插件接入统一日志](guides/plugin-logging.md) | 三 SDK 的安全失败边界、受管诊断通道、macOS v2 / Windows v1 状态,以及 `${logDir}` 的本地会话生命周期 |
| [原生构建工作流(开发中)](guides/native-build-workflows.md) | Node/Java SDK 的构建执行、精确测试、运行准备,以及未提交源码的真实 Core 验证 |
| [构建、运行与测试接入](guides/build-tool-integration.md) | 可用的执行提供者示例、工程后端边界,以及开发中的 API 3 状态 |
| [编写调试适配器插件](guides/debug-adapter-plugin.md) | API 4:从最小清单到启动方式、先编译再调试的 `executable`、调试控制台补全,以及预发布与转正 |
Expand Down
79 changes: 79 additions & 0 deletions guides/plugin-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 插件接入统一日志

新版本插件使用 `litheLogging` 1 与 `lithe/log` 发送受限的操作事实,由宿主的共享事件目录、脱敏、队列和文件写入器落盘。三个 SDK 不再提供自由文本 `log` 接口,也没有 stderr 回退。插件无需实现 v2 文件格式、文件轮转、脱敏器或后台写入线程。

宿主必须在 initialize 中提供 `capabilities.experimental.litheLogging.versions: [1]`,SDK 回复 `capabilities.experimental.litheLogging.version: 1`。缺少协议时,新 SDK 拒绝初始化;不自动退回旧日志。初始化尚未协商时的失败只通过原有 RPC 错误与宿主进程生命周期记录,不能提前发送未协商的日志通知。

本文描述源码中的新 API。插件本轮将源码与真实哈希一起固定在可复现的构建输入中;正式发布 SDK 后也可以改为锁定真实 release tag 与 SHA-256。不要引用不存在的发布版本或开发机的绝对 path 依赖。

## 每种事实与级别

| `kind` | v2 目录事件 | 级别与用途 |
| --- | --- | --- |
| `operationFinished` | `plugins.operation.finished` | INFO,关键用户操作的真实成功终态 |
| `operationFailed` | `plugins.operation.failed` | ERROR,影响请求结果的失败;SDK 在拥有结果的边界发送一次 |
| `degraded` | `plugins.operation.fallback_selected` | WARN,已处理并选择回退的失败 |
| `recovered` | `plugins.operation.recovery_finished` | WARN,曾失败后的真实恢复 |

`operationFinished` 和 `operationFailed` 必须有单调时钟计算的 `durationMs`。失败、降级、恢复使用受控的 `code`;成功没有 `code`。可选 `count` 与 `durationMs` 都是 0…9007199254740991 的整数。API 不接受原始 message、stack、request、URL、SQL、命令输出或字段字典。

`operation` 只接受本 SDK 已注册的函数 ID 或封闭协议操作,超过 64 字节的已注册函数 ID 统一为 `function-call`。公开的协议操作包括 initialize/shutdown/notification/function-call/result-encoding、SDK 已知的 `lithe/build/*` 与 `lithe/vcs/*` 方法,以及快捷键导入请求 `lithe/keymap/import`(三种 SDK 自动上报其终态);任意用户文本即使字符合法也不接受。`code` 是最长 64 字节的 ASCII 类别标识,不是错误正文。

通道由宿主从已校验的安装插件 ID 得到 `plugins/<plugin>/host`,插件不能更改通道或拼接路径。上下文诊断优先携带宿主原始 `operationID`,缺少合法值时沿用当前请求原始 JSON-RPC ID(安全字符串或整数的十进制字符串),wire 名为 `operationId`,只保留最长 128 字节的受控 ASCII 标识;无真实身份时省略。通知没有请求 ID,不生成回退身份。后台共享任务可以使用 backend/handle 的无 op 诊断,不能伪造一个操作 ID。

## 三个 SDK 的用法

```js
backend.function('project.refresh', async (params, context) => {
// 只在决定继续执行的真实降级分支调用,不把异常正文传进日志。
context.diagnostics.degraded('project.refresh', 'cache_unavailable', 1);
return refreshWithoutCache(params);
});
// 高频读取或轮询不产生 INFO 成功噪声,失败仍自动记录。
backend.function('project.inspect', (params) => inspectProject(params));
backend.suppressSuccessDiagnostics('project.inspect');
backend.diagnostics.recovered('project.refresh', 'cache_restored');
```

```java
context.diagnostics().degraded("lithe/build/model", "snapshot_unavailable", 1L);
backend.diagnostics().recovered("lithe/build/model", "snapshot_restored");
backend.suppressSuccessDiagnostics("lithe/build/dependencies");
```

```rust
context.diagnostics().degraded("query.run", "rows_truncated", Some(1))?;
handle.diagnostics().recovered("query.run", "connection_restored", None)?;
backend.suppress_success_diagnostics("query.inspect")?;
```

`degraded`、`recovered` 的 operation 必须已注册,静态示例需要放到实际的注册完成之后。Rust 的返回错误只表达调用参数非法;协议写入复用现有 writer,诊断传输失败不会把已经成功的用户操作改成失败。没有额外 writer 或后台队列。

混合用户操作和高频读取的单个入口可以在当前调用上使用 `context.suppressSuccessDiagnostics()`(Rust 为 `context.suppress_success_diagnostics()`);该决定只影响本次成功事实,不影响其他调用与失败。

SDK 在函数与受管 build/VCS 请求的结果边界自动发送一次终态,通知在 RPC 响应之前发送,宿主才能关联仍活动的请求。不要在处理函数里先发送失败再返回或抛出同一次失败。取消不产生失败事件,正常缺失的收尾对象不应产生 WARN。高频只读、预览、统计与轮询通过 `suppressSuccessDiagnostics` / `suppress_success_diagnostics` 关闭成功事实;失败与真正降级仍保留。

Rust SDK 用一次安装的 panic hook 包装器,仅在线程处于 SDK 拥有的 catch 边界时屏蔽原始 payload 输出,随后发送固定 `panic` 类别。其他线程和边界外的 panic 继续执行原 hook。不要把用户内容放进 panic,插件也不要在 SDK 启动后替换该 hook 来恢复正文输出。

## 上游诊断与文件位置

上游语言服务器的 stderr、`window/logMessage` 继续由宿主接收;它们不是插件自己的结构化日志入口。插件不得把源码、协议载荷、SQL、HTTP body 或构建输出复制进上游诊断,以为脱敏与截断能使其安全。控制台输出走已有 Build、Run、Test、VCS 或插件界面结果。

macOS 的日志窗口与导出读取 v2。Windows 的持久化仍是 v1,新的 Core 接收与目录事件不等于 Windows 文件窗口已经迁移;只有在对应目标平台执行工作流后才能声明该平台已验证。

只有上游工具确实只能写文件时,才使用 `${logDir}`。宿主显式提供当前会话的 `plugins/<id>/files/`,通过可写存储适配器创建,不能从插件安装路径或 app bundle 推导:

- 日志目录只属于当前会话,不保存数据库、索引、下载或工作区状态,不跨工作树共享。
- 缓存和状态继续使用 `${stateDir}`、`${workspaceStateDir}`。
- 日志存储不可用时,宿主不提供该变量。远程进程不能使用本机日志目录;引用 `${logDir}` 却没有绝对 `logDirectory` 的启动计划会失败。
- 不需要原生文件的插件不要增加这个启动依赖。先核对目标宿主和平台确实支持,再修改启动模板。
- 原生文件不自动变成 v2 时间线事件,不能再把整个文件打印进 stderr。
- app bundle、插件包、Windows 安装目录运行时只读;日志与锁文件不得改变签名或 Sparkle 增量更新基线。

## 最小验证

1. 用已有有界协议测试确认 logging v1 协商、四类事实、级别、原始操作关联、成功抑制、取消和非法参数拒绝。
2. 触发真实失败,确认原有用户错误码与提示保持不变,诊断只有固定类别,不含请求正文、原始异常或堆栈。
3. 使用真实 Core 查询 `diagnostics.drainCoreEvents`,核对目录事件与 `op`;不要把 v1 poll 中的旧 detail 当作新结构化记录。
4. 在目标宿主中安装并执行对应工作流,核对日志窗口。SDK 组件通过不能替代产品验收。
5. 原生日志位置有变化时比较工作流前后安装目录清单或哈希,确认发行资源不变;结束后清理本轮进程。
4 changes: 3 additions & 1 deletion scripts/sdk-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,10 @@ async function copyScenario(destination) {

async function javaSample() {
if (process.env.LITHE_PLUGIN_SDK_JAVA_SAMPLE) return resolve(process.env.LITHE_PLUGIN_SDK_JAVA_SAMPLE);
// Shade can otherwise reuse a sample JAR containing a previous SDK after only
// the reactor dependency changed. E2E must execute the current SDK sources.
await run(await which('mvn'), ['-B', '-ntp', '-q', '-f', join(repository, 'sdk/java/pom.xml'),
'-DskipTests', 'package']);
'-DskipTests', 'clean', 'package']);
const jar = join(repository, 'sdk/java/examples/sample-build/target/sample-build.jar');
await access(jar);
return jar;
Expand Down
2 changes: 2 additions & 0 deletions sdk/e2e/plugin_sdk/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ pub fn run_backend_scenario(package: &Path) {
"a machine path reached the host: {failed}"
);

super::assert_log_facts(&plugin_id, &[("save", "plugins.operation.finished", "INFO"), ("fail", "plugins.operation.failed", "ERROR")]);

// Credentials travel as host requests and never touch the state directory.
let mut store = BTreeMap::new();
let remembered = serve_host(
Expand Down
44 changes: 44 additions & 0 deletions sdk/e2e/plugin_sdk/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, PoisonError};
use std::time::{Duration, Instant};

const BUILD_SYSTEM: &str = "sample";
Expand Down Expand Up @@ -456,6 +457,7 @@ pub fn run_sample_scenario(sample: &Sample) {
dependencies["dependencies"]["dependencies"][0]["name"], "left-pad",
"{dependencies}"
);
assert_log_facts(&plugin_id, &[("model", "plugins.operation.finished", "INFO"), ("dependencies", "plugins.operation.finished", "INFO")]);

// A multi-step function: prepare, run the tool, record a snapshot.
let built = session.call(
Expand Down Expand Up @@ -495,6 +497,8 @@ pub fn run_sample_scenario(sample: &Sample) {
);
assert_eq!(failed["error"]["pluginErrorCode"], "sample.buildFailed");

assert_log_facts(&plugin_id, &[("build-app", "plugins.operation.finished", "INFO"), ("build-broken", "plugins.operation.failed", "ERROR")]);

// Cancelling a running build ends the tool the backend started.
session.invoke(
"build-slow",
Expand Down Expand Up @@ -581,3 +585,43 @@ pub fn run_sample_scenario(sample: &Sample) {
"cleared state left files behind: {state:?}"
);
}

/// Facts drained from Core's process-global catalog queue but not yet claimed.
///
/// Tests in one binary run in parallel and share that queue, so a drain can
/// return facts recorded for another test's plugin. Every drain happens under
/// this lock and lands here; each test then takes only its own plugin's facts.
static DRAINED_FACTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());

/// Drains Core's queue and returns every unclaimed fact on the plugin's channel.
fn take_plugin_facts(plugin: &str) -> Vec<Value> {
let channel = format!("plugins/{plugin}/host");
let mut facts = DRAINED_FACTS.lock().unwrap_or_else(PoisonError::into_inner);
let drained = core("diagnostics.drainCoreEvents", json!({}));
facts.extend(drained["events"].as_array().cloned().unwrap_or_default());
let (mine, others): (Vec<Value>, Vec<Value>) = facts
.drain(..)
.partition(|fact| fact["channel"] == channel.as_str());
*facts = others;
mine
}

/// Typed SDK notifications reach the Core-owned queue, with host operation correlation.
fn assert_log_facts(plugin: &str, expected: &[(&str, &str, &str)]) {
let records = take_plugin_facts(plugin);
for (operation, event, level) in expected {
let matches: Vec<_> = records
.iter()
.filter(|fact| fact["event"] == *event && fact["op"] == *operation)
.collect();
assert_eq!(
matches.len(),
1,
"one terminal {event} for {operation}: {records:#?}"
);
assert_eq!(matches[0]["level"], *level);
assert!(matches[0]["fields"]["duration_ms"].as_u64().is_some());
assert!(!matches[0].to_string().contains("built app"));
assert!(!matches[0].to_string().contains("s3cr3t"));
}
}
9 changes: 8 additions & 1 deletion sdk/e2e/plugin_sdk/vcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ impl Provider<'_> {
"arguments": arguments,
}),
);
self.session.completion(&operation_id)
let completed = self.session.completion(&operation_id);
let (event, level) = if completed.get("error").is_some() {
("plugins.operation.failed", "ERROR")
} else {
("plugins.operation.finished", "INFO")
};
super::assert_log_facts(&self.session.plugin_id, &[(&operation_id, event, level)]);
completed
}

/// Sends one operation and returns the reply as `vcs.decodeReply` normalizes it.
Expand Down
22 changes: 18 additions & 4 deletions sdk/java/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ SHA-256. The SDK is not published to a Maven repository. Use it like this:
A plugin package is installed read-only and must run offline, so the backend
JAR it starts must hold everything it loads.

## Logging

The new SDK requires `experimental.litheLogging` version 1 at initialization.
See [plugin logging](../../guides/plugin-logging.md) for the typed diagnostics
API, host-owned catalog, exact levels, operation correlation and `${logDir}`.
Use `diagnostics.degraded/recovered` (Rust: `diagnostics().degraded/recovered`)
for handled fallback or recovery facts. Function and build/VCS terminal facts
are automatic; do not log a failure before returning it to the SDK. Suppress
successful high-frequency reads with `suppressSuccessDiagnostics(operation)`
(Rust: `suppress_success_diagnostics`). The old free-text `log` API is removed;
there is no stderr fallback or plugin-owned file writer.

## API

| API | Purpose |
Expand All @@ -104,7 +116,7 @@ JAR it starts must hold everything it loads.
| `onInitialize(hook)`, `onShutdown(hook)` | Lifecycle hooks. `onInitialize` receives the `initialize` params and what was negotiated. |
| `onRequest(method, handler)`, `onNotification(method, handler)` | Ordinary LSP methods. Lifecycle and `lithe/*` methods are reserved. |
| `sendRequest`, `sendNotification` | Ordinary LSP messages to the host. `lithe/*` methods are reserved. |
| `log(message[, error])` | Writes to stderr, which the host keeps in its log. |
| `diagnostics().degraded(operation, code[, count])`, `.recovered(...)` | Typed handled fallback/recovery; no payload text. |
| `listen()` | Serves stdio until `exit` or the end of input, then exits the JVM with the protocol's exit code. `listen(in, out, err)` serves other streams and returns the code instead. |
| `PluginError(code, message)` | A failure meant for your own UI. |
| `Processes.run(command[, options])` | Runs a process without a shell. Its stdin is closed. `Options` set the directory, environment, output limits, a cancellation, and output callbacks. |
Expand All @@ -117,7 +129,7 @@ Handlers run on their own threads. Every handler receives a `CallContext`:
| `cancellation()`, `isCancelled()` | Cancelled when any of these happens: Lithe cancels the call, its deadline passes, the function is withdrawn, the session ends, or the handler finishes. Finishing the handler stops the work it left running. |
| `throwIfCancelled()` | Throws `CancelledException` once the call is cancelled. |
| `run(command[, options])` | `Processes.run` bound to the call: cancelling or ending the call stops the process. |
| `log(message)` | Same as `Backend.log`. |
| `diagnostics()` | Typed diagnostics correlated with this call. |
| `functionId()` | Functions only. |
| `host()` | [Host services](#host-services) bound to the call: ending the call cancels the requests it left unanswered. |
| `workspaceContext()` | Protocol 3 functions: the `{ contextID, generation }` of the window that invoked the call, or null. |
Expand All @@ -136,8 +148,8 @@ build operations follow their domain contract:
- A returned value becomes `result.value`.
- `PluginError` becomes `plugin.function.failed` with your message and
`pluginErrorCode`.
- Any other exception is reported with a generic message, and its stack trace
goes to stderr.
- Any other exception is reported with a generic message; automatic diagnostics
keep a fixed failure category without its message, cause or stack.
- `CancelledException` ends the call as cancelled.
- A result larger than 1 MiB of JSON fails like an unexpected error.

Expand Down Expand Up @@ -233,6 +245,8 @@ In Java:
passes through launch templates (`${stateDir}`, `${workspaceStateDir}`).
`StateStore.open` refuses a directory inside the plugin package. It finds
the package by walking up from the SDK's own JAR to `plugin.json`.
Diagnostics use the SDK and host; native log files require an explicitly
supplied `${logDir}` on a compatible local host.
5. **Work ends with its call.** Start processes with `context.run`, and check
`isCancelled()` in long loops. On cancellation the SDK first asks the
process to stop, then stops it forcibly after a grace period.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,7 @@ private Object build(Map<String, Object> params, CallContext context) throws IOE
command.add((String) part);
}
Processes.Result result = context.run(command, new Processes.Options()
.directory(inside(workspaceRoot, (String) project.get("path")))
.onStdout(text -> context.log("[" + name + "] " + text.trim())));
.directory(inside(workspaceRoot, (String) project.get("path"))));
results.add(object("name", name, "exitCode", result.exitCode()));
if (result.exitCode() != 0) {
record(results, "failed");
Expand Down Expand Up @@ -167,7 +166,7 @@ private void offerClear() {
return null;
}).whenComplete((ignored, error) -> {
if (error != null) {
backend.log("snapshot.clear stays unavailable: " + error.getMessage());
backend.diagnostics().degraded("initialize", "registration_failed");
}
});
}
Expand Down
Loading
Loading