From 2f8b0d5db1581af3d15e43afd02e7503bbd7870c Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Mon, 26 Oct 2026 23:31:36 +0800 Subject: [PATCH 01/76] feat(plugins): execute native build tasks in workspace providers (#1495) --- ...2026-10-17-build-system-plugin-contract.md | 4 + ...026-10-26-build-provider-task-execution.md | 88 +++++ macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../Core/Rust/RustBuildSystemOperations.swift | 4 +- .../Build/MacBuildProviderSessions.swift | 25 +- .../Execution/BuildSystemContracts.swift | 28 +- .../Services/BuildSystemService.swift | 85 ++++- .../Runtime/LanguageServerSession.swift | 20 +- .../LitheModuleAPI/Plugins/PluginTypes.swift | 2 +- .../BuildSystemServiceTests.swift | 122 ++++++- .../BuildToolProviderResolutionTests.swift | 8 + .../MacBuildProviderSessionsTests.swift | 72 +++- .../LitheTests/PluginWebBridgeTests.swift | 1 + .../RustBuildSystemOperationsTests.swift | 22 ++ rust/lithe-core/src/build/manifest.rs | 68 +++- rust/lithe-core/src/build/plans.rs | 52 ++- .../src/lsp/interface/build_provider.rs | 116 ++++++- rust/lithe-core/src/lsp/interface/client.rs | 23 ++ rust/lithe-core/src/lsp/interface/engine.rs | 324 +++++++++++++++++- rust/lithe-core/src/plugins/capabilities.rs | 2 +- rust/lithe-core/src/plugins/declarative.rs | 1 + rust/lithe-core/src/tests/build_systems.rs | 70 +++- shared/contracts/plugins/v1/README.md | 69 +++- .../v1/fixtures/build-execution-protocol.json | 51 +++ .../build-provider-execution.plugin.json | 175 ++++++++++ .../plugins/v1/plugin-manifest.schema.json | 6 +- shared/contracts/rust-core-api.md | 27 +- shared/i18n/locales/en-US.json | 3 +- shared/i18n/locales/zh-CN.json | 3 +- shared/i18n/messages.json | 11 + .../features/build-system-plugins.json | 10 +- 32 files changed, 1426 insertions(+), 70 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md create mode 100644 shared/contracts/plugins/v1/fixtures/build-execution-protocol.json create mode 100644 shared/contracts/plugins/v1/fixtures/build-provider-execution.plugin.json diff --git a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md index 8262826890..7dfb649559 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md +++ b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md @@ -12,6 +12,10 @@ Rust Core 负责校验声明、识别工程、展开每一条命令行,并把 目前落地的是 Core、共享契约和 macOS 宿主(第 10 节)。macOS 为每个启用的构建系统各开一个工具窗口,向 Core 声明 `buildSystems: [1]` 和 `buildProviders: [1]`,并在用户点击加载模型或依赖时启动 Provider、发送 `lithe/build/*`;Windows 宿主还没有接入。API 3 的清单按能力分版本,声明式构建系统使用 `buildSystems`,两种写法的 Provider 都额外使用 `buildProviders: 1`;插件市场只在宿主声明支持对应能力时才列出 API 3 的版本,见[声明式插件按能力分版本](2026-10-17-plugin-capability-versions.md)。宿主接入和 Maven 迁移的计划见[构建系统 Provider 提案](../../proposed/architecture/2026-10-17-build-system-provider-api.md)。 +### Provider 任务执行补充(2026-10-26) + +当前增加 `buildProviders: 2` 与 `litheBuild` 协议 2:原生 Build 任务可通过类型化执行请求进入同一个工作区 Provider,不要求网页,不使用插件私有函数。本文关于版本 1 仅有模型/依赖的说明仍适用于版本 1;具体边界、取消与删除条件见[Build Provider 原生任务执行](2026-10-26-build-provider-task-execution.md)。这项能力不代表 Maven/Java 的旧生产消费者已完成迁移。 + ## 问题 在这次改动之前,构建工具的知识分散在三处: diff --git a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md new file mode 100644 index 0000000000..4d8902fae8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md @@ -0,0 +1,88 @@ +# Agent 笔记:Build Provider 原生任务执行 + +状态:已实现 + +## 先说结论 + +构建插件现在可以在自己的工作区 Provider(提供构建能力的后端进程)里执行任务,而不是把每个任务都展开为宿主命令。原生 Build 窗口复用现有工作区会话、通用输出与诊断、取消和进程清理,不要求插件增加网页,也不调用插件的私有函数。这是 [#1495](https://github.com/1lck/Lithe-IDEA/issues/1495) 阶段 C 的第一项执行能力;Maven/Java 业务迁移、运行准备、测试与公共调试注册接入仍须另行完成。 + +## 问题 + +现有 `buildProviders: 1` 只承载模型和依赖。普通任务由 Core 展开参数、宿主启动 CLI(命令行工具),因此后端即使拥有工程快照,也不能从原生入口掌握构建执行。直接调用插件私有函数会让宿主猜测参数、结果和错误码;另建一种工作区后端则重复 Provider 已有的进程、权限、取消和生命周期。 + +## 决策 + +### 类型化执行进入现有 Provider + +`buildProviders: 2` 增加任务的 `source: "provider"`,`litheBuild` 在进程初始化时协商版本 2。两者分别负责安装准入与进程实际协议,不可互相替代。只协商版本 1 的后端明确拒绝执行,不退回 CLI 或私有函数。 + +Core 继续校验工程/目标/模型任务选择、路径与生效选项,返回 Provider 计划。后端收到任务声明 ID、选择、选项、参数与可选工具路径后,自行解释并执行领域业务。显式无效的工具或运行时选择沿用现有解析器的终止失败,不许可后端换一套自动发现。普通声明式命令任务继续走原来的公共命令能力,这不是 Maven/Java 旧业务 fallback。 + +本次只支持有界任务;`application/service` 与测试发现不借执行 RPC(请求/回复调用)伪装接入。正式形状、限制和共享用例在 `shared/contracts/plugins/v1/`,不在本笔记重复字段全集。 + +### 每个操作只有一个持有方 + +原生入口 → `BuildSystemService` → `MacBuildProviderSessions` → `LanguageServerRuntimeSession` → Core `lsp.buildRequest` → 同一 Provider 进程。模型、依赖、执行及插件自己的函数可复用这个进程,但领域调用和私有函数继续有各自契约。 + +Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍在等待的同一执行请求转换成宿主操作 ID。取消、超时、退出或回复后删除等待项;晚到回复与进度不能复活操作,宿主 ID 复用也不串用旧消息。超时最长一小时,取消走原来的 `$/cancelRequest`,卡住的写入继续使用既有逃生与后代进程清理路径。 + +结果区分工具的非零退出码、无效协议结果和后端拒绝。最终输出用于现有诊断解码;已流式显示的输出不重复追加。进度和结果都有限制,不能把无限后台服务放进单次任务。 + +### 工作区与配置失效 + +每个进程继续按插件、清单/包、构建系统及工作区隔离。`BuildSystemService` 在任务上绑定运行 ID、工作区代次、生效选项指纹、启动设置及被监听构建文件的配置代次。停用/移除插件、工作区关闭/替换、取消,以及同一构建系统选项或被监听配置文件变化,都停止或使对应操作失效;后端迟到输出不能进入替换运行或新工作区。 + +同一路径重开仍是新的服务代次。插件更新先停止旧 Provider 再替换包;配置变化后的下一次调用按现有规则重新建立会话。Windows 当前尚未接入 Build Provider 宿主,继续不声明此能力,不以 Core 支持版本 2 代替 Windows 实机实现。 + +### 资源与数据边界 + +本次没有新增下载入口、生成目录、缓存路径或用户数据迁移。执行复用既有 `workspaceStateDir` 和进程生命周期;插件包与发行目录只读,运行状态、日志、锁和输出仍由现有平台存储 adapter 提供位置。可变工作区状态不跨工作树共享,不影响代码签名或 Sparkle 增量更新的基线。 + +契约/协议变更合并前,用 `lithe-plugin-docs` 的 SDK 端到端工具验证当前源码;合并后按[契约发布规则](2026-10-23-plugin-contract-publishing.md)同步副本。已有 SDK 模型/依赖示例须继续协商版本 1 并正常工作;SDK 的新执行处理器与 Maven 插件接入属于后续独立交付,本次不宣称它们已迁移。 + +### 正确做法与反例 + +正确做法:插件将构建任务声明为 Provider 来源,用户在原生窗口点击任务,宿主发送类型化执行请求;取消后,由同一个会话停止请求,旧输出不能写回新运行。 + +不要这样做:宿主通过 `functions.invoke("maven.build")` 猜测 Maven 私有函数;Provider 超时后再启动旧 `MavenService`;为获取工作区进程而创建空网页。它们分别绕过领域契约、保留旧业务回退和错误绑定页面生命周期。 + +## 考虑过的备选方案 + +- **扩展独立页面 backend。** 它是全应用范围的私有服务;改为工作区服务需要重复已有 Provider 的作用域和清理。执行首先复用现有工作区机制。 +- **只加入任意方法调用。** 代码较少,但 Core 无法判断允许的方法、结果及进度归属,容易成为通用 JSON-RPC 隧道;仅开放明确的构建执行方法。 +- **继续要求所有任务返回 argv。** 对简单工具仍然合适,但拥有工程状态的后端不能控制执行与结果;作为并列来源保留,后端任务没有失败回退。 +- **一次补齐全部 Run/Test/Debug。** 各领域有不同目标和状态要求;先交付可独立验证的执行路径,后续基于真实需求补类型化能力,复用 #1210 的调试契约。 + +## 后果 + +收益是无页面的原生入口可以调用工作区后端执行,复用现有进程和 UI,并通过同一套协议证明进度、失败和取消。代价是多一个明确的能力/协议版本;后端作者需要实现执行处理器,宿主不能自行把未知任务变成命令。Maven/Java 仍有旧生产调用,只有对应纵向迁移完成并删除旧实现,#1495 才能最终关闭。 + +## 验证 + +共享 fixture 覆盖声明准入、请求/结果与无效输入。Core 回归验证版本 1 拒绝、版本 2 执行、进度映射、取消与迟到结果隔离,以及注入时间的超时。macOS 回归覆盖真实 Core 计划解码、原生服务不启动命令、非零退出码、配置/停用/工作区失效、同工作区会话复用及不同工作区隔离,读取与执行前后包资源字节不变。脚本化运行时验证不等于真实安装包或 Maven/Java 工程验收。 + +```bash +./scripts/verify-agent-notes.sh +./scripts/verify-shared-contracts.sh +./scripts/verify-runtime-bundle-immutability.sh +./scripts/verify-service-boundaries.sh +./scripts/verify-windows-boundaries.sh +./scripts/verify-rust-core-comments.sh +./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'BuildSystemServiceTests|MacBuildProviderSessionsTests|RustBuildSystemOperationsTests|BuildToolProviderResolutionTests|PluginWebBridgeTests' +``` + +2026-10-26 本地验证:macOS 116 项与相关 Core/LSP/插件/构建 190 项通过,单项计时分别在 `macos-swift.json`、`core-provider-focused.json`;新增测试最长 74 毫秒。官方 SDK 工具对当前 Core 临时 Git 快照的 Node/Java/Rust 示例共 4 项端到端检查通过;这验证现有 SDK 的协议兼容,不代表 SDK 已有新执行处理器。i18n 35 项、共享契约、Rust 注释、Windows 边界、包只读静态检查和功能矩阵门禁均通过。 + +`verify-service-boundaries.sh` 仍报告本次未修改、基线已存在的 `macos/Sources/Lithe/Services/Java/RunLaunchEnvironment.swift:50` 硬编码系统路径,未放宽规则。真实发行包工作流与 Windows 实机验收未执行,矩阵继续保持 `pending`。契约副本合并后同步;新执行 SDK 与 Maven/Java 消费者继续单独迁移。 + +## 适用范围 + +- `rust/lithe-core/src/build/`、`rust/lithe-core/src/lsp/interface/`、`rust/lithe-core/src/plugins/` +- `macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift` +- `macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift` +- `macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift` +- `macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift` +- `macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift` +- `shared/contracts/plugins/v1/` +- `shared/platform-feature-matrix/features/build-system-plugins.json` diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index f99778211f..6af4ff4cef 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -3700,6 +3700,7 @@ "The interface language changes immediately. English is the default." = "The interface language changes immediately. English is the default."; "The item can be recovered from the macOS Trash." = "The item can be recovered from the macOS Trash."; "The language server could not apply this setting. Restart it to use the new value." = "The language server could not apply this setting. Restart it to use the new value."; +"The language server this build system uses is not available. Open a file of the language and try again." = "The language server this build system uses is not available. Open a file of the language and try again."; "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release." = "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release."; "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release." = "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release."; "The local Codex account changed. Reconnect to refresh quota." = "The local Codex account changed. Reconnect to refresh quota."; @@ -8075,6 +8076,7 @@ "native.theLanguageProviderIsNotInstalled" = "The %1$@ language provider is not installed."; "native.theLanguageServerCouldNotApplyThisSetting" = "The language server could not apply this setting. Restart it to use the new value."; "native.theLanguageServerIsDisabledInThisWorkspace" = "The %1$@ language server is disabled in this workspace. Enable it in Settings > LSP."; +"native.theLanguageServerThisBuildSystemUsesIsNotAvailable" = "The language server this build system uses is not available. Open a file of the language and try again."; "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisAutoBuildAfterYouConbfd63f6a8e" = "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release."; "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisPreviewAfterYouConfic01b41840e" = "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release."; "native.theLitheServerWasInstalledOnThisHost" = "The Lithe server was installed on this host."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 4727365d97..977dade7a5 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3700,6 +3700,7 @@ "The interface language changes immediately. English is the default." = "界面语言会立即生效。默认语言为英文。"; "The item can be recovered from the macOS Trash." = "可以从 macOS 废纸篓中恢复此项目。"; "The language server could not apply this setting. Restart it to use the new value." = "语言服务器无法应用此设置。请重启它以使用新值。"; +"The language server this build system uses is not available. Open a file of the language and try again." = "此构建系统使用的语言服务器不可用。请打开该语言的文件后重试。"; "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release." = "将全量下载最新正式版,在你确认安装后替换当前 AutoBuild。项目和设置会保留,但 AutoBuild 专有的设置可能无法在正式版中使用。"; "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release." = "将全量下载最新正式版,在你确认安装后替换当前预览版。项目和设置会保留,但预览版专有的设置可能无法在正式版中使用。"; "The local Codex account changed. Reconnect to refresh quota." = "本机 Codex 账号已变更,请重新连接以刷新额度。"; @@ -8075,6 +8076,7 @@ "native.theLanguageProviderIsNotInstalled" = "%1$@ 语言 Provider 尚未安装。"; "native.theLanguageServerCouldNotApplyThisSetting" = "语言服务器无法应用此设置。请重启它以使用新值。"; "native.theLanguageServerIsDisabledInThisWorkspace" = "此工作区已停用 %1$@ 语言服务器。请在“设置 > LSP”中启用它。"; +"native.theLanguageServerThisBuildSystemUsesIsNotAvailable" = "此构建系统使用的语言服务器不可用。请打开该语言的文件后重试。"; "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisAutoBuildAfterYouConbfd63f6a8e" = "将全量下载最新正式版,在你确认安装后替换当前 AutoBuild。项目和设置会保留,但 AutoBuild 专有的设置可能无法在正式版中使用。"; "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisPreviewAfterYouConfic01b41840e" = "将全量下载最新正式版,在你确认安装后替换当前预览版。项目和设置会保留,但预览版专有的设置可能无法在正式版中使用。"; "native.theLitheServerWasInstalledOnThisHost" = "已在这台主机上安装 Lithe 远端服务。"; diff --git a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift index 84b2ca699f..abe8663a73 100644 --- a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift @@ -89,13 +89,13 @@ struct RustBuildSystemOperations: BuildSystemPlanning { } func taskPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTaskRequest) throws - -> BuildProcessPlan { + -> BuildOutputPlan { let outcome: Outcome = try execute( "build.taskPlan", target: target, request: TaskRequest(context: context, request: request) ) - return try outcome.value.plan.processPlan() + return try outcome.value.plan.outputPlan() } func testPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTestRequest) throws diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift index a108325da3..e99d7fff9a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift @@ -100,9 +100,7 @@ final class MacBuildProviderSessions: BuildProviderRequesting { ) { case .languageServer(let languageID): guard let sessions = languageSessions?() else { - throw UnavailableError(message: String( - localized: "The language server this build system uses is not available. Open a file of the language and try again." - )) + throw UnavailableError(message: L10n.string("The language server this build system uses is not available. Open a file of the language and try again.")) } return try await sessions.requestBuildProvider( languageID: languageID, @@ -119,8 +117,21 @@ final class MacBuildProviderSessions: BuildProviderRequesting { } } + func execute( + _ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void + ) async throws -> BuildProviderExecutionResult { + // Reuse the plugin-owned workspace process and its bounded startup. + let session = try await ownedSession(for: target) + try Task.checkCancellation() + let value = try await session.buildRequest( + method: request.method, params: request.params, timeout: Self.seconds(timeout), progress: progress + ) + return try JSONDecoder().decode(BuildProviderExecutionResult.self, from: JSONEncoder().encode(value)) + } + func pluginFunctions(of target: BuildProviderTarget) async throws -> [String] { - try await functionSession(for: target).functionIDs + try await ownedSession(for: target).functionIDs } func invokePluginFunction( @@ -130,17 +141,17 @@ final class MacBuildProviderSessions: BuildProviderRequesting { timeout: Duration, progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void ) async throws -> ToolingJSONValue { - let session = try await functionSession(for: target) + let session = try await ownedSession(for: target) return try await session.invokeFunction( functionID, params: params, timeout: Self.seconds(timeout), progress: progress ) } /// The target's own process, started like `send` would. Functions are - /// served only by a process the plugin owns: a shared language server is + /// and native task execution are served only by a process the plugin owns: a shared language server is /// started by the host for every plugin of its language, so it never /// serves one plugin's functions. - private func functionSession(for target: BuildProviderTarget) async throws -> LanguageServerRuntimeSession { + private func ownedSession(for target: BuildProviderTarget) async throws -> LanguageServerRuntimeSession { try Task.checkCancellation() let settings = launchResolver.settingValues(forPlugin: target.pluginID, workspaceRootURL: target.workspaceURL) if let session = try await runningSession(for: target, settings: settings) { return session } diff --git a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift index 84f5384aa5..8f7629eaba 100644 --- a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift @@ -747,7 +747,7 @@ package struct BuildProviderHints: Equatable, Sendable { } } -/// A model or dependency plan: a command, or a request for the plugin's +/// A task, model or dependency plan: a command, or a request for the plugin's /// Provider process. package enum BuildOutputPlan: Equatable, Sendable { case process(BuildProcessPlan) @@ -947,7 +947,7 @@ package protocol BuildSystemPlanning: Sendable { providerHints: BuildProviderHints ) throws -> BuildOutputPlan func taskPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTaskRequest) throws - -> BuildProcessPlan + -> BuildOutputPlan func testPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTestRequest) throws -> BuildTestPlan func dependencyPlan( @@ -983,6 +983,25 @@ package protocol BuildSystemPlanning: Sendable { ) throws -> BuildSyncState } +/// Bounded execution progress of one provider request, independent of pages. +package struct BuildProviderProgress: Equatable, Sendable { + package let message: String? + package let percentage: Int? + package let output: String? + + package init(message: String? = nil, percentage: Int? = nil, output: String? = nil) { + self.message = message; self.percentage = percentage; self.output = output + } +} + +/// The completed tool outcome; nonzero is a build failure, not a transport failure. +package struct BuildProviderExecutionResult: Codable, Equatable, Sendable { + package let exitCode: Int32 + package let output: String + + package init(exitCode: Int32, output: String) { self.exitCode = exitCode; self.output = output } +} + // MARK: Provider processes /// The Provider process of one build system of one plugin in one workspace. @@ -1016,6 +1035,11 @@ package protocol BuildProviderRequesting: AnyObject { to target: BuildProviderTarget, timeout: Duration ) async throws -> ToolingJSONValue + /// Runs a typed provider task in the same workspace session. + func execute( + _ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void + ) async throws -> BuildProviderExecutionResult /// Stops every process whose target `keep` rejects. func retainProviders(_ keep: (BuildProviderTarget) -> Bool) /// The plugin functions the target's process registers now, sorted. diff --git a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift index f317401eef..815d05d1d0 100644 --- a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift +++ b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift @@ -136,6 +136,8 @@ package final class BuildSystemService: ObservableObject { /// Bumped when a watched workspace file changes, so a load that started /// before the change can tell its result is based on older files. private var fileRevision = 0 + private var providerConfigurationRevision = 0 + private var providerRunID: UUID? private var detectionTask: Task? private var redetectTask: Task? private var runTask: Task? @@ -403,6 +405,8 @@ package final class BuildSystemService: ObservableObject { entry.sync?.watch.contains(where: changed.contains) == true } guard affected else { return } + providerConfigurationRevision += 1 + if providerRunID != nil { stopRun() } // Reject the old input immediately; consumers wait for the coalesced // detection instead of starting Java with pre-save import settings. detectionTask?.cancel(); detectionTask = nil @@ -650,13 +654,31 @@ package final class BuildSystemService: ObservableObject { return startRun(entryID, title: title) { service, run in let context = try service.planContext(entry, workspaceURL: workspaceURL) let plan = try service.planner.taskPlan(entry.target, context: context, request: request) - let collected = try await service.runStreamedCommand( - plan, entry: entry, workspaceURL: workspaceURL, owner: .run(run) - ) + let configurationRevision = service.providerConfigurationRevision + let launchSettings = service.settings(entry.source.pluginID) + defer { if service.providerRunID == run.id { service.providerRunID = nil } } + let collected: CollectedOutput + switch plan { + case .process(let process): + collected = try await service.runStreamedCommand( + process, entry: entry, workspaceURL: workspaceURL, owner: .run(run) + ) + case .provider(let request): + collected = try await service.runProviderTask( + request, entry: entry, workspaceURL: workspaceURL, run: run + ) + } let diagnostics = await service.decodeDiagnostics(entry, workspaceURL: workspaceURL, output: collected.text) // The decode runs after the command; a stop, replacement, or // workspace switch in between must not publish into the new state. guard service.isCurrent(run) else { throw CancellationError() } + if case .provider(let request) = plan { + guard service.providerConfigurationRevision == configurationRevision, + service.settings(entry.source.pluginID) == launchSettings, + let current = service.entry(entry.id), + service.optionFingerprint(current, workspaceURL: workspaceURL) == request.optionFingerprint + else { throw CancellationError() } + } service.diagnostics = diagnostics return collected.exitCode } @@ -726,6 +748,7 @@ package final class BuildSystemService: ObservableObject { } runTask?.cancel() runTask = nil + providerRunID = nil runID = nil runEntryID = nil if runState == .running { @@ -991,6 +1014,9 @@ package final class BuildSystemService: ObservableObject { package func setOption(_ entryID: String, optionID: String, value: ToolingJSONValue?) { guard let workspaceURL, let entry = entry(entryID) else { return } let buildSystemID = entry.instance.buildSystemID + if providerRunID != nil, let runEntryID, self.entry(runEntryID)?.instance.buildSystemID == buildSystemID { + stopRun() + } var stored = optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) stored[optionID] = value var empty = optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) @@ -1019,6 +1045,9 @@ package final class BuildSystemService: ObservableObject { return } let buildSystemID = entry.instance.buildSystemID + if providerRunID != nil, let runEntryID, self.entry(runEntryID)?.instance.buildSystemID == buildSystemID { + stopRun() + } var stored = optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) let values: [ToolingJSONValue] if case .array(let items)? = stored[optionID] { values = items } else { values = [] } @@ -1250,6 +1279,56 @@ package final class BuildSystemService: ObservableObject { return CollectedOutput(text: collector.text, exitCode: exitCode) } + /// Provider tasks share model/dependency sessions, not plugin page sessions. + /// Progress/results are bound to this run and its effective configuration. + private func runProviderTask( + _ request: BuildProviderRequest, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity + ) async throws -> CollectedOutput { + guard let providers else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } + providerRunID = run.id + let revision = providerConfigurationRevision + let fingerprint = request.optionFingerprint + let launchSettings = settings(entry.source.pluginID) + let isValid: @MainActor @Sendable () -> Bool = { [weak self] in + guard let self, self.isCurrent(run), self.providerConfigurationRevision == revision, + self.settings(entry.source.pluginID) == launchSettings, + let current = self.entry(entry.id) else { return false } + return self.optionFingerprint(current, workspaceURL: workspaceURL) == fingerprint + } + let tool = try await providerTool(entry, workspaceURL: workspaceURL, owner: .run(run)) + guard isValid() else { throw CancellationError() } + var params = request.params + if let tool, case .object(var object) = params { + object["tool"] = .object(["executable": .string(tool.path)]) + params = .object(object) + } + let planned = BuildProviderRequest( + method: request.method, params: params, optionFingerprint: fingerprint, + executesProjectCode: request.executesProjectCode, mayUseNetwork: request.mayUseNetwork + ) + let directory = outputDirectory(entry) + let streamed = OutputCollector(limit: Self.decodedOutputLimit) + let result = try await providers.execute( + planned, on: Self.providerTarget(entry, workspaceURL: workspaceURL), timeout: .seconds(3600) + ) { [weak self] progress in + guard let self, isValid() else { return } + if let text = progress.output { + streamed.append(text) + self.appendOutput(text, in: directory) + } else if let message = progress.message { + self.appendOutput(message + "\n", in: directory) + } + } + guard isValid() else { throw CancellationError() } + if result.output.utf8.starts(with: streamed.text.utf8) { + appendOutput(String(decoding: result.output.utf8.dropFirst(streamed.text.utf8.count), as: UTF8.self), in: directory) + } else { + // A malformed stream must not hide the final diagnostic output. + appendOutput(result.output, in: directory) + } + return CollectedOutput(text: result.output, exitCode: result.exitCode) + } + /// Plans a model or dependency load. A provider request also carries the /// load reason and the build tool the host resolved, so the provider /// neither repeats the lookup nor answers a reload from a stale snapshot. diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 3f65d39748..d045b535f1 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -50,7 +50,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { private var sessionID: String? private var pendingOperations: [String: PendingOperation] = [:] - /// Progress listeners of running plugin functions, by operation ID. + /// Progress listeners of running provider tasks, by operation ID. + private var buildProgress: [String: @MainActor @Sendable (BuildProviderProgress) -> Void] = [:] + /// Progress listeners of plugin-private calls remain a separate contract. private var functionProgress: [String: @MainActor @Sendable (PluginFunctionProgress) -> Void] = [:] private var documentVersions: [URL: Int] = [:] private var pollTask: Task? @@ -572,7 +574,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { package func buildRequest( method: String, params: ToolingJSONValue, - timeout: TimeInterval + timeout: TimeInterval, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void = { _ in } ) async throws -> ToolingJSONValue { guard let sessionID, state == .ready else { throw LanguageServerRuntimeSessionError.notReady @@ -587,12 +590,14 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { try Task.checkCancellation() throw LanguageServerRuntimeSessionError.requestRejected(error.userMessage) } + buildProgress[operation.operationID] = progress // Registration happens before this actor can run the cancellation // below, so a cancelled request always finds its pending entry and // ends with the cancellation event Core publishes. return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in - registerPendingOperation(operation, name: method) { result in + registerPendingOperation(operation, name: method) { [weak self] result in + self?.buildProgress[operation.operationID] = nil continuation.resume(with: result.flatMap { event in guard case .object(let object)? = event.result, let value = object["value"] else { return .failure(LanguageServerRuntimeSessionError.missingResult) @@ -1065,6 +1070,14 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { onFunctionsChange?(updated) return false case "requestProgress": + if let operationID = event.operationID, let listener = buildProgress[operationID], + case .object(let object)? = event.result { + let message: String? = if case .string(let text)? = object["message"] { text } else { nil } + let output: String? = if case .string(let text)? = object["output"] { text } else { nil } + let percentage: Int? = if case .integer(let number)? = object["percentage"] { number } else { nil } + listener(BuildProviderProgress(message: message, percentage: percentage, output: output)) + return false + } guard let operationID = event.operationID, let listener = functionProgress[operationID], case .object(let object)? = event.result else { return false @@ -1147,6 +1160,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { pollTask = nil stopWatchdog?.disarm() stopWatchdog = nil + buildProgress = [:] functionProgress = [:] failPendingOperations(with: LanguageServerRuntimeSessionError.sessionStopped) if let sessionID { diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 84639f226d..54d22c8ba6 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -491,7 +491,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { /// not running it, so an entry is added only with its host support. public static let hostCapabilities: [String: [Int]] = [ "backend": [1], - "buildProviders": [1], + "buildProviders": [1, 2], "buildSystems": [1, 2], "executableResolution": [1], "executionProviders": [1], "languageServers": [1, 2], diff --git a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift index 1c3e076ede..c5475b360a 100644 --- a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift @@ -1343,6 +1343,111 @@ struct BuildSystemServiceTests { }) } + @Test(arguments: [Int32(0), Int32(7)]) + func providerTaskRunsWithoutPageOrHostCommand(exitCode: Int32) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let providers = FakeBuildProviders() + providers.executionResult = BuildProviderExecutionResult(exitCode: exitCode, output: "completed") + let runner = FakeBuildRunner() + let harness = Harness(planner: planner, runner: runner, providers: providers) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + try #require(harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") != nil) + try #require(await harness.waitForRunState { $0 != .running }) + #expect(providers.sent.map(\.request.method) == ["lithe/build/execute"]) + #expect(providers.sent.first?.target.workspaceURL == Self.workspace) + #expect(runner.requests.isEmpty) + #expect(harness.service.output.contains("completed")) + if exitCode == 0 { #expect(harness.service.runState == .succeeded) } + else { if case .failed = harness.service.runState {} else { Issue.record("Expected tool failure") } } + } + + @Test(arguments: ["early\n", "a"]) + func providerTaskAppendsFinalSuffixAndDecodesTheCompleteOutput(prefix: String) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let providers = FakeBuildProviders() + // A progress chunk may end before a combining scalar in the next + // chunk; compare byte prefixes rather than normalized Characters. + let final = prefix == "a" ? "a\u{301}" : "early\nfinal\n" + providers.executionResult = BuildProviderExecutionResult(exitCode: 0, output: final) + let release = BuildTestGate() + providers.replyGate = release + let harness = Harness(planner: planner, providers: providers) + defer { release.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") + try #require(await providers.requestStarted.waitUntilOpen()) + providers.executionProgress?(BuildProviderProgress(output: prefix)) + release.open() + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(Array(harness.service.output.utf8) == Array(final.utf8)) + #expect(planner.diagnosticOutputs == [final]) + } + + /// Configuration may change while the final decode is off the main actor. + @Test + func providerTaskOptionsCancelDuringDiagnosticDecode() async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let gate = GatedBuildDecode() + planner.diagnosticsGate = gate + let providers = FakeBuildProviders() + let harness = Harness(planner: planner, providers: providers) + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") + try #require(await gate.entered.waitUntilOpen()) + harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + #expect(harness.service.runState == .cancelled) + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + #expect(harness.service.diagnostics.isEmpty) + } + + /// A backend may drain after cancellation; no output or result may reach + /// the replacement workspace or the new effective configuration. + @Test(arguments: ["stop", "workspace", "options", "disable"]) + func providerTaskLateOutputCannotCrossItsOwner(change: String) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let providers = FakeBuildProviders() + let release = BuildTestGate() + providers.replyGate = release + let harness = Harness(planner: planner, providers: providers) + defer { release.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") + try #require(await providers.requestStarted.waitUntilOpen()) + providers.executionProgress?(BuildProviderProgress(output: "early")) + #expect(harness.service.output.contains("early")) + switch change { + case "workspace": harness.service.reset(workspaceURL: URL(fileURLWithPath: "/workspace/next")) + case "options": harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + case "disable": harness.sources = []; harness.service.pluginsChanged() + default: harness.service.stopRun() + } + providers.executionProgress?(BuildProviderProgress(output: "late")) + release.open() + try #require(await providers.requestFinished.waitUntilOpen()) + #expect(!harness.service.output.contains("late")) + #expect(!harness.service.output.contains("completed")) + #expect(harness.service.runState != .succeeded) + } + // MARK: Provider @Test @@ -2115,9 +2220,10 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { } func taskPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTaskRequest) throws - -> BuildProcessPlan { + -> BuildOutputPlan { access { $0.taskPlanContexts.append(context) } - return plan([request.task, "with space"], output: nil) + if let request = providerRequest { return .provider(request) } + return .process(plan([request.task, "with space"], output: nil)) } func testPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTestRequest) throws @@ -2461,6 +2567,18 @@ private final class FakeBuildProviders: BuildProviderRequesting { return reply } + var executionResult = BuildProviderExecutionResult(exitCode: 0, output: "completed") + var executionProgress: (@MainActor @Sendable (BuildProviderProgress) -> Void)? + + func execute( + _ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void + ) async throws -> BuildProviderExecutionResult { + executionProgress = progress + _ = try await send(request, to: target, timeout: timeout) + return executionResult + } + func retainProviders(_ keep: (BuildProviderTarget) -> Bool) { for target in running where !keep(target) { running.remove(target) diff --git a/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift b/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift index 94e7454301..bc2c7501e9 100644 --- a/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift +++ b/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift @@ -136,6 +136,14 @@ private final class ProviderRequestRecorder: BuildProviderRequesting { return .object(["format": .string("litheBuildModel"), "version": .integer(1), "projects": .array([.object(["path": .string("."), "name": .string("fixture")])])]) } + func execute( + _ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void + ) async throws -> BuildProviderExecutionResult { + _ = try await send(request, to: target, timeout: timeout) + return BuildProviderExecutionResult(exitCode: 0, output: "") + } + func retainProviders(_ keep: (BuildProviderTarget) -> Bool) {} } diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index 720c256beb..e9235864a5 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -9,6 +9,39 @@ import Testing /// process a request reuses, and when the host stops or restarts it. @MainActor struct MacBuildProviderSessionsTests { + /// Native execution and model requests share the same process; no page + /// or plugin-function negotiation participates. + @Test + func nativeExecutionReusesTheWorkspaceProviderAndDeliversProgress() async throws { + let fixture = try ProviderFixture() + defer { fixture.remove() } + let packageBefore = try fixture.packageSnapshot() + _ = try await fixture.load() + let received = BuildProgressRecorder() + let result = try await fixture.sessions.execute(BuildProviderRequest( + method: "lithe/build/execute", params: .object([:]), optionFingerprint: "", + executesProjectCode: true, mayUseNetwork: true + ), on: fixture.target, timeout: .seconds(5)) { received.values.append($0) } + #expect(result == BuildProviderExecutionResult(exitCode: 0, output: "built")) + #expect(received.values == [BuildProviderProgress(percentage: 50, output: "building")]) + #expect(fixture.core.startCalls.count == 1) + #expect(fixture.core.buildRequests == ["lithe/build/model", "lithe/build/execute"]) + let otherRoot = fixture.root.appendingPathComponent("other-workspace") + try FileManager.default.createDirectory(at: otherRoot, withIntermediateDirectories: true) + let other = BuildProviderTarget( + pluginID: ProviderFixture.pluginID, manifest: fixture.manifest, packageURL: fixture.packageURL, + buildSystemID: "gradle", workspaceURL: otherRoot + ) + _ = try await fixture.sessions.execute(BuildProviderRequest( + method: "lithe/build/execute", params: .object([:]), optionFingerprint: "", + executesProjectCode: true, mayUseNetwork: true + ), on: other, timeout: .seconds(5)) { _ in } + #expect(fixture.core.startCalls.count == 2) + #expect(try fixture.packageSnapshot() == packageBefore) + fixture.sessions.stopProviders(ofPlugin: ProviderFixture.pluginID) + #expect(fixture.core.stopCalls.count == 2) + } + /// A second request reuses the process; stopping the plugin's providers, /// which the plugin manager does before replacing its package, ends it /// and the next request starts a new one. @@ -421,6 +454,28 @@ private struct ProviderFixture { ) } + /// Compare both the complete listing and bytes, including added empty + /// directories; runtime state must not appear next to packaged inputs. + func packageSnapshot() throws -> [String: Data] { + let keys: Set = [.isDirectoryKey, .isSymbolicLinkKey] + let files = try #require(FileManager.default.enumerator( + at: packageURL, includingPropertiesForKeys: Array(keys) + )) + var snapshot: [String: Data] = [:] + for case let url as URL in files { + let relative = String(url.path.dropFirst(packageURL.path.count)) + let values = try url.resourceValues(forKeys: keys) + if values.isSymbolicLink == true { + snapshot["link:" + relative] = Data(try FileManager.default.destinationOfSymbolicLink(atPath: url.path).utf8) + } else if values.isDirectory == true { + snapshot["directory:" + relative] = Data() + } else { + snapshot["file:" + relative] = try Data(contentsOf: url) + } + } + return snapshot + } + func remove() { sessions.retainProviders { _ in false } try? FileManager.default.removeItem(at: root) @@ -617,10 +672,18 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked _buildRequests.append(method) _buildParams.append(params) let operationID = "\(sessionID)-\(_buildRequests.count)" + if method == "lithe/build/execute" { + events[sessionID, default: []].append(LanguageServerRuntimeEvent( + type: "requestProgress", operationID: operationID, + result: .object(["percentage": .integer(50), "output": .string("building")]) + )) + } + let value: ToolingJSONValue = method == "lithe/build/execute" + ? .object(["exitCode": .integer(0), "output": .string("built")]) + : .object(["session": .string(sessionID)]) events[sessionID, default: []].append(LanguageServerRuntimeEvent( - type: "requestCompleted", - operationID: operationID, - result: .object(["value": .object(["session": .string(sessionID)])]) + type: "requestCompleted", operationID: operationID, + result: .object(["value": value]) )) return .success(LanguageServerRuntimeOperation(operationID: operationID)) } @@ -681,3 +744,6 @@ private final class EmptyToolRuntime: LanguageToolRuntimePort { func languageToolProcessEnvironment(runtimeTools _: [String]) -> [String: String] { [:] } func missingLanguageToolMessage(_ name: String) -> String { name } } + +@MainActor +private final class BuildProgressRecorder { var values: [BuildProviderProgress] = [] } diff --git a/macos/Tests/LitheTests/PluginWebBridgeTests.swift b/macos/Tests/LitheTests/PluginWebBridgeTests.swift index 5829c883f0..7d863bd71c 100644 --- a/macos/Tests/LitheTests/PluginWebBridgeTests.swift +++ b/macos/Tests/LitheTests/PluginWebBridgeTests.swift @@ -465,6 +465,7 @@ struct PluginWebBridgeTests { ) #expect(request.project == BuildSelection(project: project)) #expect(request.target == BuildSelection(target: example)) + guard case .process(let plan) = plan else { Issue.record("Expected a command plan"); return } #expect(plan.arguments == ["run", "--example", "demo"]) #expect(plan.workingDirectory == instance.root) #expect(plan.execution == .application) diff --git a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift index e52a9b53d3..a16338dc1e 100644 --- a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift +++ b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift @@ -99,6 +99,27 @@ struct RustBuildSystemOperationsTests { #expect(goVersion.satisfied) } + @Test + func providerTaskPlanCarriesSelectionWithoutHostArguments() throws { + let target = try Self.target("build-provider-execution", "gradle") + let plan = try operations.taskPlan( + target, context: BuildPlanContext(root: ".", options: ["offline": .bool(true)]), + request: BuildTaskRequest(task: "build", project: BuildSelection(name: "app", path: "app")) + ) + guard case .provider(let request) = plan, case .object(let params) = request.params else { + Issue.record("Expected a typed provider execution plan") + return + } + #expect(request.method == "lithe/build/execute") + #expect(params["task"] == .string("build")) + #expect(params["project"] == .object([ + "name": .string("app"), "path": .string("app"), "tags": .array([]), "properties": .object([:]) + ])) + #expect(params["options"] == .object(["offline": .bool(true)])) + #expect(request.executesProjectCode) + #expect(request.mayUseNetwork) + } + @Test func taskAndTestPlansCarryOptionsAndSelection() throws { let target = try Self.target("build-static-file", "cargo") @@ -110,6 +131,7 @@ struct RustBuildSystemOperationsTests { context: context, request: BuildTaskRequest(task: "build", project: BuildSelection(project: project)) ) + guard case .process(let build) = build else { Issue.record("Expected a command plan"); return } #expect(build.arguments == ["build", "--message-format=short", "--release"]) #expect(build.workingDirectory == "crates/demo") #expect(build.output == nil) diff --git a/rust/lithe-core/src/build/manifest.rs b/rust/lithe-core/src/build/manifest.rs index 5a9bd8760d..b2e1359ff2 100644 --- a/rust/lithe-core/src/build/manifest.rs +++ b/rust/lithe-core/src/build/manifest.rs @@ -602,9 +602,27 @@ pub(crate) struct TaskDeclaration { /// template runs. Display only; empty files the task by `group`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) category: Vec, + #[serde(default, skip_serializing_if = "TaskSource::is_command")] + pub(crate) source: TaskSource, pub(crate) arguments: Vec, } +/// Whether Core expands a command or a workspace provider executes the task. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum TaskSource { + #[default] + #[serde(skip_deserializing)] + Command, + Provider, +} + +impl TaskSource { + fn is_command(&self) -> bool { + *self == Self::Command + } +} + fn task_execution() -> BuildExecution { BuildExecution::Task } @@ -903,6 +921,8 @@ fn check_target_tags(tags: &mut Vec, scopes: &[Scope], field: &str) -> V pub(crate) struct DeclarationContext<'a> { pub(crate) plugin_api_version: u32, pub(crate) build_systems_version: u32, + /// Admission version for provider execution; independent of wire negotiation. + pub(crate) build_providers_version: u32, pub(crate) official: bool, /// Setting IDs a provider's launch templates may name. pub(crate) declared_settings: &'a crate::plugins::settings::DeclaredSettings, @@ -987,7 +1007,13 @@ fn program(build_system: &BuildSystemDeclaration) -> Value { let tasks: Vec = build_system .tasks .iter() - .map(|task| json!({ "id": task.id, "arguments": task.arguments })) + .map(|task| { + let mut review = json!({ "id": task.id, "arguments": task.arguments }); + if task.source == TaskSource::Provider { + review["source"] = json!("provider"); + } + review + }) .collect(); let model = match &build_system.model { Some(ModelSource::Command { arguments, .. }) => json!({ "arguments": arguments }), @@ -1272,7 +1298,11 @@ impl BuildSystemDeclaration { fn validate_provider(&self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { let uses = matches!(self.model, Some(ModelSource::Provider)) - || matches!(self.dependencies, Some(DependenciesSource::Provider { .. })); + || matches!(self.dependencies, Some(DependenciesSource::Provider { .. })) + || self + .tasks + .iter() + .any(|task| task.source == TaskSource::Provider); let field = format!("{field}.provider"); let Some(provider) = &self.provider else { if uses { @@ -1283,9 +1313,24 @@ impl BuildSystemDeclaration { if !uses { return Err(invalid( field, - "No model or dependencies source uses the provider.", + "No model, dependencies or task source uses the provider.", )); } + if self + .tasks + .iter() + .any(|task| task.source == TaskSource::Provider) + { + if context.build_providers_version < 2 { + return Err(invalid(&field, "Provider tasks require buildProviders 2.")); + } + if !matches!(provider, ProviderDeclaration::Process(_)) { + return Err(invalid( + &field, + "Provider tasks require a plugin-owned provider process.", + )); + } + } match provider { ProviderDeclaration::LanguageServer(shared) => { if !context.language_servers.contains(&shared.language_id) { @@ -1588,6 +1633,15 @@ impl BuildSystemDeclaration { ..Allowed::none() } }; + if task.source == TaskSource::Provider { + if !task.arguments.is_empty() || task.execution != BuildExecution::Task { + return Err(invalid( + &field, + "A provider task has empty arguments and task execution.", + )); + } + continue; + } check_argument_count(&task.arguments, &format!("{field}.arguments"))?; template::validate_arguments(&task.arguments, &allowed, &format!("{field}.arguments"))?; if task.requires_user_arguments @@ -1731,6 +1785,14 @@ impl BuildSystemDeclaration { format!("{unknown} is not a fromModel task."), )); } + if self.tasks.iter().any(|task| { + task.source == TaskSource::Provider + && task.from_model + && (tasks.templates.is_empty() || tasks.templates.contains(&task.id)) + }) { + return Err(invalid(format!("{field}.fromTasks"), + "Provider tasks need a typed run capability and cannot become command run entries.")); + } tasks.templates = sorted_unique(std::mem::take(&mut tasks.templates)); tasks.executions.sort(); tasks.executions.dedup(); diff --git a/rust/lithe-core/src/build/plans.rs b/rust/lithe-core/src/build/plans.rs index 15e4244980..b22a3f439c 100644 --- a/rust/lithe-core/src/build/plans.rs +++ b/rust/lithe-core/src/build/plans.rs @@ -12,8 +12,8 @@ use super::decode::{self, join_relative}; use super::integrations; use super::manifest::{ BuildExecution, BuildSystemDeclaration, DependenciesSource, ExecutableCandidate, ModelSource, - OutputChannel, ProviderDeclaration, RunDeclaration, Scope, TestFilter, TestResultsSource, - WorkingDirectory, + OutputChannel, ProviderDeclaration, RunDeclaration, Scope, TaskSource, TestFilter, + TestResultsSource, WorkingDirectory, }; use super::model::{entry_id, BuildModel, ModelProject}; use super::options::{self, OptionContribution}; @@ -463,7 +463,20 @@ pub(crate) fn task_plan( build_system.display_name, request.task )) })?; - check_user_arguments(&request.user_arguments, &declaration.arguments)?; + if declaration.source == TaskSource::Provider { + if request.user_arguments.len() > MAX_USER_ARGUMENTS + || request + .user_arguments + .iter() + .any(|argument| argument.contains('\0') || argument.len() > MAX_USER_ARGUMENT) + { + return Err(invalid_selection( + "The extra arguments are too long or contain NUL.", + )); + } + } else { + check_user_arguments(&request.user_arguments, &declaration.arguments)?; + } let project = request .project .as_ref() @@ -523,6 +536,39 @@ pub(crate) fn task_plan( values.project = project.as_ref().map(Selection::selected); values.target = target.as_ref().map(Selection::selected); values.task = task.as_ref().map(Selection::selected); + if declaration.source == TaskSource::Provider { + // A model may override the template execution; do not turn an + // unbounded application/service into a single bounded RPC task. + if execution != BuildExecution::Task { + return Err(invalid_selection( + "A provider task supports task execution only.", + )); + } + let mut params = Map::new(); + params.insert("task".to_string(), json!(request.task)); + params.insert("userArguments".to_string(), json!(request.user_arguments)); + for (name, selected) in [ + ("project", &project), + ("target", &target), + ("modelTask", &task), + ] { + if let Some(selected) = selected { + params.insert( + name.to_string(), + json!({ + "name": selected.name, "path": selected.path, + "tags": selected.tags, "properties": selected.properties + }), + ); + } + } + return Ok(context.provider_plan( + declared_provider(build_system)?, + "lithe/build/execute", + "execution", + params, + )); + } context.plan(&declaration.arguments, &values, execution, Map::new()) } diff --git a/rust/lithe-core/src/lsp/interface/build_provider.rs b/rust/lithe-core/src/lsp/interface/build_provider.rs index b99f36fbb4..be4eb4af5b 100644 --- a/rust/lithe-core/src/lsp/interface/build_provider.rs +++ b/rust/lithe-core/src/lsp/interface/build_provider.rs @@ -15,15 +15,19 @@ use serde_json::{json, Value}; const EXPERIMENTAL_KEY: &str = "litheBuild"; /// Build-provider protocol versions this client speaks, in preference order. -const SUPPORTED_VERSIONS: [u64; 1] = [1]; +const SUPPORTED_VERSIONS: [u64; 2] = [2, 1]; /// Feature name recorded in the session's server capabilities once a /// provider answered a supported version. pub(crate) const BUILD_PROVIDER_FEATURE: &str = "litheBuild"; -/// Requests of protocol version 1. Core plans both and decodes their results, -/// so the host can only forward what `build.modelPlan` and -/// `build.dependencyPlan` returned. +/// Version 2 uses the same workspace session and operation lifecycle. +pub(crate) const EXECUTE_METHOD: &str = "lithe/build/execute"; +/// Progress never uses caller-chosen host operation IDs. +pub(crate) const PROGRESS_METHOD: &str = "lithe/build/progress"; +/// Separate feature so version 1 cannot accidentally execute version 2 tasks. +pub(crate) const EXECUTION_FEATURE: &str = "litheBuildExecution"; + const VERSION_1_METHODS: [&str; 2] = ["lithe/build/model", "lithe/build/dependencies"]; /// The client half of the negotiation, placed under `capabilities.experimental`. @@ -44,7 +48,68 @@ pub(crate) fn negotiated(capabilities: &Value) -> bool { /// Whether `method` is a request of the negotiated protocol. pub(crate) fn is_build_method(method: &str) -> bool { - VERSION_1_METHODS.contains(&method) + VERSION_1_METHODS.contains(&method) || method == EXECUTE_METHOD +} + +/// True only for the execution protocol, not merely any negotiated provider. +pub(crate) fn execution_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + == Some(2) +} + +/// Normalizes bounded progress; the engine checks the pending request identity. +pub(crate) fn progress(params: Option<&Value>) -> Option { + let params = params?.as_object()?; + let id = params.get("operationID")?.as_str()?; + let mut result = json!({ "operationID": id }); + for (field, limit) in [("message", 4096), ("output", 65536)] { + if let Some(value) = params.get(field) { + let text = value.as_str()?; + if text.len() > limit { + return None; + } + result[field] = json!(text); + } + } + if let Some(value) = params.get("percentage") { + result["percentage"] = json!(value.as_u64().filter(|value| *value <= 100)?); + } + Some(result) +} + +/// Stable outcome for a provider-owned task. Tool failure is an exit code, +/// while malformed replies and JSON-RPC errors are protocol failures. +pub(crate) fn execution_outcome(reply: &Value) -> Result { + if reply.get("error").is_some() { + return Err(( + "plugin.build.failed", + reply + .pointer("/error/message") + .and_then(Value::as_str) + .unwrap_or("The build provider rejected the task.") + .chars() + .take(4096) + .collect(), + )); + } + let invalid = ( + "plugin.build.invalidResult", + "The build provider returned an invalid task result.".to_string(), + ); + let value = reply.get("result").ok_or_else(|| invalid.clone())?; + let exit = value + .get("exitCode") + .and_then(Value::as_i64) + .filter(|code| i32::try_from(*code).is_ok()) + .ok_or_else(|| invalid.clone())?; + let output = value + .get("output") + .and_then(Value::as_str) + .filter(|text| text.len() <= 32 * 1024 * 1024) + .ok_or_else(|| invalid.clone())?; + Ok(json!({ "value": { "exitCode": exit, "output": output } })) } #[cfg(test)] @@ -63,7 +128,7 @@ mod tests { &json!({ "experimental": { "litheBuild": {} } }) )); assert!(!negotiated( - &json!({ "experimental": { "litheBuild": { "version": 2 } } }) + &json!({ "experimental": { "litheBuild": { "version": 3 } } }) )); assert!(!negotiated( &json!({ "experimental": { "litheBuild": { "version": "1" } } }) @@ -74,10 +139,45 @@ mod tests { } #[test] - fn version_one_accepts_only_its_two_requests() { + fn only_typed_provider_requests_are_accepted() { assert!(is_build_method("lithe/build/model")); assert!(is_build_method("lithe/build/dependencies")); - assert!(!is_build_method("lithe/build/execute")); + assert!(is_build_method("lithe/build/execute")); assert!(!is_build_method("workspace/executeCommand")); } + #[test] + fn execution_replies_and_progress_match_the_shared_contract() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/build-execution-protocol.json" + )) + .unwrap(); + assert_eq!( + execution_outcome(&json!({"result": fixture["result"]})).unwrap(), + json!({"value": fixture["result"]}) + ); + for result in fixture["invalidResults"].as_array().unwrap() { + assert_eq!( + execution_outcome(&json!({"result": result})).unwrap_err().0, + "plugin.build.invalidResult" + ); + } + assert_eq!( + execution_outcome(&json!({"error": {"message": "private"}})) + .unwrap_err() + .0, + "plugin.build.failed" + ); + assert_eq!( + progress(Some(&fixture["progress"])), + Some(fixture["progress"].clone()) + ); + for invalid in fixture["invalidProgress"].as_array().unwrap() { + assert!(progress(Some(invalid)).is_none()); + } + assert!(progress(Some(&json!({"operationID":"1","output":"x".repeat(65537)}))).is_none()); + assert!(execution_outcome( + &json!({"result": {"exitCode": 0,"output":"x".repeat(32*1024*1024+1)}}) + ) + .is_err()); + } } diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index b56d5918dd..ec02107976 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -469,6 +469,26 @@ pub(super) fn client_apply_server_message_for_plugin( } } } + super::build_provider::PROGRESS_METHOD + if !state.shutdown_requested + && state + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::EXECUTION_FEATURE) => + { + if let Some(result) = super::build_provider::progress(message.get("params")) { + events.push(LspClientEvent { + kind: "buildProgress".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(result), + error: None, + }); + } + } plugin_function::PROGRESS_METHOD if plugin_owned && !state.shutdown_requested @@ -1730,6 +1750,9 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { if super::build_provider::negotiated(capabilities) { insert_unique(&mut values, super::build_provider::BUILD_PROVIDER_FEATURE); } + if super::build_provider::execution_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::EXECUTION_FEATURE); + } if plugin_function::negotiate(capabilities).is_some() { insert_unique(&mut values, plugin_function::FUNCTIONS_FEATURE); } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 60b26ce11b..23b32afd93 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -2101,14 +2101,39 @@ impl RuntimeSession { ) .with_details(super::build_provider::BUILD_PROVIDER_FEATURE)); } + if request.method == super::build_provider::EXECUTE_METHOD + && !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::EXECUTION_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate build execution.", + ) + .with_details("plugin.build.unavailable")); + } + let mut params = request.params; + if request.method == super::build_provider::EXECUTE_METHOD { + let object = params.as_object_mut().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Build task parameters must be an object.", + ) + })?; + object.insert( + "operationID".to_string(), + json!(state.client.next_request_id.to_string()), + ); + } let timeout = request .timeout_milliseconds .map(|milliseconds| { Duration::from_millis(milliseconds.clamp(1, MAX_BUILD_PROVIDER_TIMEOUT_MS)) }) .unwrap_or(state.request_timeout); - let response = - allocate_raw_request(state.client.clone(), &request.method, request.params)?; + let response = allocate_raw_request(state.client.clone(), &request.method, params)?; let request_id = (response.state.next_request_id - 1).to_string(); let now = Instant::now(); state.client = response.state; @@ -3123,6 +3148,41 @@ impl RuntimeSession { ); } } + Some(PendingKind::Feature) + if pending_before.as_ref().is_some_and(|pending| { + pending.method == super::build_provider::EXECUTE_METHOD + }) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let (result, error) = + match super::build_provider::execution_outcome(&value) { + Ok(result) => (Some(result), None), + Err((code, message)) => ( + None, + Some(runtime_error( + self, + code, + "request", + Some(&pending.method), + None, + &message, + None, + None, + )), + ), + }; + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + result, + error, + ); + } + } + } Some(PendingKind::Feature) if pending_before .as_ref() @@ -3779,6 +3839,8 @@ impl RuntimeSession { push_functions_event(self, &mut state, result); } else if event.kind == "functionProgress" { push_function_progress_locked(self, &mut state, event.result); + } else if event.kind == "buildProgress" { + push_build_progress_locked(self, &mut state, event.result); } else if event.kind == "hostRequest" { outbound.extend(forward_host_request_locked( self, @@ -5495,6 +5557,57 @@ fn revoke_function_calls_locked( cancellations } +/// A wire request ID identifies one exact build operation in this session. +/// Cancellation/completion removes it from pending, so late progress is inert. +fn push_build_progress_locked( + session: &RuntimeSession, + state: &mut SessionState, + progress: Option, +) { + let Some(mut progress) = progress else { + return; + }; + let Some(id) = progress.get("operationID").and_then(Value::as_str) else { + return; + }; + let Some(pending) = state.pending.get(id) else { + return; + }; + if pending.method != super::build_provider::EXECUTE_METHOD { + return; + } + let Some(operation_id) = pending.operation_id.clone() else { + return; + }; + progress["operationID"] = json!(operation_id); + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "requestProgress".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: Some(operation_id), + method: Some(super::build_provider::EXECUTE_METHOD.to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(progress), + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + maven_profile_project: None, + maven_profile_task: None, + }, + ); +} + /// Publishes a progress report of a call that is still running. Reports for /// an unknown, finished, cancelled, or revoked operation are dropped, so a /// late report never revives an ended call in the host. @@ -11101,6 +11214,203 @@ public class Main { assert!(harness.snapshot().pending_operation_ids.is_empty()); } + /// Execution must not reach a model-only provider. + #[test] + fn provider_execution_requires_version_two() { + let harness = negotiated_build_provider(); + let error = build_request(&harness, "lithe/build/execute", json!({}), None).unwrap_err(); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/execute")); + } + + /// Ordered messages use a completed round trip as a barrier: old progress + /// and replies are drained before checking that cancellation stayed final. + #[test] + fn provider_execution_progress_cancel_and_late_results_are_isolated() { + let mut harness = Harness::start(|_| {}); + harness + .server + .complete_initialize(json!({"experimental":{"litheBuild":{"version":2}}})); + harness.await_state(LspLifecycleState::Ready); + let operation = build_request( + &harness, + "lithe/build/execute", + json!({"operationID":"spoof"}), + None, + ) + .unwrap(); + let id = harness.server.await_request("lithe/build/execute").unwrap(); + assert_eq!(harness.written_request(&id)["params"]["operationID"], id); + harness + .server + .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", + "params":{"operationID":id,"output":"first","percentage":20}})); + let progress = harness + .await_event(|event| event.kind == "requestProgress") + .clone(); + assert_eq!(progress.operation_id.as_deref(), Some(operation.as_str())); + assert_eq!(progress.result.unwrap()["output"], "first"); + harness.session().cancel_operation(&operation).unwrap(); + let ended = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert_eq!(ended.error.unwrap().code, "requestCancelled"); + harness + .server + .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", + "params":{"operationID":id,"output":"late"}})); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":{"exitCode":0,"output":"late"}})); + let barrier = build_request(&harness, "lithe/build/model", json!({}), None).unwrap(); + let barrier_id = harness.server.await_request("lithe/build/model").unwrap(); + harness + .server + .send(json!({"jsonrpc":"2.0","id":barrier_id,"result":{}})); + harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(barrier.as_str()) + }); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "requestProgress") + .count(), + 1 + ); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str())) + .count(), + 1 + ); + } + + /// Host operation IDs may be reused; only the current wire request ID + /// may contribute progress to that new call. + #[test] + fn provider_execution_reused_host_ids_reject_old_wire_progress() { + let mut harness = plugin_backend(json!({"litheBuild":{"version":2}})); + let operation = "reused-build".to_string(); + let send = |harness: &Harness| { + harness + .session() + .build_request( + BuildProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation.clone()), + method: "lithe/build/execute".to_string(), + params: json!({}), + timeout_milliseconds: None, + }, + operation.clone(), + ) + .unwrap() + }; + send(&harness); + let old = harness.server.await_request("lithe/build/execute").unwrap(); + harness.session().cancel_operation(&operation).unwrap(); + harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + send(&harness); + let current = harness + .server + .await_request_at("lithe/build/execute", 1) + .unwrap(); + assert_ne!(old, current); + for (id, output) in [(old, "old"), (current, "current")] { + harness + .server + .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", + "params":{"operationID":id,"output":output}})); + } + harness.await_event(|event| { + event.kind == "requestProgress" + && event + .result + .as_ref() + .is_some_and(|result| result["output"] == "current") + }); + assert!(harness + .events + .iter() + .filter(|event| event.kind == "requestProgress") + .all(|event| event.result.as_ref().unwrap()["output"] != "old")); + harness.session().cancel_operation(&operation).unwrap(); + } + + #[test] + fn provider_execution_validates_results_and_expires_without_sleeping() { + let mut harness = Harness::start(|_| {}); + harness + .server + .complete_initialize(json!({"experimental":{"litheBuild":{"version":2}}})); + harness.await_state(LspLifecycleState::Ready); + for (index, (reply, code)) in [ + (json!({"exitCode":7,"output":"failed build"}), None), + ( + json!({"exitCode":"bad"}), + Some("plugin.build.invalidResult"), + ), + ] + .into_iter() + .enumerate() + { + let operation = + build_request(&harness, "lithe/build/execute", json!({}), None).unwrap(); + let id = harness + .server + .await_request_at("lithe/build/execute", index) + .unwrap(); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":reply})); + let ended = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert_eq!(ended.error.as_ref().map(|error| error.code.as_str()), code); + if code.is_none() { + assert_eq!(ended.result.unwrap()["value"], reply); + } + } + let operation = + build_request(&harness, "lithe/build/execute", json!({}), Some(1000)).unwrap(); + harness + .server + .await_request_at("lithe/build/execute", 2) + .unwrap(); + harness + .session() + .expire_deadlines_at(Instant::now() + Duration::from_secs(2)); + assert_eq!( + harness + .await_event(|event| event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str())) + .error + .as_ref() + .unwrap() + .code, + "requestTimeout" + ); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + } + /// A process that answered build-provider protocol version 1. fn negotiated_build_provider() -> Harness { let mut harness = Harness::start(|_| {}); @@ -11147,7 +11457,7 @@ public class Main { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"]["litheBuild"], - json!({ "versions": [1] }) + json!({ "versions": [2, 1] }) ); let error = build_request(&harness, "lithe/build/model", json!({}), None) @@ -11200,11 +11510,7 @@ public class Main { #[test] fn build_requests_accept_only_the_protocol_methods() { let harness = negotiated_build_provider(); - for method in [ - "workspace/executeCommand", - "lithe/build/execute", - "shutdown", - ] { + for method in ["workspace/executeCommand", "shutdown"] { let error = build_request(&harness, method, json!({}), None) .expect_err("only protocol methods are build requests"); assert!(matches!(error.code, ErrorCode::InvalidRequest), "{method}"); @@ -11658,7 +11964,7 @@ public class Main { assert_eq!( initialize["params"]["capabilities"]["experimental"], json!({ - "litheBuild": { "versions": [1] }, + "litheBuild": { "versions": [2, 1] }, "litheFunctions": { "versions": [1, 2] }, "litheVcs": { "versions": [1] } }) diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index c5430e1ae6..eab35527fe 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -66,7 +66,7 @@ pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; /// capability keeps the meaning its section had at API level 2. pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (BACKEND, &[1]), - (BUILD_PROVIDERS, &[1]), + (BUILD_PROVIDERS, &[1, 2]), (BUILD_SYSTEMS, &[1, 2]), (DEBUG_ADAPTERS, &[1]), (EXECUTION_PROVIDERS, &[1]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index ac6b0e07c9..2eaf5e4923 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -975,6 +975,7 @@ impl DeclarativePluginManifest { &crate::build::DeclarationContext { plugin_api_version: self.plugin_api_version, build_systems_version: self.requires.get("buildSystems").copied().unwrap_or(1), + build_providers_version: self.requires.get("buildProviders").copied().unwrap_or(1), official: is_official_namespace(&self.id), declared_settings: &declared_settings, language_servers: &server_languages, diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index 55b69c4b42..7a5e79c210 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -1096,7 +1096,8 @@ fn option_arguments_may_repeat_under_opposite_conditions() { { "value": "${target.name}", "when": "targetSelected" }, { "value": "${optionArguments}", "when": "noTargetSelected" } ]); - assert_eq!(validate(manifest.clone())["status"], "ok"); + let validated = validate(manifest.clone()); + assert_eq!(validated["status"], "ok", "{validated}"); manifest["buildSystems"][0]["test"]["arguments"][4] = json!({ "value": "${optionArguments:release}", "when": "caseSelected" }); @@ -1599,7 +1600,8 @@ fn task_categories_and_option_placements_are_display_only() { fn explicit_build_tool_path_requires_supported_global_file_setting() { let manifest: Value = serde_json::from_str(contract_file!("fixtures/build-tool-path.plugin.json")).unwrap(); - assert_eq!(validate(manifest.clone())["status"], "ok"); + let validated = validate(manifest.clone()); + assert_eq!(validated["status"], "ok", "{validated}"); let invoke = |settings: Value| { call( "invocationPlan", @@ -1784,3 +1786,67 @@ fn executable_resolution_is_opt_in_and_custom_runtimes_share_the_same_plan() { json!("runtime-only"); assert_eq!(validate(manifest)["status"], "rejected"); } + +/// A provider-only task is admitted only with the host capability and never +/// becomes argv. Selection boundaries and effectful admission remain shared. +#[test] +fn provider_execution_tasks_are_typed_and_version_gated() { + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-execution.plugin.json" + )) + .unwrap(); + let validated = validate(manifest.clone()); + assert_eq!(validated["status"], "ok", "{validated}"); + let mut old = manifest.clone(); + old["requires"]["buildProviders"] = json!(1); + assert_eq!(validate(old)["status"], "rejected"); + let mut command = manifest.clone(); + command["buildSystems"][0]["tasks"][0]["arguments"] = json!(["build"]); + assert_eq!(validate(command)["status"], "rejected"); + // An older host must refuse admission, not try a command fallback. + let refused = validate_command(json!({ + "manifest": manifest, "hostCapabilities": { + "buildProviders":[1], "buildSystems":[1], "serverProcess":[1], "settings":[1] + } + })) + .unwrap(); + assert_eq!(refused["status"], "rejected"); + assert_eq!(refused["error"]["field"], "requires.buildProviders"); + + let mut run = manifest.clone(); + run["buildSystems"][0]["tasks"][0]["fromModel"] = json!(true); + run["buildSystems"][0]["tasks"][0] + .as_object_mut() + .unwrap() + .remove("scopes"); + run["buildSystems"][0]["run"] = json!([{"id":"run", "fromTasks":{"templates":["build"]}}]); + assert_eq!(validate(run)["status"], "rejected"); + let escape = build_task_plan_command(json!({ + "manifest":manifest, "buildSystemID":"gradle", + "request":{"root":"app","task":"build","project":{"name":"escape","path":"../escape"}} + })) + .unwrap(); + assert_eq!(escape["status"], "rejected"); + let oversized = build_task_plan_command(json!({ + "manifest":manifest, "buildSystemID":"gradle", + "request":{"root":".","task":"custom","userArguments":vec!["arg";65]} + })) + .unwrap(); + assert_eq!(oversized["status"], "rejected"); + let result = build_task_plan_command(json!({ + "manifest": manifest, "buildSystemID":"gradle", "request": {"root":".", "task":"build", + "project": {"name":"app","path":"app"}, "options":{"offline":true}} + })) + .unwrap(); + assert_eq!(result["status"], "ok", "{result}"); + assert!(result["plan"].get("arguments").is_none(), "{result}"); + assert_eq!(result["plan"]["provider"]["method"], "lithe/build/execute"); + assert_eq!( + result["plan"]["provider"]["params"]["project"]["path"], + "app" + ); + assert_eq!( + result["plan"]["provider"]["params"]["options"]["offline"], + true + ); +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index c1559f29e0..363c0e5162 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -406,7 +406,7 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | | `buildSystems` | Declarative build detection, models, and command plans | `1` | -| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers | `1` | +| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; version 2 adds provider task execution | `1`, `2` | | `vcsProviders` | `vcsProviders[]` | `1` | | `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters) | `1` | | `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | @@ -886,6 +886,66 @@ Canonical formats let a plugin's own script print the shape Core expects: Unknown fields in canonical documents are ignored so newer scripts work with older hosts. Output that does not fit its decoder is `plugin.build.invalidOutput`. +### Provider task execution (buildProviders 2) + +A task with `source: "provider"` requires `requires.buildProviders: 2`. +It keeps the ordinary task ID, scopes, target tags and model selection, but +`arguments` must be `[]` and `execution` must be `task`. Application/service +launch and test discovery are separate capabilities. Provider model-task +templates cannot be referenced by `run[].fromTasks`; they need a typed Run +capability rather than an empty command plan. A provider task never +falls back to a host command or a plugin-private function. Command tasks +retain their current shape and omit `source`. + +`build.taskPlan` returns a `provider` request instead of argv for this source. +`lithe/build/execute` receives the effective `buildSystemID`, workspace-relative +`root`, `options`, `optionArguments`, option `environment`, `task` declaration ID, +bounded `userArguments`, and optional `project`, `target`, `modelTask` selections +(`name`, workspace-relative `path`, `tags`, `properties`). Scope/path validation +is identical to command tasks. The provider owns build semantics; options +are not a second host-generated command. As with model/dependency requests, +the host may add `tool: { executable: }` from the +same invocation resolver; explicit invalid tool/runtime selection is a failure, +never permission for independent discovery. It must reject stale project facts +rather than guess a target. + +The host offers `litheBuild.versions: [2, 1]`. Version 2 adds only execution +and its progress notification to the two version 1 methods. A process that +chooses version 1 cannot receive execution; unsupported negotiation fails +explicitly. A plugin that needs this behavior declares capability version 2 +so unsupported hosts refuse installation. The two version numbers serve +different purposes: installation admission and per-process negotiation. + +Core sets `params.operationID` to the unique JSON-RPC request ID (as a string); +callers cannot choose it. `lithe/build/progress` notifications echo that ID and +may include `message` (up to 4 KiB UTF-8), integer `percentage` (0–100), and +`output` (up to 64 KiB UTF-8 per notification). Malformed reports and reports +for ended, cancelled, timed-out or other requests are dropped. Core maps the +wire ID back to the host operation ID; reused host IDs cannot accept old +progress. No plugin-function negotiation is needed. + +The reply is `{ "exitCode": , "output": }`. +`output` is the complete bounded diagnostic text (at most 32 MiB UTF-8), +including text streamed in progress; the host displays progress output and +uses final output for the existing diagnostic decoder without duplicating it +in the console. Nonzero exit codes are completed tool failures, not protocol +errors. Malformed replies end with `plugin.build.invalidResult`; JSON-RPC +errors end with `plugin.build.failed`. Timeout and cancellation use the +existing request codes and `$/cancelRequest`. Request completion occurs once; +late replies/progress do not revive it. + +The host binds each call to the plugin package, build-system/workspace +session, current run, effective option fingerprint and watched configuration revision. +Settings, package replacement, disable, workspace close/reopen, cancellation +and backend failure invalidate or stop the owning operation. The existing +Provider session owns children and writable workspace state; no page, +additional runtime, directory or cross-plugin private cache is required. +Only public typed results may cross domain boundaries. + +Canonical examples: `fixtures/build-provider-execution.plugin.json` and +`fixtures/build-execution-protocol.json`. Protocol v2 is a construction +primitive for #1495; it does not by itself migrate Maven or Java consumers. + ### Build providers Some tools expose their model only through their own API (the Gradle Tooling @@ -936,11 +996,12 @@ another format is `plugin.build.invalidOutput`. The manifest only says how to start the provider and which sources use it. Everything else is negotiated in the LSP `initialize` exchange, so a provider -can grow without a new manifest field or capability version: +can add optional data within a negotiated protocol version: -- The host sends `capabilities.experimental.litheBuild: { "versions": [1] }`, +- The host sends `capabilities.experimental.litheBuild: { "versions": [2, 1] }`, the build-provider protocol versions it speaks. Version 1 is the two - requests above with the params and result formats of this section. + requests above with the params and result formats of this section; + version 2 adds the typed execution described above. - The provider answers `capabilities.experimental.litheBuild: { "version": 1 }` with the version it chose from that list. A provider that answers no version, or one the host did not offer, is not used for build requests; a diff --git a/shared/contracts/plugins/v1/fixtures/build-execution-protocol.json b/shared/contracts/plugins/v1/fixtures/build-execution-protocol.json new file mode 100644 index 0000000000..0faea862a1 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-execution-protocol.json @@ -0,0 +1,51 @@ +{ + "version": 2, + "request": { + "method": "lithe/build/execute", + "params": { + "buildSystemID": "gradle", + "root": ".", + "task": "build", + "options": {}, + "optionArguments": [], + "environment": {}, + "userArguments": [] + } + }, + "result": { + "exitCode": 0, + "output": "built\n" + }, + "progress": { + "operationID": "42", + "message": "Building", + "percentage": 50, + "output": "compiling\n" + }, + "invalidResults": [ + {}, + { + "exitCode": true, + "output": "" + }, + { + "exitCode": 2147483648, + "output": "" + }, + { + "exitCode": 0, + "output": false + } + ], + "invalidProgress": [ + {}, + { + "operationID": "42", + "percentage": 101 + }, + { + "operationID": "42", + "output": false + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-execution.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-execution.plugin.json new file mode 100644 index 0000000000..725065e3e3 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-execution.plugin.json @@ -0,0 +1,175 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-execution", + "displayName": "Build Provider Execution Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 2, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + } + } + ] +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 8a6ef35716..3fad6f6413 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -28,7 +28,7 @@ "additionalProperties": false, "properties": { "backend": { "description": "Required exactly when backend is present: the plugin's own process serving its web pages.", "enum": [1] }, - "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support.", "enum": [1] }, + "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support.", "enum": [1, 2] }, "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1] }, "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, @@ -2243,6 +2243,10 @@ "maxLength": 64 } }, + "source": { + "description": "Omitted expands argv in Core; provider sends lithe/build/execute. Provider tasks require buildProviders 2, empty arguments, and task execution.", + "enum": ["provider"] + }, "requiresUserArguments": { "type": "boolean" }, diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 7afecc207e..537baac764 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -2539,17 +2539,22 @@ and virtual-location representation as ordinary navigation. `lsp.buildRequest` accepts `{ sessionId, operationId?, method, params, timeoutMilliseconds? }` and returns `{ operationId }`. `method` and `params` -are the `provider` field of a `build.modelPlan` or `build.dependencyPlan` -result; only `lithe/build/model` and `lithe/build/dependencies` are accepted, -and anything else is `invalid_request`. Every session offers -`capabilities.experimental.litheBuild: { versions: [1] }` in `initialize`; a +are the `provider` field of a `build.modelPlan`, `build.dependencyPlan` or +provider-sourced `build.taskPlan` result. Version 1 accepts `lithe/build/model` +and `lithe/build/dependencies`; version 2 also accepts `lithe/build/execute`. +Other methods are `invalid_request`; execution on version 1 is `not_supported`. +Every session offers +`capabilities.experimental.litheBuild: { versions: [2, 1] }` in `initialize`; a session whose process did not answer one of those versions rejects the request with `not_supported`, so a shared provider that does not speak the protocol still serves its language. `timeoutMilliseconds` replaces the session's request timeout for this request and is capped at one hour. The `requestCompleted` event carries the provider's reply unchanged as `result.value`, which the host passes to `build.decodeModel` or `build.decodeDependencies` as `result`; -cancellation and late results follow `lsp.cancelOperation`. +cancellation and late results follow `lsp.cancelOperation`. Execution replies +are validated as `{exitCode: Int32, output: string}` and progress is mapped +from a Core-owned wire request ID to `requestProgress` for the still-pending +operation. See the [provider execution contract](plugins/v1/README.md#provider-task-execution-buildproviders-2). `lsp.vcsRequest` accepts `{ sessionId, operationId?, vcs, operation, root, tool?, arguments?, timeoutMilliseconds? }` and returns `{ operationId }`. @@ -3305,11 +3310,11 @@ has one build workflow and never branches on a build-system ID: Core plans each process, the host runs it with the discovered executable, and Core decodes what it printed or wrote. -A build system with a `provider` also requires `buildProviders: 1`. Core supports -validation and planning for that capability; a host advertises it only after -implementing Provider lifecycle and requests. macOS does not yet advertise it, -so its marketplace and install review reject Provider plugins while accepting -compatible declarative build systems. +A build system with a `provider` requires `buildProviders: 1`, or version 2 +when a task uses `source: "provider"`. Core validates and plans both versions; +a host advertises each only after implementing its lifecycle and requests. +macOS advertises both and routes provider tasks through the existing workspace +session. Windows does not yet advertise Provider lifecycle support. Except `build.detect`, every command accepts `{ manifest, buildSystemID, request }`, revalidates the manifest, and answers @@ -3348,7 +3353,7 @@ and the directories are absolute host paths used only as substitution text. | `build.checkToolVersion` | `{ runtime?, output }` | `version`: `{ satisfied, version? }`; `runtime` checks that runtime dependency instead of the tool | | `build.resolveOptions` | `{ options, modelValues? }` | `options`: `{ options: [{ id, displayName, kind, value, source, choices, allowCustom }], dropped, fingerprint }` | | `build.modelPlan`, `build.dependencyPlan` | plan fields plus `project?` (dependencies), `outputFile?`, and for provider sources `refresh?` (boolean) and `tool?: { executable }` (absolute path) | `plan`: `{ buildSystemID, root, arguments, environment, workingDirectory, execution, optionFingerprint, output, decoder, executesProjectCode, mayUseNetwork }`; for a `provider` source `{ buildSystemID, root, provider: { method, params }, decoder, executesProjectCode, mayUseNetwork, optionFingerprint }`, where `method` is `lithe/build/model` or `lithe/build/dependencies` and `params` is `{ buildSystemID, root, options, optionArguments, environment, project?, refresh?, tool? }`; `refresh` appears only when `true` and `tool` only when the host sent it | -| `build.taskPlan` | plan fields plus `task`, `project?`, `target?`, `modelTask?`, `userArguments?` | `plan` with `arguments`, `environment`, `workingDirectory`, `execution`, and `optionFingerprint` | +| `build.taskPlan` | plan fields plus `task`, `project?`, `target?`, `modelTask?`, `userArguments?` | command `plan` with `arguments`, `environment`, `workingDirectory`, `execution`, and `optionFingerprint`; a provider task instead returns `provider: {method: "lithe/build/execute", params}` with effective options, selected task/entities, user arguments and effect metadata | | `build.testPlan` | plan fields plus `project?`, `target?`, `suite?`, `case?`, `userArguments?`, `projects?` (the model's project directories) | `plan` with the task-plan fields and `results`: `null`, `{ decoder, reports }` with workspace-relative globs (for the selected project, or for the root and every listed project when none is selected), or `{ decoder: "hostIntegration" }`. A `suite` or `case` the templates cannot place on the command line under this selection, such as a case without its suite when the tool only addresses `Suite#case`, is `plugin.build.invalidSelection` rather than a wider run | | `build.decodeModel` | `{ workspaceRoot, root, output?, outputFile?, result?, options? }`; `result` is the provider's JSON answer | `model`: `{ projects, targets, tasks, contentRoots, optionValues, run }`; `run` entries carry `buildSystem: { id, root, projectID, targetID?, optionFingerprint }` | | `build.decodeTestResults` | `{ workspaceRoot, root, project?, projects?, output, notBeforeMillis?, suite? }`; without `project` the reports of the root and every listed model project are read | `results`: `{ tests: [{ suite, case, status, message?, durationMillis?, path?, line?, invocations }], summary: { total, passed, failed, errors, skipped }, success }`; repeated invocations of one case merge into the most severe status | diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index cef21e5d72..c8a30859a0 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9295,7 +9295,8 @@ "native.openLocalFolder": "Local Folder", "native.size": "Size", "native.thisFolderCannotBeRead": "This folder cannot be read.", - "native.thisFolderIsEmpty": "This folder is empty" + "native.thisFolderIsEmpty": "This folder is empty", + "native.theLanguageServerThisBuildSystemUsesIsNotAvailable": "The language server this build system uses is not available. Open a file of the language and try again." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 4923071f87..46346873bf 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9295,7 +9295,8 @@ "native.openLocalFolder": "本地文件夹", "native.size": "大小", "native.thisFolderCannotBeRead": "无法读取此文件夹。", - "native.thisFolderIsEmpty": "此文件夹为空" + "native.thisFolderIsEmpty": "此文件夹为空", + "native.theLanguageServerThisBuildSystemUsesIsNotAvailable": "此构建系统使用的语言服务器不可用。请打开该语言的文件后重试。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 6de2dcbe03..80e13c2df5 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -88259,6 +88259,17 @@ "key": "This folder is empty", "arguments": [] } + }, + { + "id": "native.theLanguageServerThisBuildSystemUsesIsNotAvailable", + "description": "Native UI: The language server this build system uses is not available. Open a file of the language and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The language server this build system uses is not available. Open a file of the language and try again.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index b2bc106b4f..30df5225d6 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -2,7 +2,7 @@ "id": "build-system-plugins", "area": "扩展", "group": "插件与主题", - "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退", + "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退;buildProviders 2 原生任务执行复用工作区后端会话,带进度、取消、结果与配置失效隔离", "macos": { "evidence": [ "rust/lithe-core/src/build", @@ -28,7 +28,11 @@ "rust/lithe-core/src/plugins/executables.rs", "macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift", "macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift", - "macos/Tests/LitheTests/MacBuildToolResolverTests.swift" + "macos/Tests/LitheTests/MacBuildToolResolverTests.swift", + "shared/contracts/plugins/v1/fixtures/build-provider-execution.plugin.json", + "shared/contracts/plugins/v1/fixtures/build-execution-protocol.json", + "macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift", + ".agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -43,5 +47,5 @@ "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口与 lithe/build/execute 尚未接入,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。" + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。" } From 3e21778c77ccb61b45678846e347f9d1e281dbc8 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Tue, 27 Oct 2026 09:04:49 +0800 Subject: [PATCH 02/76] feat(plugins): discover and execute native provider tests (#1495) --- ...2026-10-17-build-system-plugin-contract.md | 4 +- ...026-10-26-build-provider-task-execution.md | 32 +- macos/Resources/en.lproj/Localizable.strings | 8 + .../zh-Hans.lproj/Localizable.strings | 8 + .../Core/Rust/RustBuildSystemOperations.swift | 32 +- .../Views/Run/BuildSystemToolWindowView.swift | 45 +++ .../Execution/BuildSystemContracts.swift | 36 +- .../Plugins/DeclarativePluginContracts.swift | 4 +- .../BuildSystemsFeatureModel.swift | 5 + .../Services/BuildSystemService.swift | 199 +++++++--- .../LitheModuleAPI/Plugins/PluginTypes.swift | 2 +- .../BuildSystemServiceTests.swift | 228 ++++++++++- .../MacBuildProviderSessionsTests.swift | 14 +- .../RustBuildSystemOperationsTests.swift | 50 ++- rust/lithe-core/src/build/commands.rs | 25 ++ rust/lithe-core/src/build/manifest.rs | 87 +++-- rust/lithe-core/src/build/mod.rs | 1 + rust/lithe-core/src/build/plans.rs | 85 +++- rust/lithe-core/src/build/test_discovery.rs | 91 +++++ .../src/lsp/interface/build_provider.rs | 41 +- rust/lithe-core/src/lsp/interface/client.rs | 3 + rust/lithe-core/src/lsp/interface/engine.rs | 368 ++++++++++-------- rust/lithe-core/src/plugins/capabilities.rs | 2 +- rust/lithe-core/src/protocol/command.rs | 6 + rust/lithe-core/src/runtime/dispatcher.rs | 10 + rust/lithe-core/src/tests/build_systems.rs | 170 +++++++- shared/contracts/plugins/v1/README.md | 95 ++++- .../fixtures/build-provider-tests.plugin.json | 195 ++++++++++ .../v1/fixtures/build-test-discovery.json | 24 ++ .../v1/fixtures/build-test-protocol.json | 48 +++ .../plugins/v1/plugin-manifest.schema.json | 10 +- shared/contracts/rust-core-api.md | 26 +- shared/i18n/locales/en-US.json | 6 +- shared/i18n/locales/zh-CN.json | 6 +- shared/i18n/messages.json | 44 +++ .../features/build-system-plugins.json | 10 +- 36 files changed, 1707 insertions(+), 313 deletions(-) create mode 100644 rust/lithe-core/src/build/test_discovery.rs create mode 100644 shared/contracts/plugins/v1/fixtures/build-provider-tests.plugin.json create mode 100644 shared/contracts/plugins/v1/fixtures/build-test-discovery.json create mode 100644 shared/contracts/plugins/v1/fixtures/build-test-protocol.json diff --git a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md index 7dfb649559..3939cf21d0 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md +++ b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md @@ -12,9 +12,9 @@ Rust Core 负责校验声明、识别工程、展开每一条命令行,并把 目前落地的是 Core、共享契约和 macOS 宿主(第 10 节)。macOS 为每个启用的构建系统各开一个工具窗口,向 Core 声明 `buildSystems: [1]` 和 `buildProviders: [1]`,并在用户点击加载模型或依赖时启动 Provider、发送 `lithe/build/*`;Windows 宿主还没有接入。API 3 的清单按能力分版本,声明式构建系统使用 `buildSystems`,两种写法的 Provider 都额外使用 `buildProviders: 1`;插件市场只在宿主声明支持对应能力时才列出 API 3 的版本,见[声明式插件按能力分版本](2026-10-17-plugin-capability-versions.md)。宿主接入和 Maven 迁移的计划见[构建系统 Provider 提案](../../proposed/architecture/2026-10-17-build-system-provider-api.md)。 -### Provider 任务执行补充(2026-10-26) +### Provider 任务与测试执行补充(2026-10-26) -当前增加 `buildProviders: 2` 与 `litheBuild` 协议 2:原生 Build 任务可通过类型化执行请求进入同一个工作区 Provider,不要求网页,不使用插件私有函数。本文关于版本 1 仅有模型/依赖的说明仍适用于版本 1;具体边界、取消与删除条件见[Build Provider 原生任务执行](2026-10-26-build-provider-task-execution.md)。这项能力不代表 Maven/Java 的旧生产消费者已完成迁移。 +当前增加 `buildProviders: 2/3` 与 `litheBuild` 协议 2/3:原生 Build 任务和测试可通过各自的类型化请求进入同一个工作区 Provider,不要求网页,不使用插件私有函数。测试报告复用原有解析器,筛选参数由后端解释;协议 3 还允许插件提供可精确运行的测试清单,在原生共享树中展示。本文关于版本 1 仅有模型/依赖的说明仍适用于版本 1;具体边界、取消与删除条件见[Build Provider 原生任务执行](2026-10-26-build-provider-task-execution.md)。这项能力不代表 Maven/Java 的旧生产消费者已完成迁移。 ## 问题 diff --git a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md index 4d8902fae8..52bcc51507 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md +++ b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md @@ -4,7 +4,7 @@ ## 先说结论 -构建插件现在可以在自己的工作区 Provider(提供构建能力的后端进程)里执行任务,而不是把每个任务都展开为宿主命令。原生 Build 窗口复用现有工作区会话、通用输出与诊断、取消和进程清理,不要求插件增加网页,也不调用插件的私有函数。这是 [#1495](https://github.com/1lck/Lithe-IDEA/issues/1495) 阶段 C 的第一项执行能力;Maven/Java 业务迁移、运行准备、测试与公共调试注册接入仍须另行完成。 +构建插件现在可以在自己的工作区 Provider(提供构建能力的后端进程)里执行任务、发现和运行测试,而不是把每个操作都展开为宿主命令。原生 Build 窗口复用现有工作区会话、输出、诊断、取消和进程清理,测试报告复用现有解析器,不要求插件增加网页或调用私有函数。这是 [#1495](https://github.com/1lck/Lithe-IDEA/issues/1495) 阶段 C 的构建与测试能力;Maven/Java 业务迁移、运行准备与公共调试注册接入仍须另行完成。 ## 问题 @@ -18,11 +18,27 @@ Core 继续校验工程/目标/模型任务选择、路径与生效选项,返回 Provider 计划。后端收到任务声明 ID、选择、选项、参数与可选工具路径后,自行解释并执行领域业务。显式无效的工具或运行时选择沿用现有解析器的终止失败,不许可后端换一套自动发现。普通声明式命令任务继续走原来的公共命令能力,这不是 Maven/Java 旧业务 fallback。 -本次只支持有界任务;`application/service` 与测试发现不借执行 RPC(请求/回复调用)伪装接入。正式形状、限制和共享用例在 `shared/contracts/plugins/v1/`,不在本笔记重复字段全集。 +这里只支持有界任务和测试;`application/service` 不借执行 RPC(请求/回复调用)伪装接入。测试发现使用独立的方法,不把发现误当成运行。正式形状、限制和共享用例在 `shared/contracts/plugins/v1/`,不在本笔记重复字段全集。 + +### 测试执行使用独立的版本准入 + +`buildProviders: 3` 和进程选择 `litheBuild` 版本 3 后,原生测试入口才能发送 `lithe/build/test`。版本 3 继续支持构建执行;选择版本 1/2 的进程明确拒绝测试,不转成构建任务或命令。测试声明仍持有作用范围、支持的筛选类型和报告位置,参数模板必须为空,也不能指定宿主领域集成的报告解析。 + +套件、用例和附加参数分别交给插件,由插件使用已有上游测试引擎执行。宿主不猜测 Java/Gradle 的选择器语法、不发现第二份测试模型。报告继续复用仓库现有的 JUnit XML 与标准测试结果解析、时间与路径检查;例如后端调用上游测试引擎生成 JUnit 报告,原生结果列表只读解析后的通用结果。不要把用例名拼进宿主命令后,再假定插件运行了完全相同的筛选条件。 + +测试与任务共用执行结果、进度及唯一操作身份,但各自有明确方法。工作区全部测试仍要求需要模型的构建系统已加载最新模型,以覆盖所有子工程报告。后端回复之后,运行身份与配置检查继续覆盖诊断和报告解析;改选项或关闭工作区不能发布迟到报告。 + +### 测试发现复用上游事实与已有树 + +插件显式声明 `test.discovery`,同时支持套件和用例筛选,原生窗口才提供发现入口。用户展开“已发现的测试”或点击发现时,宿主通过协议 3 的 `lithe/build/discoverTests` 请求整个实例的清单;不会在打开工程时扫描源码或后台执行工具。列表里的每个条目都必须能通过同一测试计划精确运行,Core(共享校验层)拒绝错误范围、路径、筛选类型和超限清单,不用部分成功假装已经列全测试。 + +插件复用其已选择的上游测试引擎回答套件、用例、工程及目标;宿主只显示现有共享树的节点。不增加另一套 Java 注解扫描、框架探测、工程图或 Test Explorer。条目身份包括工程和目标,所以两个模块中的同名测试不会互相覆盖。 + +发现和执行共用唯一原生操作槽及工作区进程,避免又建一个后台任务运行时。加载状态、成功空列表和失败是不同结果;失败可由用户重试。发现缓存仅在同一插件包、生效选项、启动设置及配置代次下可用,任何工作区源码修改都会清除清单,并取消正在进行的发现。旧工具栏选择运行前必须用 ID 重新查验当前清单,不能只保存旧请求然后直接执行。停用、更新、工作区重开和退出继续通过既有会话释放资源;报告和清单不写进插件包。 ### 每个操作只有一个持有方 -原生入口 → `BuildSystemService` → `MacBuildProviderSessions` → `LanguageServerRuntimeSession` → Core `lsp.buildRequest` → 同一 Provider 进程。模型、依赖、执行及插件自己的函数可复用这个进程,但领域调用和私有函数继续有各自契约。 +原生入口 → `BuildSystemService` → `MacBuildProviderSessions` → `LanguageServerRuntimeSession` → Core `lsp.buildRequest` → 同一 Provider 进程。模型、依赖、任务/测试执行及插件自己的函数可复用这个进程,但领域调用和私有函数继续有各自契约。 Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍在等待的同一执行请求转换成宿主操作 ID。取消、超时、退出或回复后删除等待项;晚到回复与进度不能复活操作,宿主 ID 复用也不串用旧消息。超时最长一小时,取消走原来的 `$/cancelRequest`,卡住的写入继续使用既有逃生与后代进程清理路径。 @@ -51,6 +67,8 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 - **扩展独立页面 backend。** 它是全应用范围的私有服务;改为工作区服务需要重复已有 Provider 的作用域和清理。执行首先复用现有工作区机制。 - **只加入任意方法调用。** 代码较少,但 Core 无法判断允许的方法、结果及进度归属,容易成为通用 JSON-RPC 隧道;仅开放明确的构建执行方法。 - **继续要求所有任务返回 argv。** 对简单工具仍然合适,但拥有工程状态的后端不能控制执行与结果;作为并列来源保留,后端任务没有失败回退。 +- **让测试复用任务方法并让宿主推导筛选命令。** 无法证明旧后端支持测试,也容易扩大筛选范围;单独协商测试方法,保留选择器为类型化数据。报告解析复用现有实现,不增加另一套测试报告系统。 +- **只在宿主源码里找测试,或返回不可运行的标签。** 宿主会拥有第二份框架事实,测试清单和实际运行容易不一致。插件返回上游选择器,Core 只复用现有测试计划校验;当前不支持精确筛选的工具暂不声明发现能力。 - **一次补齐全部 Run/Test/Debug。** 各领域有不同目标和状态要求;先交付可独立验证的执行路径,后续基于真实需求补类型化能力,复用 #1210 的调试契约。 ## 后果 @@ -74,6 +92,12 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 2026-10-26 本地验证:macOS 116 项与相关 Core/LSP/插件/构建 190 项通过,单项计时分别在 `macos-swift.json`、`core-provider-focused.json`;新增测试最长 74 毫秒。官方 SDK 工具对当前 Core 临时 Git 快照的 Node/Java/Rust 示例共 4 项端到端检查通过;这验证现有 SDK 的协议兼容,不代表 SDK 已有新执行处理器。i18n 35 项、共享契约、Rust 注释、Windows 边界、包只读静态检查和功能矩阵门禁均通过。 +测试执行补充验证:macOS 119 项与相关 Core 194 项通过,计时分别记录于 `provider-tests-macos.json` 和 `core-provider-focused.json`。原生入口覆盖零/非零退出码、报告发布、报告解析期间改选项,以及任务/测试共用的取消和迟到输出隔离;会话回归对两个方法分别验证跨工作区隔离和包字节不变。诊断解析后的反向订阅断言对两个入口各观察 500 毫秒,聚合测试约 1.025 秒;报告解析对应断言约 520 毫秒,不使用睡眠同步。 + +官方 SDK 工具在 `lithe-plugin-docs@2215a62b4d748a627bc5bb00ccec8bbf3e6ac749` 对当前 Core 的隔离快照完成 4 项端到端检查,已有版本 1 的模型/依赖及函数示例继续可用;这仍是兼容性证据,不代表三个 SDK 已提供版本 3 的测试处理器。i18n 35 项通过,功能矩阵继续保留真实安装包与 Windows 待验收状态。新增测试路径没有增加下载物或缓存资源,资源复用清单不变。 + +2026-10-27 测试发现补充验证:macOS 167 项、相关 Core 196 项、SDK 端到端 4 项与 i18n 35 项通过。`provider-discovery-macos.json` 与 `core-provider-focused.json` 记录逐项计时;发现清单往返真实 Core、原生精确运行、源码/设置/选项失效、排队后立即替换、无后端/后端失败,以及解码期间取消/停用/切换工作区均有回归。五种迟到清单的反向订阅各观察 500 毫秒,聚合约 2.566 秒;这是有界的不应发布断言,不是靠睡眠等待成功。SDK 工具仍使用上述固定版本,对当前源码的隔离快照重新通过;协议 3 的真实插件/SDK 处理器与发行包实机验收不包含在这项兼容性证据中。 + `verify-service-boundaries.sh` 仍报告本次未修改、基线已存在的 `macos/Sources/Lithe/Services/Java/RunLaunchEnvironment.swift:50` 硬编码系统路径,未放宽规则。真实发行包工作流与 Windows 实机验收未执行,矩阵继续保持 `pending`。契约副本合并后同步;新执行 SDK 与 Maven/Java 消费者继续单独迁移。 ## 适用范围 @@ -81,6 +105,8 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 - `rust/lithe-core/src/build/`、`rust/lithe-core/src/lsp/interface/`、`rust/lithe-core/src/plugins/` - `macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift` - `macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift` +- `macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift` +- `macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift` - `macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift` - `macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift` - `macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift` diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 6af4ff4cef..782868a69e 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1145,6 +1145,8 @@ "Disconnect" = "Disconnect"; "Disconnected" = "Disconnected"; "Discover MCP servers" = "Discover MCP servers"; +"Discover Tests" = "Discover Tests"; +"Discovered Tests" = "Discovered Tests"; "Discovering Maven…" = "Discovering Maven…"; "Discovering…" = "Discovering…"; "Disk" = "Disk"; @@ -1902,6 +1904,7 @@ "Load Project Model" = "Load Project Model"; "Load more" = "Load more"; "Load more commits" = "Load more commits"; +"Load or reload the project model before running all tests, so subproject reports are collected." = "Load or reload the project model before running all tests, so subproject reports are collected."; "Load the %@ model" = "Load the %@ model"; "Load the dependencies again" = "Load the dependencies again"; "Load the dependencies of %@" = "Load the dependencies of %@"; @@ -2322,6 +2325,7 @@ "No subagents yet" = "No subagents yet"; "No tasks yet" = "No tasks yet"; "No test runner is configured for %@." = "No test runner is configured for %@."; +"No tests discovered." = "No tests discovered."; "No textual diff available" = "No textual diff available"; "No textual difference" = "No textual difference"; "No tools running" = "No tools running"; @@ -5368,6 +5372,8 @@ "native.disconnect" = "Disconnect"; "native.disconnected" = "Disconnected"; "native.discoverMcpServers" = "Discover MCP servers"; +"native.discoverTests" = "Discover Tests"; +"native.discoveredTests" = "Discovered Tests"; "native.discovering" = "Discovering…"; "native.discoveringMaven" = "Discovering Maven…"; "native.disk" = "Disk"; @@ -6274,6 +6280,7 @@ "native.llds" = "%1$llds"; "native.load" = "Load"; "native.loadDiskVersion" = "Load Disk Version"; +"native.loadModelBeforeWorkspaceTests" = "Load or reload the project model before running all tests, so subproject reports are collected."; "native.loadMore" = "Load More"; "native.loadMore2" = "Load more"; "native.loadMoreCommits" = "Load more commits"; @@ -6711,6 +6718,7 @@ "native.noSubagentsYet" = "No subagents yet"; "native.noTasksYet" = "No tasks yet"; "native.noTestRunnerIsConfiguredFor" = "No test runner is configured for %1$@."; +"native.noTestsDiscovered" = "No tests discovered."; "native.noTextualDiffAvailable" = "No textual diff available"; "native.noTextualDifference" = "No textual difference"; "native.noToolsRunning" = "No tools running"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 977dade7a5..6f981b93da 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1145,6 +1145,8 @@ "Disconnect" = "断开连接"; "Disconnected" = "已断开"; "Discover MCP servers" = "发现 MCP 服务器"; +"Discover Tests" = "发现测试"; +"Discovered Tests" = "已发现的测试"; "Discovering Maven…" = "正在检测 Maven…"; "Discovering…" = "正在检测…"; "Disk" = "磁盘"; @@ -1902,6 +1904,7 @@ "Load Project Model" = "加载项目模型"; "Load more" = "加载更多"; "Load more commits" = "加载更多提交"; +"Load or reload the project model before running all tests, so subproject reports are collected." = "运行全部测试前,请加载或重新加载项目模型,以便收集子项目的测试报告。"; "Load the %@ model" = "加载 %@ 的模型"; "Load the dependencies again" = "重新加载依赖"; "Load the dependencies of %@" = "加载 %@ 的依赖"; @@ -2322,6 +2325,7 @@ "No subagents yet" = "暂无子代理"; "No tasks yet" = "暂无任务"; "No test runner is configured for %@." = "没有为 %@ 配置测试运行器。"; +"No tests discovered." = "未发现测试。"; "No textual diff available" = "没有可用的文本 Diff"; "No textual difference" = "没有文本差异"; "No tools running" = "暂无工具运行"; @@ -5368,6 +5372,8 @@ "native.disconnect" = "断开连接"; "native.disconnected" = "已断开"; "native.discoverMcpServers" = "发现 MCP 服务器"; +"native.discoverTests" = "发现测试"; +"native.discoveredTests" = "已发现的测试"; "native.discovering" = "正在检测…"; "native.discoveringMaven" = "正在检测 Maven…"; "native.disk" = "磁盘"; @@ -6274,6 +6280,7 @@ "native.llds" = "%1$lld秒"; "native.load" = "加载"; "native.loadDiskVersion" = "加载磁盘版本"; +"native.loadModelBeforeWorkspaceTests" = "运行全部测试前,请加载或重新加载项目模型,以便收集子项目的测试报告。"; "native.loadMore" = "加载更多"; "native.loadMore2" = "加载更多"; "native.loadMoreCommits" = "加载更多提交"; @@ -6711,6 +6718,7 @@ "native.noSubagentsYet" = "暂无子代理"; "native.noTasksYet" = "暂无任务"; "native.noTestRunnerIsConfiguredFor" = "没有为 %1$@ 配置测试运行器。"; +"native.noTestsDiscovered" = "未发现测试。"; "native.noTextualDiffAvailable" = "没有可用的文本 Diff"; "native.noTextualDifference" = "没有文本差异"; "native.noToolsRunning" = "暂无工具运行"; diff --git a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift index abe8663a73..9dc1b114d1 100644 --- a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift @@ -106,7 +106,37 @@ struct RustBuildSystemOperations: BuildSystemPlanning { request: TestRequest(context: context, request: request) ) let plan = outcome.value.plan - return BuildTestPlan(process: try plan.processPlan(), results: plan.results?.results) + return BuildTestPlan(execution: try plan.outputPlan(), results: plan.results?.results) + } + + func testDiscoveryPlan(_ target: BuildSystemTarget, context: BuildPlanContext) throws -> BuildProviderRequest { + let outcome: Outcome = try execute( + "build.testDiscoveryPlan", target: target, + request: OutputRequest(context: context, project: nil, outputFile: nil, hints: BuildProviderHints(reason: .refresh)) + ) + guard case .provider(let request) = try outcome.value.plan.outputPlan() else { + throw DeclarativePluginRejection(code: "plugin.build.unavailable", message: "Expected a provider test discovery plan.") + } + return request + } + + func decodeTestDiscovery( + _ target: BuildSystemTarget, context: BuildPlanContext, result: ToolingJSONValue + ) throws -> BuildTestDiscovery { + struct Request: Encodable { + let root: String + let options: [String: ToolingJSONValue] + let result: ToolingJSONValue + let pluginDirectory: String? + let workspaceStateDirectory: String? + } + struct Reply: Decodable { let discovery: BuildTestDiscovery } + let outcome: Outcome = try execute( + "build.decodeTestDiscovery", target: target, + request: Request(root: context.root, options: context.options, result: result, + pluginDirectory: context.pluginDirectory?.path, workspaceStateDirectory: context.workspaceStateDirectory?.path) + ) + return outcome.value.discovery } func dependencyPlan( diff --git a/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift b/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift index ed7312dd7a..3962d1e6f1 100644 --- a/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift +++ b/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift @@ -81,6 +81,7 @@ struct BuildSystemToolWindowView: View { .onChange(of: rootNodeIDs) { _ in expandNewRoots() } .onChange(of: entries.map(\.model)) { _ in clearSelection(); resetOptionEditor() } .onChange(of: entries.map(\.id)) { _ in clearSelection(); resetOptionEditor() } + .onChange(of: entries.map { feature.testDiscovery($0.id) }) { _ in clearSelection() } .onChange(of: feature.workspaceURL) { _ in clearSelection(); optionEntryID = nil; resetOptionEditor() } .sheet(item: $argumentsPrompt) { prompt in BuildArgumentsSheet(prompt: prompt) { arguments in @@ -499,6 +500,9 @@ struct BuildSystemToolWindowView: View { iconPath: "expui/nodes/testGroup.svg", entryID: entry.id, project: project, runnable: tests ) } + if entry.declaration.testDiscovery, project == nil || isRoot { + discoveredTestsNode(entry, ownerID: ownerID) + } if let scope = dependencyScope(entry, project: project, isRoot: isRoot) { dependencyNode(entry, project: scope.project, ownerID: ownerID) } @@ -690,6 +694,44 @@ struct BuildSystemToolWindowView: View { ) } + private func discoveredTestsNode(_ entry: BuildSystemEntry, ownerID: String) -> LitheTreeListNode { + let nodeID = ownerID + ":discovered-tests" + let state = feature.testDiscovery(entry.id) + let discover = { feature.discoverTests(entry.id) } + return treeNode(id: nodeID, title: L10n.string("Discovered Tests"), iconPath: "expui/nodes/testGroup.svg", + isLoading: state == .loading, onToggle: { + let opening = !expandedNodeIDs.contains(nodeID) + toggleNode(nodeID) + if opening, state == nil, !feature.isRunning { discover() } + }, menu: { + [.action("Discover Tests", isEnabled: !feature.isRunning, action: discover)] + }) { + switch state { + case .loaded(let discovery): + if discovery.tests.isEmpty { + LitheTreeListNode(id: nodeID + ":empty", isSelectable: false) { Text("No tests discovered.") } + } + for test in discovery.tests { + let title = [test.project?.path, test.target?.name, test.suite, test.testCase] + .compactMap { $0 }.joined(separator: " · ") + leafRow(id: nodeID + ":" + test.id, title: title, + help: test.project?.path, iconPath: "expui/nodes/testGroup.svg", entryID: entry.id, project: nil, + runnable: .discoveredTest(test.id, title: title)) + } + case .failed(let message): + LitheTreeListNode(id: nodeID + ":failed", isSelectable: false) { + Text(verbatim: message).foregroundStyle(LitheTheme.warning) + } + actionRow(id: nodeID + ":retry", title: L10n.string("Discover Tests"), + iconPath: "expui/general/refresh.svg") { if !feature.isRunning { discover() } } + case .loading: [] + case nil: + actionRow(id: nodeID + ":load", title: L10n.string("Discover Tests"), + iconPath: "expui/general/refresh.svg") { if !feature.isRunning { discover() } } + } + } + } + // MARK: Dependencies private struct DependencyScope { let project: BuildModel.Project? } @@ -886,6 +928,7 @@ struct BuildSystemToolWindowView: View { private enum Runnable: Equatable { case task(BuildTaskRequest, title: String, needsArguments: Bool) case tests(BuildTestRequest, title: String) + case discoveredTest(String, title: String) } private struct Selection: Equatable { @@ -914,6 +957,8 @@ struct BuildSystemToolWindowView: View { feature.runTask(entryID, request: request, title: title) case .tests(let request, let title): feature.runTests(entryID, request: request, title: title) + case .discoveredTest(let testID, let title): + feature.runDiscoveredTest(entryID, testID: testID, title: title) } model.showToolWindow(.buildOutput) } diff --git a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift index 8f7629eaba..ae253e1565 100644 --- a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift @@ -102,6 +102,7 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { public let testScopes: [BuildScope] /// Tags a target must carry to run its tests. public let testTargetTags: [String] + public let testDiscovery: Bool /// Empty when the build system lists no dependencies. public let dependencyScopes: [BuildScope] /// The build system declares a provider launch. Installation verifies @@ -111,12 +112,13 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { public init( id: String, displayName: String, icon: PluginUIIcon? = nil, modelSource: ModelSource?, options: [Option] = [], tasks: [Task], testScopes: [BuildScope], testTargetTags: [String] = [], - dependencyScopes: [BuildScope], declaresProvider: Bool = false + dependencyScopes: [BuildScope], declaresProvider: Bool = false, testDiscovery: Bool = false ) { self.id = id; self.displayName = displayName; self.icon = icon; self.modelSource = modelSource self.options = options; self.tasks = tasks self.testScopes = testScopes; self.testTargetTags = testTargetTags; self.dependencyScopes = dependencyScopes self.declaresProvider = declaresProvider + self.testDiscovery = testDiscovery } public func option(_ id: String) -> Option? { @@ -613,7 +615,7 @@ package struct BuildPlanContext: Equatable, Sendable { } /// A project, target, or model task a plan addresses. -package struct BuildSelection: Equatable, Sendable, Encodable { +package struct BuildSelection: Equatable, Sendable, Codable { package let name: String /// Workspace-relative directory. package let path: String? @@ -678,6 +680,26 @@ package struct BuildTestRequest: Equatable, Sendable { } } +/// Exact, Core-validated selectors owned by a plugin's upstream test engine. +package struct BuildTestDiscovery: Decodable, Equatable, Sendable { + package struct Item: Decodable, Equatable, Identifiable, Sendable { + package let id: String + package let suite: String + package let testCase: String + package let project: BuildSelection? + package let target: BuildSelection? + + private enum CodingKeys: String, CodingKey { + case id, suite, testCase = "case", project, target + } + + package var request: BuildTestRequest { + BuildTestRequest(project: project, target: target, suite: suite, testCase: testCase) + } + } + package let tests: [Item] +} + /// An argv the host runs as Core expanded it. package struct BuildProcessPlan: Equatable, Sendable { package enum Output: String, Equatable, Sendable { @@ -761,11 +783,11 @@ package struct BuildTestPlan: Equatable, Sendable { case hostIntegration } - package let process: BuildProcessPlan + package let execution: BuildOutputPlan package let results: Results? - package init(process: BuildProcessPlan, results: Results?) { - self.process = process; self.results = results + package init(execution: BuildOutputPlan, results: Results?) { + self.execution = execution; self.results = results } } @@ -950,6 +972,10 @@ package protocol BuildSystemPlanning: Sendable { -> BuildOutputPlan func testPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTestRequest) throws -> BuildTestPlan + func testDiscoveryPlan(_ target: BuildSystemTarget, context: BuildPlanContext) throws -> BuildProviderRequest + func decodeTestDiscovery( + _ target: BuildSystemTarget, context: BuildPlanContext, result: ToolingJSONValue + ) throws -> BuildTestDiscovery func dependencyPlan( _ target: BuildSystemTarget, context: BuildPlanContext, diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index ca45631600..d58753f2c2 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -1038,6 +1038,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { struct Test: Decodable { let scopes: [BuildScope] let targetTags: [String]? + let discovery: Bool? } let id: String @@ -1093,7 +1094,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { // A host integration lists the whole instance and each // project; the other sources declare their scopes. dependencyScopes: dependencies.map { $0.scopes ?? [.workspace, .project] } ?? [], - declaresProvider: declaresProvider + declaresProvider: declaresProvider, + testDiscovery: test?.discovery ?? false ) } } diff --git a/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift b/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift index a1408bc597..4107067102 100644 --- a/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift +++ b/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift @@ -68,6 +68,11 @@ package final class BuildSystemsFeatureModel: ObservableObject { package func runTests(_ entryID: String, request: BuildTestRequest, title: String) -> UUID? { service.runTests(entryID, request: request, title: title) } + package func testDiscovery(_ entryID: String) -> BuildTestDiscoveryState? { service.testDiscovery(entryID) } + package func discoverTests(_ entryID: String) { service.discoverTests(entryID) } + package func runDiscoveredTest(_ entryID: String, testID: String, title: String) { + service.runDiscoveredTest(entryID, testID: testID, title: title) + } package func loadDependencies( _ entryID: String, project: BuildModel.Project?, reason: BuildLoadReason = .initial ) { diff --git a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift index 815d05d1d0..f1b4472ed1 100644 --- a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift +++ b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift @@ -18,6 +18,12 @@ package enum BuildDependencyState: Equatable, Sendable { case failed(String) } +package enum BuildTestDiscoveryState: Equatable, Sendable { + case loading + case loaded(BuildTestDiscovery) + case failed(String) +} + package enum BuildRunState: Equatable, Sendable { case idle case running @@ -105,6 +111,14 @@ package final class BuildSystemService: ObservableObject { @Published package private(set) var outputRoots: [URL] = [] @Published package private(set) var diagnostics: [BuildDiagnostic] = [] @Published package private(set) var testResults: BuildTestResults? + @Published private var testDiscoveries: [String: BuildTestDiscoveryState] = [:] + private var discoverySnapshots: [String: DiscoverySnapshot] = [:] + private struct DiscoverySnapshot: Equatable { + let source: BuildSystemPluginSource + let fingerprint: String? + let settings: [String: ToolingJSONValue] + let revision: Int + } package private(set) var workspaceURL: URL? /// Nil while detection is pending. An empty value means no plugin import. @Published package private(set) var languageImport: JavaLanguageImportConfiguration? @@ -373,6 +387,9 @@ package final class BuildSystemService: ObservableObject { /// Ends what a now disabled plugin started and detects again with the /// plugins enabled now, so its instances leave the tool window. package func pluginsChanged() { + if let runEntryID, testDiscoveries[runEntryID] == .loading { stopRun() } + testDiscoveries = [:] + discoverySnapshots = [:] let enabled = Set(sources().map(\.pluginID)) let removed = Set(entries.filter { !enabled.contains($0.instance.pluginID) }.map(\.id)) if let runEntryID, removed.contains(runEntryID) { stopRun() } @@ -399,8 +416,15 @@ package final class BuildSystemService: ObservableObject { let root = workspaceURL.path.hasSuffix("/") ? workspaceURL.path : workspaceURL.path + "/" let changed = Set(urls.compactMap { url -> String? in let path = url.standardizedFileURL.path + if path == workspaceURL.path { return "." } return path.hasPrefix(root) ? String(path.dropFirst(root.count)) : nil }) + // Source edits change discovery even when no build configuration did. + if !changed.isEmpty { + if let runEntryID, testDiscoveries[runEntryID] == .loading { stopRun() } + testDiscoveries = [:] + discoverySnapshots = [:] + } let affected = entries.contains { entry in entry.sync?.watch.contains(where: changed.contains) == true } @@ -647,6 +671,65 @@ package final class BuildSystemService: ObservableObject { // MARK: Tasks and tests + /// Discovery is user-triggered and independent of a plugin page. Cached + /// selectors are visible/runnable only for their exact configuration. + package func testDiscovery(_ entryID: String) -> BuildTestDiscoveryState? { + guard let workspaceURL, let entry = entry(entryID), + discoverySnapshots[entryID] == discoverySnapshot(entry, workspaceURL: workspaceURL) + else { return nil } + return testDiscoveries[entryID] + } + + private func discoverySnapshot(_ entry: BuildSystemEntry, workspaceURL: URL) -> DiscoverySnapshot { + DiscoverySnapshot(source: entry.source, fingerprint: optionFingerprint(entry, workspaceURL: workspaceURL), + settings: settings(entry.source.pluginID), revision: providerConfigurationRevision) + } + + @discardableResult + package func discoverTests(_ entryID: String) -> UUID? { + guard let workspaceURL, let entry = entry(entryID), entry.declaration.testDiscovery else { return nil } + testDiscoveries[entryID] = nil + discoverySnapshots[entryID] = nil + return startRun(entryID, title: L10n.string("Discover Tests")) { service, run in + let context = try service.planContext(entry, workspaceURL: workspaceURL) + let request = try service.planner.testDiscoveryPlan(entry.target, context: context) + let isValid = service.runValidity(.provider(request), entry: entry, workspaceURL: workspaceURL, run: run) + service.providerRunID = run.id + service.discoverySnapshots[entryID] = service.discoverySnapshot(entry, workspaceURL: workspaceURL) + service.testDiscoveries[entryID] = .loading + defer { if service.providerRunID == run.id { service.providerRunID = nil } } + do { + guard let providers = service.providers else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } + let planned = try await service.providerRequestWithTool(request, entry: entry, workspaceURL: workspaceURL, run: run) + guard isValid() else { throw CancellationError() } + let result = try await providers.send(planned, to: Self.providerTarget(entry, workspaceURL: workspaceURL), + timeout: Self.modelTimeout) + guard isValid() else { throw CancellationError() } + let planner = service.planner + let discovery = try await Task.detached(priority: .userInitiated) { + try planner.decodeTestDiscovery(entry.target, context: context, result: result) + }.value + guard isValid() else { throw CancellationError() } + service.testDiscoveries[entryID] = .loaded(discovery) + return 0 + } catch { + if isValid() { service.testDiscoveries[entryID] = .failed(error.localizedDescription) } + else if service.runID == run.id { service.testDiscoveries[entryID] = nil } + throw error + } + } + } + + @discardableResult + package func runDiscoveredTest(_ entryID: String, testID: String, title: String) -> UUID? { + guard case .loaded(let discovery) = testDiscovery(entryID), + let test = discovery.tests.first(where: { $0.id == testID }) else { return nil } + let request = BuildTestRequest(project: test.project, target: test.target, + suite: test.suite, testCase: test.testCase, + projects: entry(entryID)?.model?.projects.map(\.path) ?? []) + return runTests(entryID, request: request, title: title) + } + /// Runs a declared task, or a model task through its template. @discardableResult package func runTask(_ entryID: String, request: BuildTaskRequest, title: String) -> UUID? { @@ -654,31 +737,13 @@ package final class BuildSystemService: ObservableObject { return startRun(entryID, title: title) { service, run in let context = try service.planContext(entry, workspaceURL: workspaceURL) let plan = try service.planner.taskPlan(entry.target, context: context, request: request) - let configurationRevision = service.providerConfigurationRevision - let launchSettings = service.settings(entry.source.pluginID) + let isValid = service.runValidity(plan, entry: entry, workspaceURL: workspaceURL, run: run) defer { if service.providerRunID == run.id { service.providerRunID = nil } } - let collected: CollectedOutput - switch plan { - case .process(let process): - collected = try await service.runStreamedCommand( - process, entry: entry, workspaceURL: workspaceURL, owner: .run(run) - ) - case .provider(let request): - collected = try await service.runProviderTask( - request, entry: entry, workspaceURL: workspaceURL, run: run - ) - } + let collected = try await service.runExecutionPlan(plan, entry: entry, workspaceURL: workspaceURL, run: run) let diagnostics = await service.decodeDiagnostics(entry, workspaceURL: workspaceURL, output: collected.text) // The decode runs after the command; a stop, replacement, or // workspace switch in between must not publish into the new state. - guard service.isCurrent(run) else { throw CancellationError() } - if case .provider(let request) = plan { - guard service.providerConfigurationRevision == configurationRevision, - service.settings(entry.source.pluginID) == launchSettings, - let current = service.entry(entry.id), - service.optionFingerprint(current, workspaceURL: workspaceURL) == request.optionFingerprint - else { throw CancellationError() } - } + guard isValid() else { throw CancellationError() } service.diagnostics = diagnostics return collected.exitCode } @@ -690,6 +755,8 @@ package final class BuildSystemService: ObservableObject { return startRun(entryID, title: title) { service, run in let context = try service.planContext(entry, workspaceURL: workspaceURL) let plan = try service.planner.testPlan(entry.target, context: context, request: request) + let isValid = service.runValidity(plan.execution, entry: entry, workspaceURL: workspaceURL, run: run) + defer { if service.providerRunID == run.id { service.providerRunID = nil } } if plan.results != nil, request.project == nil, entry.instance.modelSource != nil, entry.model == nil || entry.isReloadRequired { @@ -697,16 +764,16 @@ package final class BuildSystemService: ObservableObject { // model a new subproject's reports are invisible and a failing // run would decode as success. A plugin without a model has no // projects to discover, so its own reports stay readable. - throw BuildSystemServiceError(String( - localized: "Load or reload the project model before running all tests, so subproject reports are collected." + throw BuildSystemServiceError(L10n.string( + "Load or reload the project model before running all tests, so subproject reports are collected." )) } let started = Date() - let collected = try await service.runStreamedCommand( - plan.process, entry: entry, workspaceURL: workspaceURL, owner: .run(run) + let collected = try await service.runExecutionPlan( + plan.execution, entry: entry, workspaceURL: workspaceURL, run: run ) let diagnostics = await service.decodeDiagnostics(entry, workspaceURL: workspaceURL, output: collected.text) - guard service.isCurrent(run) else { throw CancellationError() } + guard isValid() else { throw CancellationError() } service.diagnostics = diagnostics if plan.results != nil { let resultsRequest = BuildTestResultsRequest( @@ -724,12 +791,12 @@ package final class BuildSystemService: ObservableObject { let results = try await Task.detached(priority: .userInitiated) { try planner.decodeTestResults(target, request: resultsRequest) }.value - guard service.isCurrent(run) else { throw CancellationError() } + guard isValid() else { throw CancellationError() } service.testResults = results } catch is CancellationError { throw CancellationError() } catch { - guard service.isCurrent(run) else { throw CancellationError() } + guard isValid() else { throw CancellationError() } service.appendOutput( "\n" + error.localizedDescription + "\n", in: service.outputDirectory(entry) ) @@ -745,6 +812,7 @@ package final class BuildSystemService: ObservableObject { var ended: BuildRunEnd? if let runID, let runEntryID { ended = BuildRunEnd(runID: runID, entryID: runEntryID, state: .cancelled, exitCode: nil) + if testDiscoveries[runEntryID] == .loading { testDiscoveries[runEntryID] = nil } } runTask?.cancel() runTask = nil @@ -767,6 +835,8 @@ package final class BuildSystemService: ObservableObject { isDetecting = false finishImportWaiters(.failure(CancellationError())) stopRun() + testDiscoveries = [:] + discoverySnapshots = [:] for task in modelTasks.values { task.cancel() } for task in dependencyTasks.values { task.cancel() } modelTasks = [:] @@ -803,7 +873,7 @@ package final class BuildSystemService: ObservableObject { runState = .running runningTitle = title runTask = Task { [weak self] in - guard let self else { return } + guard let self, self.isCurrent(run) else { return } let state: BuildRunState var exitCode: Int32? do { @@ -1014,6 +1084,7 @@ package final class BuildSystemService: ObservableObject { package func setOption(_ entryID: String, optionID: String, value: ToolingJSONValue?) { guard let workspaceURL, let entry = entry(entryID) else { return } let buildSystemID = entry.instance.buildSystemID + invalidateTestDiscovery(buildSystemID: buildSystemID) if providerRunID != nil, let runEntryID, self.entry(runEntryID)?.instance.buildSystemID == buildSystemID { stopRun() } @@ -1045,6 +1116,7 @@ package final class BuildSystemService: ObservableObject { return } let buildSystemID = entry.instance.buildSystemID + invalidateTestDiscovery(buildSystemID: buildSystemID) if providerRunID != nil, let runEntryID, self.entry(runEntryID)?.instance.buildSystemID == buildSystemID { stopRun() } @@ -1067,6 +1139,13 @@ package final class BuildSystemService: ObservableObject { detect() } + private func invalidateTestDiscovery(buildSystemID: String) { + for entry in entries where entry.instance.buildSystemID == buildSystemID { + testDiscoveries[entry.id] = nil + discoverySnapshots[entry.id] = nil + } + } + private func refreshOptions(buildSystemID: String) { guard let workspaceURL else { return } let stored = optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) @@ -1279,6 +1358,34 @@ package final class BuildSystemService: ObservableObject { return CollectedOutput(text: collector.text, exitCode: exitCode) } + /// Retain the provider configuration guard through every asynchronous decode, + /// including report parsing after the backend has already returned. + private func runValidity( + _ plan: BuildOutputPlan, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity + ) -> @MainActor @Sendable () -> Bool { + let revision = providerConfigurationRevision + let launchSettings = settings(entry.source.pluginID) + return { [weak self] in + guard let self, self.isCurrent(run) else { return false } + guard case .provider(let request) = plan else { return true } + guard self.providerConfigurationRevision == revision, + self.settings(entry.source.pluginID) == launchSettings, + let current = self.entry(entry.id), current.source == entry.source else { return false } + return self.optionFingerprint(current, workspaceURL: workspaceURL) == request.optionFingerprint + } + } + + private func runExecutionPlan( + _ plan: BuildOutputPlan, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity + ) async throws -> CollectedOutput { + switch plan { + case .process(let process): + return try await runStreamedCommand(process, entry: entry, workspaceURL: workspaceURL, owner: .run(run)) + case .provider(let request): + return try await runProviderTask(request, entry: entry, workspaceURL: workspaceURL, run: run) + } + } + /// Provider tasks share model/dependency sessions, not plugin page sessions. /// Progress/results are bound to this run and its effective configuration. private func runProviderTask( @@ -1286,26 +1393,9 @@ package final class BuildSystemService: ObservableObject { ) async throws -> CollectedOutput { guard let providers else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } providerRunID = run.id - let revision = providerConfigurationRevision - let fingerprint = request.optionFingerprint - let launchSettings = settings(entry.source.pluginID) - let isValid: @MainActor @Sendable () -> Bool = { [weak self] in - guard let self, self.isCurrent(run), self.providerConfigurationRevision == revision, - self.settings(entry.source.pluginID) == launchSettings, - let current = self.entry(entry.id) else { return false } - return self.optionFingerprint(current, workspaceURL: workspaceURL) == fingerprint - } - let tool = try await providerTool(entry, workspaceURL: workspaceURL, owner: .run(run)) + let isValid = runValidity(.provider(request), entry: entry, workspaceURL: workspaceURL, run: run) + let planned = try await providerRequestWithTool(request, entry: entry, workspaceURL: workspaceURL, run: run) guard isValid() else { throw CancellationError() } - var params = request.params - if let tool, case .object(var object) = params { - object["tool"] = .object(["executable": .string(tool.path)]) - params = .object(object) - } - let planned = BuildProviderRequest( - method: request.method, params: params, optionFingerprint: fingerprint, - executesProjectCode: request.executesProjectCode, mayUseNetwork: request.mayUseNetwork - ) let directory = outputDirectory(entry) let streamed = OutputCollector(limit: Self.decodedOutputLimit) let result = try await providers.execute( @@ -1329,6 +1419,19 @@ package final class BuildSystemService: ObservableObject { return CollectedOutput(text: result.output, exitCode: result.exitCode) } + private func providerRequestWithTool( + _ request: BuildProviderRequest, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity + ) async throws -> BuildProviderRequest { + let tool = try await providerTool(entry, workspaceURL: workspaceURL, owner: .run(run)) + var params = request.params + if let tool, case .object(var object) = params { + object["tool"] = .object(["executable": .string(tool.path)]) + params = .object(object) + } + return BuildProviderRequest(method: request.method, params: params, optionFingerprint: request.optionFingerprint, + executesProjectCode: request.executesProjectCode, mayUseNetwork: request.mayUseNetwork) + } + /// Plans a model or dependency load. A provider request also carries the /// load reason and the build tool the host resolved, so the provider /// neither repeats the lookup nor answers a reload from a stale snapshot. diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 54d22c8ba6..5dbc27aa99 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -491,7 +491,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { /// not running it, so an entry is added only with its host support. public static let hostCapabilities: [String: [Int]] = [ "backend": [1], - "buildProviders": [1, 2], + "buildProviders": [1, 2, 3], "buildSystems": [1, 2], "executableResolution": [1], "executionProviders": [1], "languageServers": [1, 2], diff --git a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift index c5475b360a..5e82672e36 100644 --- a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift @@ -1393,12 +1393,14 @@ struct BuildSystemServiceTests { } /// Configuration may change while the final decode is off the main actor. - @Test - func providerTaskOptionsCancelDuringDiagnosticDecode() async throws { + @Test(arguments: [false, true]) + func providerTaskOptionsCancelDuringDiagnosticDecode(tests: Bool) async throws { let planner = FakeBuildPlanner() planner.fingerprint = "o" planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) - planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + planner.providerRequest = Self.providerRequest(method: tests ? "lithe/build/test" : "lithe/build/execute") + let stale = try Self.diagnostic("stale provider diagnostic") + planner.diagnostics = [stale] let gate = GatedBuildDecode() planner.diagnosticsGate = gate let providers = FakeBuildProviders() @@ -1406,23 +1408,25 @@ struct BuildSystemServiceTests { defer { gate.releaseDecode(); harness.service.stop() } harness.service.reset(workspaceURL: Self.workspace) try #require(await harness.waitForEntries { !$0.isEmpty }) - harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") + if tests { harness.service.runTests(Self.instanceID, request: BuildTestRequest(), title: "Tests") } + else { harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") } try #require(await gate.entered.waitUntilOpen()) harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) #expect(harness.service.runState == .cancelled) gate.releaseDecode() try #require(await gate.finished.waitUntilOpen()) + #expect(await harness.waitsForStaleDiagnostics([stale]) == false) #expect(harness.service.diagnostics.isEmpty) } /// A backend may drain after cancellation; no output or result may reach /// the replacement workspace or the new effective configuration. - @Test(arguments: ["stop", "workspace", "options", "disable"]) - func providerTaskLateOutputCannotCrossItsOwner(change: String) async throws { + @Test(arguments: ["stop", "workspace", "options", "disable"], [false, true]) + func providerTaskLateOutputCannotCrossItsOwner(change: String, tests: Bool) async throws { let planner = FakeBuildPlanner() planner.fingerprint = "o" planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) - planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + planner.providerRequest = Self.providerRequest(method: tests ? "lithe/build/test" : "lithe/build/execute") let providers = FakeBuildProviders() let release = BuildTestGate() providers.replyGate = release @@ -1430,7 +1434,8 @@ struct BuildSystemServiceTests { defer { release.open(); harness.service.stop() } harness.service.reset(workspaceURL: Self.workspace) try #require(await harness.waitForEntries { !$0.isEmpty }) - harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") + if tests { harness.service.runTests(Self.instanceID, request: BuildTestRequest(), title: "Tests") } + else { harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") } try #require(await providers.requestStarted.waitUntilOpen()) providers.executionProgress?(BuildProviderProgress(output: "early")) #expect(harness.service.output.contains("early")) @@ -1448,6 +1453,179 @@ struct BuildSystemServiceTests { #expect(harness.service.runState != .succeeded) } + @Test(arguments: [Int32(0), Int32(3)]) + func providerTestsDecodeReportsWithoutStartingAHostCommand(exitCode: Int32) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [ + try Self.instance(modelSource: "static", model: Self.model(name: "loaded")) + ]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/test") + planner.testPlanDeclaresResults = true + let expected = try Self.testResults("assertion failed") + planner.decodedTestResults = expected + let providers = FakeBuildProviders() + providers.executionResult = BuildProviderExecutionResult(exitCode: exitCode, output: "test output") + let runner = FakeBuildRunner() + let harness = Harness(planner: planner, runner: runner, providers: providers) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.runTests(Self.instanceID, request: BuildTestRequest(projects: ["app"]), title: "Tests") + try #require(await harness.waitForRunState { $0 != .running }) + #expect(providers.sent.map(\.request.method) == ["lithe/build/test"]) + #expect(runner.requests.isEmpty) + #expect(harness.service.testResults == expected) + #expect(planner.diagnosticOutputs == ["test output"]) + if exitCode == 0 { #expect(harness.service.runState == .succeeded) } + else { if case .failed = harness.service.runState {} else { Issue.record("Expected failed test exit") } } + } + + /// The provider operation remains cancellable while its reports decode. + @Test + func providerTestOptionsCancelDuringReportDecode() async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [ + try Self.instance(modelSource: "static", model: Self.model(name: "loaded")) + ]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/test") + planner.testPlanDeclaresResults = true + let stale = try Self.testResults("stale") + planner.decodedTestResults = stale + let gate = GatedBuildDecode() + planner.testResultsGate = gate + let harness = Harness(planner: planner, providers: FakeBuildProviders()) + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.runTests(Self.instanceID, request: BuildTestRequest(), title: "Tests") + try #require(await gate.entered.waitUntilOpen()) + harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + #expect(harness.service.runState == .cancelled) + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + #expect(await harness.waitsForStaleTestResults(stale) == false) + #expect(harness.service.testResults == nil) + } + + @Test(arguments: ["source", "settings", "options"]) + func discoveredTestsRunExactSelectorsAndSourceEditsRevokeThem(change: String) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/discoverTests") + let discovered = try decode(BuildTestDiscovery.self, #"{"tests":[{"id":"exact","suite":"Suite","case":"case with spaces","project":{"name":"app","path":"app","tags":[],"properties":{}}}]}"#) + planner.discovery = discovered + let providers = FakeBuildProviders() + let runner = FakeBuildRunner() + var providerSettings: [String: ToolingJSONValue] = [:] + let harness = Harness(planner: planner, runner: runner, sources: [fixtureSource(testDiscovery: true)], providers: providers, + settings: { _ in providerSettings }) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.discoverTests(Self.instanceID) + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(harness.service.testDiscovery(Self.instanceID) == .loaded(discovered)) + #expect(runner.requests.isEmpty) + planner.providerRequest = Self.providerRequest(method: "lithe/build/test") + try #require(harness.service.runDiscoveredTest(Self.instanceID, testID: "exact", title: "Test") != nil) + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(planner.testRequests == [discovered.tests[0].request]) + #expect(providers.sent.map(\.request.method) == ["lithe/build/discoverTests", "lithe/build/test"]) + // A source edit, not just a build-file edit, revokes the cached selector. + if change == "source" { + harness.service.workspaceFilesChanged([Self.workspace.appendingPathComponent("src/Test.java")]) + } else if change == "settings" { providerSettings = ["providerHeapMB": .integer(2048)] } + else { + harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + harness.service.setOption(Self.instanceID, optionID: "release", value: nil) + } + #expect(harness.service.testDiscovery(Self.instanceID) == nil) + #expect(harness.service.runDiscoveredTest(Self.instanceID, testID: "exact", title: "Test") == nil) + } + + @Test + func queuedDiscoveryCannotPublishAfterItWasReplaced() async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/discoverTests") + planner.discovery = try decode(BuildTestDiscovery.self, #"{"tests":[]}"#) + let providers = FakeBuildProviders() + let harness = Harness(planner: planner, sources: [fixtureSource(testDiscovery: true)], providers: providers) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.discoverTests(Self.instanceID) + // Both actions run on the main actor before the queued task can start. + harness.service.stopRun() + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(providers.sent.map(\.request.method) == ["lithe/build/execute"]) + #expect(harness.service.testDiscovery(Self.instanceID) == nil) + } + + @Test(arguments: [false, true]) + func discoveryFailureIsVisibleAndNeverStartsAHostCommand(hasProvider: Bool) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/discoverTests") + let providers = FakeBuildProviders() + providers.failure = BuildSystemServiceTestsError(message: "discovery failed") + let runner = FakeBuildRunner() + let harness = Harness(planner: planner, runner: runner, sources: [fixtureSource(testDiscovery: true)], + providers: hasProvider ? providers : nil) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.discoverTests(Self.instanceID) + try #require(await harness.waitForRunState { if case .failed = $0 { return true }; return false }) + guard case .failed(let message) = harness.service.testDiscovery(Self.instanceID) else { + Issue.record("Discovery failure must be visible and retryable"); return + } + if hasProvider { #expect(message.contains("discovery failed")) } + #expect(runner.requests.isEmpty) + } + + @Test(arguments: ["stop", "workspace", "options", "source", "disable"]) + func discoveryDecodeCannotPublishAfterItsOwnerChanges(change: String) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/discoverTests") + planner.discovery = try decode(BuildTestDiscovery.self, #"{"tests":[]}"#) + let gate = GatedBuildDecode() + planner.discoveryGate = gate + let harness = Harness(planner: planner, sources: [fixtureSource(testDiscovery: true)], providers: FakeBuildProviders()) + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.discoverTests(Self.instanceID) + try #require(await gate.entered.waitUntilOpen()) + switch change { + case "workspace": harness.service.reset(workspaceURL: URL(fileURLWithPath: "/workspace/next")) + case "options": harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + case "source": harness.service.workspaceFilesChanged([Self.workspace.appendingPathComponent("src/Test.java")]) + case "disable": harness.sources = []; harness.service.pluginsChanged() + default: harness.service.stopRun() + } + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + // Observe publication rather than assuming the worker's return already + // passed through the main actor. The bounded negative assertion covers it. + let published = BuildTestGate() + let observation = harness.service.objectWillChange.sink { + if case .loaded = harness.service.testDiscovery(Self.instanceID) { published.open() } + } + defer { observation.cancel() } + #expect(await published.waitUntilOpen(timeout: .milliseconds(500)) == false) + #expect(harness.service.testDiscovery(Self.instanceID) == nil) + } + // MARK: Provider @Test @@ -1817,14 +1995,14 @@ struct BuildSystemServiceTests { } } -private func fixtureSource(pluginID: String = "dev.example") -> BuildSystemPluginSource { +private func fixtureSource(pluginID: String = "dev.example", testDiscovery: Bool = false) -> BuildSystemPluginSource { BuildSystemPluginSource( pluginID: pluginID, manifest: Data("{}".utf8), packageURL: URL(fileURLWithPath: "/plugins/\(pluginID)"), buildSystems: [DeclarativeBuildSystem( id: "fixture", displayName: "Fixture", modelSource: .command, tasks: [], - testScopes: [], dependencyScopes: [] + testScopes: [], dependencyScopes: [], testDiscovery: testDiscovery )] ) } @@ -2027,6 +2205,9 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { var detectGate: GatedBuildDecode? var decodedTestResults: BuildTestResults? var testPlanDeclaresResults = false + var discovery: BuildTestDiscovery? + var discoveryGate: GatedBuildDecode? + var testRequests: [BuildTestRequest] = [] var optionsResolution: BuildOptionsResolution? var loadedOptionsResolution: BuildOptionsResolution? var dynamicOptions = false @@ -2107,6 +2288,15 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { get { access { $0.decodedTestResults } } set { access { $0.decodedTestResults = newValue } } } + var discovery: BuildTestDiscovery? { + get { access { $0.discovery } } + set { access { $0.discovery = newValue } } + } + var discoveryGate: GatedBuildDecode? { + get { access { $0.discoveryGate } } + set { access { $0.discoveryGate = newValue } } + } + var testRequests: [BuildTestRequest] { access { $0.testRequests } } var testPlanDeclaresResults: Bool { get { access { $0.testPlanDeclaresResults } } set { access { $0.testPlanDeclaresResults = newValue } } @@ -2228,13 +2418,29 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { func testPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTestRequest) throws -> BuildTestPlan { + access { $0.testRequests.append(request) } let declaresResults = access { $0.testPlanDeclaresResults } return BuildTestPlan( - process: plan(["test"], output: nil), + execution: providerRequest.map(BuildOutputPlan.provider) ?? .process(plan(["test"], output: nil)), results: declaresResults ? .reports(decoder: "junitXml", patterns: []) : nil ) } + func testDiscoveryPlan(_ target: BuildSystemTarget, context: BuildPlanContext) throws -> BuildProviderRequest { + guard let providerRequest else { throw CancellationError() } + return providerRequest + } + + func decodeTestDiscovery(_ target: BuildSystemTarget, context: BuildPlanContext, result: ToolingJSONValue) throws -> BuildTestDiscovery { + if let gate = access({ let gate = $0.discoveryGate; $0.discoveryGate = nil; return gate }) { + gate.entered.open() + guard gate.waitForRelease() else { throw GatedDecodeError.releaseTimedOut } + gate.finished.open() + } + guard let discovery else { throw CancellationError() } + return discovery + } + func dependencyPlan( _ target: BuildSystemTarget, context: BuildPlanContext, diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index e9235864a5..8d3196c9b4 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -11,21 +11,21 @@ import Testing struct MacBuildProviderSessionsTests { /// Native execution and model requests share the same process; no page /// or plugin-function negotiation participates. - @Test - func nativeExecutionReusesTheWorkspaceProviderAndDeliversProgress() async throws { + @Test(arguments: ["lithe/build/execute", "lithe/build/test"]) + func nativeExecutionReusesTheWorkspaceProviderAndDeliversProgress(method: String) async throws { let fixture = try ProviderFixture() defer { fixture.remove() } let packageBefore = try fixture.packageSnapshot() _ = try await fixture.load() let received = BuildProgressRecorder() let result = try await fixture.sessions.execute(BuildProviderRequest( - method: "lithe/build/execute", params: .object([:]), optionFingerprint: "", + method: method, params: .object([:]), optionFingerprint: "", executesProjectCode: true, mayUseNetwork: true ), on: fixture.target, timeout: .seconds(5)) { received.values.append($0) } #expect(result == BuildProviderExecutionResult(exitCode: 0, output: "built")) #expect(received.values == [BuildProviderProgress(percentage: 50, output: "building")]) #expect(fixture.core.startCalls.count == 1) - #expect(fixture.core.buildRequests == ["lithe/build/model", "lithe/build/execute"]) + #expect(fixture.core.buildRequests == ["lithe/build/model", method]) let otherRoot = fixture.root.appendingPathComponent("other-workspace") try FileManager.default.createDirectory(at: otherRoot, withIntermediateDirectories: true) let other = BuildProviderTarget( @@ -33,7 +33,7 @@ struct MacBuildProviderSessionsTests { buildSystemID: "gradle", workspaceURL: otherRoot ) _ = try await fixture.sessions.execute(BuildProviderRequest( - method: "lithe/build/execute", params: .object([:]), optionFingerprint: "", + method: method, params: .object([:]), optionFingerprint: "", executesProjectCode: true, mayUseNetwork: true ), on: other, timeout: .seconds(5)) { _ in } #expect(fixture.core.startCalls.count == 2) @@ -672,13 +672,13 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked _buildRequests.append(method) _buildParams.append(params) let operationID = "\(sessionID)-\(_buildRequests.count)" - if method == "lithe/build/execute" { + if ["lithe/build/execute", "lithe/build/test"].contains(method) { events[sessionID, default: []].append(LanguageServerRuntimeEvent( type: "requestProgress", operationID: operationID, result: .object(["percentage": .integer(50), "output": .string("building")]) )) } - let value: ToolingJSONValue = method == "lithe/build/execute" + let value: ToolingJSONValue = ["lithe/build/execute", "lithe/build/test"].contains(method) ? .object(["exitCode": .integer(0), "output": .string("built")]) : .object(["session": .string(sessionID)]) events[sessionID, default: []].append(LanguageServerRuntimeEvent( diff --git a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift index a16338dc1e..40a58a8fb8 100644 --- a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift +++ b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift @@ -120,6 +120,51 @@ struct RustBuildSystemOperationsTests { #expect(request.mayUseNetwork) } + @Test + func providerTestPlanKeepsTypedFiltersAndReportPatterns() throws { + let target = try Self.target("build-provider-tests", "gradle") + let plan = try operations.testPlan( + target, context: BuildPlanContext(root: ".", options: ["offline": .bool(true)]), + request: BuildTestRequest(project: BuildSelection(name: "app", path: "app"), + suite: "Suite", testCase: "case with spaces", userArguments: ["--rerun-tasks"]) + ) + guard case .provider(let request) = plan.execution, case .object(let params) = request.params else { + Issue.record("Expected a provider test plan"); return + } + #expect(request.method == "lithe/build/test") + #expect(params["suite"] == .string("Suite")) + #expect(params["case"] == .string("case with spaces")) + #expect(params["userArguments"] == .array([.string("--rerun-tasks")])) + #expect(plan.results == .reports(decoder: "junitXml", patterns: ["app/build/test-results/**/*.xml"])) + } + + @Test + func discoveredSelectorsRoundTripThroughTheRealCore() throws { + let descriptor = try Self.descriptor("build-provider-tests") + #expect(descriptor.buildSystems.first?.testDiscovery == true) + #expect(try Self.descriptor("build-static-file").buildSystems.first?.testDiscovery == false) + let target = try Self.target("build-provider-tests", "gradle") + let context = BuildPlanContext(root: ".", options: ["offline": .bool(true)]) + let plan = try operations.testDiscoveryPlan(target, context: context) + #expect(plan.method == "lithe/build/discoverTests") + let fixture = try JSONDecoder().decode(ToolingJSONValue.self, from: Data(contentsOf: + DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-test-discovery.json"))) + guard case .object(let object) = fixture, let result = object["result"] else { + Issue.record("Missing discovery result"); return + } + let discovery = try operations.decodeTestDiscovery(target, context: context, result: result) + #expect(discovery.tests.count == 2) + #expect(Set(discovery.tests.map(\.id)).count == 2) + for test in discovery.tests { + let execution = try operations.testPlan(target, context: context, request: test.request) + guard case .provider(let request) = execution.execution, case .object(let params) = request.params else { + Issue.record("Discovery selector must remain a provider test"); return + } + #expect(params["suite"] == .string(test.suite)) + #expect(params["case"] == .string(test.testCase)) + } + } + @Test func taskAndTestPlansCarryOptionsAndSelection() throws { let target = try Self.target("build-static-file", "cargo") @@ -145,7 +190,10 @@ struct RustBuildSystemOperationsTests { projects: ["crates/demo"] ) ) - #expect(tests.process.arguments == ["test", "--release", "--test", "integration"]) + guard case .process(let process) = tests.execution else { + Issue.record("Expected a command test plan"); return + } + #expect(process.arguments == ["test", "--release", "--test", "integration"]) #expect(throws: DeclarativePluginRejection.self) { try operations.taskPlan(target, context: context, request: BuildTaskRequest(task: "missing")) diff --git a/rust/lithe-core/src/build/commands.rs b/rust/lithe-core/src/build/commands.rs index e8d7181236..6718d1ee4c 100644 --- a/rust/lithe-core/src/build/commands.rs +++ b/rust/lithe-core/src/build/commands.rs @@ -16,6 +16,7 @@ use super::outputs::{ DecodeTestResultsRequest, SyncFingerprintRequest, }; use super::plans::{self, OutputPlanRequest, TaskPlanRequest, TestPlanRequest}; +use super::test_discovery::{self, DecodeTestDiscoveryRequest}; use crate::plugins::declarative::{ decode_payload, outcome, validate_manifest_value, DeclarativePluginManifest, PluginRejection, Validation, @@ -170,6 +171,30 @@ pub(crate) fn build_test_plan_command(payload: Value) -> Result Result { + with_build_system( + payload, + "build.testDiscoveryPlan", + "plan", + |build_system, request: &OutputPlanRequest| { + plans::test_discovery_plan(build_system, request) + }, + ) +} + +/// `build.decodeTestDiscovery`: validated, deterministic selectors for native UI. +pub(crate) fn build_decode_test_discovery_command(payload: Value) -> Result { + with_build_system( + payload, + "build.decodeTestDiscovery", + "discovery", + |build_system, request: &DecodeTestDiscoveryRequest| { + test_discovery::decode(build_system, request) + }, + ) +} + /// `build.dependencyPlan`: the command that prints a project's dependencies. pub(crate) fn build_dependency_plan_command(payload: Value) -> Result { with_build_system( diff --git a/rust/lithe-core/src/build/manifest.rs b/rust/lithe-core/src/build/manifest.rs index b2e1359ff2..6d2a29e035 100644 --- a/rust/lithe-core/src/build/manifest.rs +++ b/rust/lithe-core/src/build/manifest.rs @@ -684,6 +684,12 @@ pub(crate) struct TestDeclaration { pub(crate) target_tags: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) filters: Vec, + /// Omitted for argv templates; provider sends a typed test request. + #[serde(default, skip_serializing_if = "TaskSource::is_command")] + pub(crate) source: TaskSource, + /// The provider can enumerate exact selectors for the native test tree. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) discovery: bool, pub(crate) arguments: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) results: Option, @@ -1030,7 +1036,14 @@ fn program(build_system: &BuildSystemDeclaration) -> Value { "options": options, "tasks": tasks, "run": build_system.run, - "test": build_system.test.as_ref().map(|test| json!({ "arguments": test.arguments })), + "test": build_system.test.as_ref().map(|test| { + let mut review = json!({ "arguments": test.arguments }); + if test.source == TaskSource::Provider { + review["source"] = json!("provider"); + } + if test.discovery { review["discovery"] = json!(true); } + review + }), "model": model, "dependencies": dependencies, }); @@ -1297,8 +1310,13 @@ impl BuildSystemDeclaration { } fn validate_provider(&self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { + let provider_tests = self + .test + .as_ref() + .is_some_and(|test| test.source == TaskSource::Provider); let uses = matches!(self.model, Some(ModelSource::Provider)) || matches!(self.dependencies, Some(DependenciesSource::Provider { .. })) + || provider_tests || self .tasks .iter() @@ -1313,13 +1331,17 @@ impl BuildSystemDeclaration { if !uses { return Err(invalid( field, - "No model, dependencies or task source uses the provider.", + "No model, dependencies, task or test source uses the provider.", )); } - if self - .tasks - .iter() - .any(|task| task.source == TaskSource::Provider) + if provider_tests && context.build_providers_version < 3 { + return Err(invalid(&field, "Provider tests require buildProviders 3.")); + } + if provider_tests + || self + .tasks + .iter() + .any(|task| task.source == TaskSource::Provider) { if context.build_providers_version < 2 { return Err(invalid(&field, "Provider tasks require buildProviders 2.")); @@ -1327,7 +1349,7 @@ impl BuildSystemDeclaration { if !matches!(provider, ProviderDeclaration::Process(_)) { return Err(invalid( &field, - "Provider tasks require a plugin-owned provider process.", + "Provider execution requires a plugin-owned provider process.", )); } } @@ -1866,21 +1888,42 @@ impl BuildSystemDeclaration { check_target_tags(&mut test.target_tags, &test.scopes, &field)?; test.filters.sort(); test.filters.dedup(); - check_argument_count(&test.arguments, &format!("{field}.arguments"))?; - template::validate_arguments( - &test.arguments, - &Allowed { - root: true, - project: availability(&test.scopes, Scope::Project), - target: availability(&test.scopes, Scope::Target), - test: true, - user_arguments: true, - conditions: true, - options: Some(options), - ..Allowed::none() - }, - &format!("{field}.arguments"), - )?; + if test.discovery + && (test.source != TaskSource::Provider + || !test.filters.contains(&TestFilter::Suite) + || !test.filters.contains(&TestFilter::Case)) + { + return Err(invalid( + &field, + "Test discovery requires a provider and suite/case filters.", + )); + } + if test.source == TaskSource::Provider { + if !test.arguments.is_empty() + || matches!(test.results, Some(TestResultsSource::HostIntegration)) + { + return Err(invalid( + &field, + "Provider tests require empty arguments and plugin-owned reports.", + )); + } + } else { + check_argument_count(&test.arguments, &format!("{field}.arguments"))?; + template::validate_arguments( + &test.arguments, + &Allowed { + root: true, + project: availability(&test.scopes, Scope::Project), + target: availability(&test.scopes, Scope::Target), + test: true, + user_arguments: true, + conditions: true, + options: Some(options), + ..Allowed::none() + }, + &format!("{field}.arguments"), + )?; + } match &mut test.results { Some(TestResultsSource::JunitXml { reports }) | Some(TestResultsSource::LitheTestResults { reports }) => { diff --git a/rust/lithe-core/src/build/mod.rs b/rust/lithe-core/src/build/mod.rs index bb5f284928..fcc9c1fdc5 100644 --- a/rust/lithe-core/src/build/mod.rs +++ b/rust/lithe-core/src/build/mod.rs @@ -28,6 +28,7 @@ mod outputs; mod plans; mod select; mod template; +mod test_discovery; pub(crate) use commands::*; pub(crate) use invocation::host_runtime_version_arguments; diff --git a/rust/lithe-core/src/build/plans.rs b/rust/lithe-core/src/build/plans.rs index b22a3f439c..1da22b6916 100644 --- a/rust/lithe-core/src/build/plans.rs +++ b/rust/lithe-core/src/build/plans.rs @@ -397,6 +397,11 @@ fn check_user_arguments(arguments: &[String], declared: &[BuildArgument]) -> Val if !template::uses(declared, &template::Variable::UserArguments) { return Err(invalid_selection("This command takes no extra arguments.")); } + check_argument_limits(arguments) +} + +/// Provider calls accept raw argument values without a CLI placeholder. +fn check_argument_limits(arguments: &[String]) -> Validation<()> { if arguments.len() > MAX_USER_ARGUMENTS || arguments .iter() @@ -464,16 +469,7 @@ pub(crate) fn task_plan( )) })?; if declaration.source == TaskSource::Provider { - if request.user_arguments.len() > MAX_USER_ARGUMENTS - || request - .user_arguments - .iter() - .any(|argument| argument.contains('\0') || argument.len() > MAX_USER_ARGUMENT) - { - return Err(invalid_selection( - "The extra arguments are too long or contain NUL.", - )); - } + check_argument_limits(&request.user_arguments)?; } else { check_user_arguments(&request.user_arguments, &declaration.arguments)?; } @@ -579,6 +575,32 @@ fn check_test_text(value: &Option, what: &str) -> Validation<()> { Ok(()) } +/// Discovers the instance's tests; individual selectors are validated on decode. +pub(crate) fn test_discovery_plan( + build_system: &BuildSystemDeclaration, + request: &OutputPlanRequest, +) -> Validation { + if !build_system + .test + .as_ref() + .is_some_and(|test| test.discovery) + { + return Err(unavailable(build_system, "test discovery")); + } + if request.project.is_some() || request.output_file.is_some() { + return Err(invalid_selection( + "Test discovery addresses the whole instance.", + )); + } + let context = PlanContext::new(build_system, &request.base)?; + Ok(context.provider_plan( + declared_provider(build_system)?, + "lithe/build/discoverTests", + "litheTestDiscovery", + provider_hints(request)?, + )) +} + /// `build.testPlan`: the command and where its results will be. /// Whether a selected target carries one of the declared tags; no tags /// accept every target. @@ -595,7 +617,11 @@ pub(crate) fn test_plan( .as_ref() .ok_or_else(|| unavailable(build_system, "tests"))?; let context = PlanContext::new(build_system, &request.base)?; - check_user_arguments(&request.user_arguments, &test.arguments)?; + if test.source == TaskSource::Provider { + check_argument_limits(&request.user_arguments)?; + } else { + check_user_arguments(&request.user_arguments, &test.arguments)?; + } check_test_text(&request.suite, "test suite")?; check_test_text(&request.case, "test case")?; for (requested, filter) in [ @@ -658,7 +684,10 @@ pub(crate) fn test_plan( "this test case without its suite", ), ] { - if requested && !template::applies_any(&test.arguments, &values, variables) { + if test.source != TaskSource::Provider + && requested + && !template::applies_any(&test.arguments, &values, variables) + { return Err(invalid_selection(format!( "{} cannot select {what}.", build_system.display_name @@ -698,6 +727,38 @@ pub(crate) fn test_plan( }; let mut extra = Map::new(); extra.insert("results".to_string(), results); + if test.source == TaskSource::Provider { + let mut params = Map::new(); + params.insert("userArguments".to_string(), json!(request.user_arguments)); + // Filters stay separate values; the owning backend interprets them + // using its upstream test engine, never a host-generated CLI string. + for (name, value) in [("suite", &request.suite), ("case", &request.case)] { + if let Some(value) = value { + params.insert(name.to_string(), json!(value)); + } + } + for (name, selected) in [("project", &project), ("target", &target)] { + if let Some(selected) = selected { + params.insert( + name.to_string(), + json!({ + "name": selected.name, "path": selected.path, + "tags": selected.tags, "properties": selected.properties + }), + ); + } + } + let mut plan = context.provider_plan( + declared_provider(build_system)?, + "lithe/build/test", + "execution", + params, + ); + plan.as_object_mut() + .expect("provider plan is an object") + .extend(extra); + return Ok(plan); + } context.plan(&test.arguments, &values, BuildExecution::Task, extra) } diff --git a/rust/lithe-core/src/build/test_discovery.rs b/rust/lithe-core/src/build/test_discovery.rs new file mode 100644 index 0000000000..d3b9d19f28 --- /dev/null +++ b/rust/lithe-core/src/build/test_discovery.rs @@ -0,0 +1,91 @@ +//! Normalizes provider-owned test selectors without inferring test semantics. +//! Every item must round-trip through the ordinary typed test plan, so discovery +//! cannot expose an item the declaration is unable to run exactly. + +use super::decode::{invalid_output, MAX_OUTPUT_BYTES}; +use super::manifest::BuildSystemDeclaration; +use super::plans::{self, PlanBase, SelectedEntity, TestPlanRequest}; +use crate::plugins::declarative::Validation; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +const MAX_TESTS: usize = 10_000; + +/// The plan's configuration and a provider's canonical discovery result. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DecodeTestDiscoveryRequest { + #[serde(flatten)] + pub(crate) base: PlanBase, + pub(crate) result: Value, +} + +/// Selectors have no host-generated command or cached upstream project model. +#[derive(Deserialize)] +struct Selector { + suite: String, + case: String, + project: Option, + target: Option, +} + +/// Rejects the entire malformed result rather than publishing a partial list +/// whose apparent completeness could hide tests or widen a selected run. +pub(crate) fn decode( + build_system: &BuildSystemDeclaration, + request: &DecodeTestDiscoveryRequest, +) -> Validation { + if !build_system + .test + .as_ref() + .is_some_and(|test| test.discovery) + { + return Err(plans::unavailable(build_system, "test discovery")); + } + // Validate host context even for a valid empty discovery result. + plans::PlanContext::new(build_system, &request.base)?; + let result = &request.result; + if serde_json::to_vec(result).map_or(usize::MAX, |bytes| bytes.len()) > MAX_OUTPUT_BYTES + || result["format"] != "litheTestDiscovery" + || result["version"] != 1 + { + return Err(invalid_output("Invalid test discovery document.")); + } + let tests = result["tests"] + .as_array() + .filter(|tests| tests.len() <= MAX_TESTS) + .ok_or_else(|| invalid_output("Test discovery requires at most 10000 selectors."))?; + let mut normalized = BTreeMap::new(); + for test in tests { + let selector: Selector = serde_json::from_value(test.clone()) + .map_err(|_| invalid_output("Invalid test selector."))?; + let plan = plans::test_plan( + build_system, + &TestPlanRequest { + base: request.base.clone(), + project: selector.project, + target: selector.target, + suite: Some(selector.suite), + case: Some(selector.case), + user_arguments: vec![], + projects: vec![], + }, + ) + .map_err(|_| invalid_output("A discovered selector cannot be run by this declaration."))?; + let params = &plan["provider"]["params"]; + let mut selected = json!({"suite":params["suite"],"case":params["case"]}); + for field in ["project", "target"] { + if let Some(value) = params.get(field) { + selected[field] = value.clone(); + } + } + let key = serde_json::to_string(&selected).expect("selector is JSON"); + // Identity includes project and target, so equal suite/case names in + // sibling modules remain independently runnable. Order is deterministic. + selected["id"] = json!(format!("test:{:x}", Sha256::digest(key.as_bytes()))); + normalized.insert(key, selected); + } + Ok(json!({"tests":normalized.into_values().collect::>()})) +} diff --git a/rust/lithe-core/src/lsp/interface/build_provider.rs b/rust/lithe-core/src/lsp/interface/build_provider.rs index be4eb4af5b..459ffea419 100644 --- a/rust/lithe-core/src/lsp/interface/build_provider.rs +++ b/rust/lithe-core/src/lsp/interface/build_provider.rs @@ -15,7 +15,7 @@ use serde_json::{json, Value}; const EXPERIMENTAL_KEY: &str = "litheBuild"; /// Build-provider protocol versions this client speaks, in preference order. -const SUPPORTED_VERSIONS: [u64; 2] = [2, 1]; +const SUPPORTED_VERSIONS: [u64; 3] = [3, 2, 1]; /// Feature name recorded in the session's server capabilities once a /// provider answered a supported version. @@ -23,6 +23,12 @@ pub(crate) const BUILD_PROVIDER_FEATURE: &str = "litheBuild"; /// Version 2 uses the same workspace session and operation lifecycle. pub(crate) const EXECUTE_METHOD: &str = "lithe/build/execute"; +/// Version 3 adds tests while retaining the bounded execution lifecycle. +pub(crate) const TEST_METHOD: &str = "lithe/build/test"; +/// Version 3 returns exact selectors; decoding remains a separate Core command. +pub(crate) const DISCOVER_TESTS_METHOD: &str = "lithe/build/discoverTests"; +/// Feature required before writing a version 3 test request. +pub(crate) const TEST_EXECUTION_FEATURE: &str = "litheBuildTestExecution"; /// Progress never uses caller-chosen host operation IDs. pub(crate) const PROGRESS_METHOD: &str = "lithe/build/progress"; /// Separate feature so version 1 cannot accidentally execute version 2 tasks. @@ -48,7 +54,14 @@ pub(crate) fn negotiated(capabilities: &Value) -> bool { /// Whether `method` is a request of the negotiated protocol. pub(crate) fn is_build_method(method: &str) -> bool { - VERSION_1_METHODS.contains(&method) || method == EXECUTE_METHOD + VERSION_1_METHODS.contains(&method) + || is_execution_method(method) + || method == DISCOVER_TESTS_METHOD +} + +/// Whether the method shares bounded output, progress, and final result handling. +pub(crate) fn is_execution_method(method: &str) -> bool { + matches!(method, EXECUTE_METHOD | TEST_METHOD) } /// True only for the execution protocol, not merely any negotiated provider. @@ -56,7 +69,15 @@ pub(crate) fn execution_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - == Some(2) + .is_some_and(|version| matches!(version, 2 | 3)) +} + +/// Tests require an explicit version 3 agreement, independent of task support. +pub(crate) fn tests_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + == Some(3) } /// Normalizes bounded progress; the engine checks the pending request identity. @@ -128,7 +149,7 @@ mod tests { &json!({ "experimental": { "litheBuild": {} } }) )); assert!(!negotiated( - &json!({ "experimental": { "litheBuild": { "version": 3 } } }) + &json!({ "experimental": { "litheBuild": { "version": 4 } } }) )); assert!(!negotiated( &json!({ "experimental": { "litheBuild": { "version": "1" } } }) @@ -143,8 +164,20 @@ mod tests { assert!(is_build_method("lithe/build/model")); assert!(is_build_method("lithe/build/dependencies")); assert!(is_build_method("lithe/build/execute")); + assert!(is_build_method("lithe/build/test")); assert!(!is_build_method("workspace/executeCommand")); } + #[test] + fn test_protocol_does_not_remove_task_support() { + let capabilities = json!({"experimental":{"litheBuild":{"version":3}}}); + assert!(negotiated(&capabilities)); + assert!(execution_negotiated(&capabilities)); + assert!(tests_negotiated(&capabilities)); + assert!(!tests_negotiated( + &json!({"experimental":{"litheBuild":{"version":2}}}) + )); + } + #[test] fn execution_replies_and_progress_match_the_shared_contract() { let fixture: Value = serde_json::from_str(include_str!( diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index ec02107976..273f359e76 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -1753,6 +1753,9 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { if super::build_provider::execution_negotiated(capabilities) { insert_unique(&mut values, super::build_provider::EXECUTION_FEATURE); } + if super::build_provider::tests_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::TEST_EXECUTION_FEATURE); + } if plugin_function::negotiate(capabilities).is_some() { insert_unique(&mut values, plugin_function::FUNCTIONS_FEATURE); } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 23b32afd93..387157cfe4 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -2101,7 +2101,7 @@ impl RuntimeSession { ) .with_details(super::build_provider::BUILD_PROVIDER_FEATURE)); } - if request.method == super::build_provider::EXECUTE_METHOD + if super::build_provider::is_execution_method(&request.method) && !state .client .server_capabilities @@ -2114,8 +2114,23 @@ impl RuntimeSession { ) .with_details("plugin.build.unavailable")); } + if matches!( + request.method.as_str(), + super::build_provider::TEST_METHOD | super::build_provider::DISCOVER_TESTS_METHOD + ) && !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::TEST_EXECUTION_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate test execution.", + ) + .with_details("plugin.build.unavailable")); + } let mut params = request.params; - if request.method == super::build_provider::EXECUTE_METHOD { + if super::build_provider::is_execution_method(&request.method) { let object = params.as_object_mut().ok_or_else(|| { CoreError::new( ErrorCode::InvalidRequest, @@ -3150,7 +3165,7 @@ impl RuntimeSession { } Some(PendingKind::Feature) if pending_before.as_ref().is_some_and(|pending| { - pending.method == super::build_provider::EXECUTE_METHOD + super::build_provider::is_execution_method(&pending.method) }) => { if let Some(pending) = pending_before.as_ref() { @@ -5573,13 +5588,14 @@ fn push_build_progress_locked( let Some(pending) = state.pending.get(id) else { return; }; - if pending.method != super::build_provider::EXECUTE_METHOD { + if !super::build_provider::is_execution_method(&pending.method) { return; } let Some(operation_id) = pending.operation_id.clone() else { return; }; progress["operationID"] = json!(operation_id); + let method = pending.method.clone(); let sequence = take_sequence(state); enqueue_runtime_event( session, @@ -5591,7 +5607,7 @@ fn push_build_progress_locked( session_id: session.id.clone(), state: None, operation_id: Some(operation_id), - method: Some(super::build_provider::EXECUTE_METHOD.to_string()), + method: Some(method), uri: None, version: None, diagnostics: None, @@ -11227,188 +11243,220 @@ public class Main { .all(|message| message["method"] != "lithe/build/execute")); } - /// Ordered messages use a completed round trip as a barrier: old progress - /// and replies are drained before checking that cancellation stayed final. #[test] - fn provider_execution_progress_cancel_and_late_results_are_isolated() { - let mut harness = Harness::start(|_| {}); - harness - .server - .complete_initialize(json!({"experimental":{"litheBuild":{"version":2}}})); - harness.await_state(LspLifecycleState::Ready); + fn provider_test_execution_requires_version_three() { + for version in [1, 2] { + for method in ["lithe/build/test", "lithe/build/discoverTests"] { + let harness = plugin_backend(json!({"litheBuild":{"version":version}})); + let error = build_request(&harness, method, json!({}), None).unwrap_err(); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != method)); + } + } + } + + #[test] + fn provider_test_discovery_uses_the_existing_request_lifecycle() { + let mut harness = plugin_backend(json!({"litheBuild":{"version":3}})); let operation = build_request( &harness, - "lithe/build/execute", - json!({"operationID":"spoof"}), + "lithe/build/discoverTests", + json!({"root":"."}), None, ) .unwrap(); - let id = harness.server.await_request("lithe/build/execute").unwrap(); - assert_eq!(harness.written_request(&id)["params"]["operationID"], id); - harness - .server - .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", - "params":{"operationID":id,"output":"first","percentage":20}})); - let progress = harness - .await_event(|event| event.kind == "requestProgress") - .clone(); - assert_eq!(progress.operation_id.as_deref(), Some(operation.as_str())); - assert_eq!(progress.result.unwrap()["output"], "first"); - harness.session().cancel_operation(&operation).unwrap(); - let ended = harness - .await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }) - .clone(); - assert_eq!(ended.error.unwrap().code, "requestCancelled"); - harness - .server - .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", - "params":{"operationID":id,"output":"late"}})); - harness + let id = harness .server - .send(json!({"jsonrpc":"2.0","id":id,"result":{"exitCode":0,"output":"late"}})); - let barrier = build_request(&harness, "lithe/build/model", json!({}), None).unwrap(); - let barrier_id = harness.server.await_request("lithe/build/model").unwrap(); + .await_request("lithe/build/discoverTests") + .unwrap(); + let result = json!({"format":"litheTestDiscovery","version":1,"tests":[]}); harness .server - .send(json!({"jsonrpc":"2.0","id":barrier_id,"result":{}})); - harness.await_event(|event| { + .send(json!({"jsonrpc":"2.0","id":id,"result":result})); + let event = harness.await_event(|event| { event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(barrier.as_str()) + && event.operation_id.as_deref() == Some(operation.as_str()) }); - assert_eq!( + assert_eq!(event.result.as_ref().unwrap()["value"], result); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + } + + /// Ordered messages use a completed round trip as a barrier: old progress + /// and replies are drained before checking that cancellation stayed final. + #[test] + fn provider_execution_progress_cancel_and_late_results_are_isolated() { + for (version, method) in [(2, "lithe/build/execute"), (3, "lithe/build/test")] { + let mut harness = Harness::start(|_| {}); harness - .events - .iter() - .filter(|event| event.kind == "requestProgress") - .count(), - 1 - ); - assert_eq!( + .server + .complete_initialize(json!({"experimental":{"litheBuild":{"version":version}}})); + harness.await_state(LspLifecycleState::Ready); + let operation = + build_request(&harness, method, json!({"operationID":"spoof"}), None).unwrap(); + let id = harness.server.await_request(method).unwrap(); + assert_eq!(harness.written_request(&id)["params"]["operationID"], id); harness - .events - .iter() - .filter(|event| event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str())) - .count(), - 1 - ); + .server + .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", + "params":{"operationID":id,"output":"first","percentage":20}})); + let progress = harness + .await_event(|event| event.kind == "requestProgress") + .clone(); + assert_eq!(progress.operation_id.as_deref(), Some(operation.as_str())); + assert_eq!(progress.result.unwrap()["output"], "first"); + harness.session().cancel_operation(&operation).unwrap(); + let ended = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert_eq!(ended.error.unwrap().code, "requestCancelled"); + harness + .server + .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", + "params":{"operationID":id,"output":"late"}})); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":{"exitCode":0,"output":"late"}})); + let barrier = build_request(&harness, "lithe/build/model", json!({}), None).unwrap(); + let barrier_id = harness.server.await_request("lithe/build/model").unwrap(); + harness + .server + .send(json!({"jsonrpc":"2.0","id":barrier_id,"result":{}})); + harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(barrier.as_str()) + }); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "requestProgress") + .count(), + 1 + ); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str())) + .count(), + 1 + ); + } } /// Host operation IDs may be reused; only the current wire request ID /// may contribute progress to that new call. #[test] fn provider_execution_reused_host_ids_reject_old_wire_progress() { - let mut harness = plugin_backend(json!({"litheBuild":{"version":2}})); - let operation = "reused-build".to_string(); - let send = |harness: &Harness| { - harness - .session() - .build_request( - BuildProviderRequest { - session_id: harness.session_id.clone(), - operation_id: Some(operation.clone()), - method: "lithe/build/execute".to_string(), - params: json!({}), - timeout_milliseconds: None, - }, - operation.clone(), - ) - .unwrap() - }; - send(&harness); - let old = harness.server.await_request("lithe/build/execute").unwrap(); - harness.session().cancel_operation(&operation).unwrap(); - harness.await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }); - send(&harness); - let current = harness - .server - .await_request_at("lithe/build/execute", 1) - .unwrap(); - assert_ne!(old, current); - for (id, output) in [(old, "old"), (current, "current")] { - harness - .server - .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", + for (version, method) in [(2, "lithe/build/execute"), (3, "lithe/build/test")] { + let mut harness = plugin_backend(json!({"litheBuild":{"version":version}})); + let operation = "reused-build".to_string(); + let send = |harness: &Harness| { + harness + .session() + .build_request( + BuildProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation.clone()), + method: method.to_string(), + params: json!({}), + timeout_milliseconds: None, + }, + operation.clone(), + ) + .unwrap() + }; + send(&harness); + let old = harness.server.await_request(method).unwrap(); + harness.session().cancel_operation(&operation).unwrap(); + harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + send(&harness); + let current = harness.server.await_request_at(method, 1).unwrap(); + assert_ne!(old, current); + for (id, output) in [(old, "old"), (current, "current")] { + harness + .server + .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", "params":{"operationID":id,"output":output}})); + } + harness.await_event(|event| { + event.kind == "requestProgress" + && event + .result + .as_ref() + .is_some_and(|result| result["output"] == "current") + }); + assert!(harness + .events + .iter() + .filter(|event| event.kind == "requestProgress") + .all(|event| event.result.as_ref().unwrap()["output"] != "old")); + harness.session().cancel_operation(&operation).unwrap(); } - harness.await_event(|event| { - event.kind == "requestProgress" - && event - .result - .as_ref() - .is_some_and(|result| result["output"] == "current") - }); - assert!(harness - .events - .iter() - .filter(|event| event.kind == "requestProgress") - .all(|event| event.result.as_ref().unwrap()["output"] != "old")); - harness.session().cancel_operation(&operation).unwrap(); } #[test] fn provider_execution_validates_results_and_expires_without_sleeping() { - let mut harness = Harness::start(|_| {}); - harness - .server - .complete_initialize(json!({"experimental":{"litheBuild":{"version":2}}})); - harness.await_state(LspLifecycleState::Ready); - for (index, (reply, code)) in [ - (json!({"exitCode":7,"output":"failed build"}), None), - ( - json!({"exitCode":"bad"}), - Some("plugin.build.invalidResult"), - ), - ] - .into_iter() - .enumerate() - { - let operation = - build_request(&harness, "lithe/build/execute", json!({}), None).unwrap(); - let id = harness - .server - .await_request_at("lithe/build/execute", index) - .unwrap(); + for (version, method) in [(2, "lithe/build/execute"), (3, "lithe/build/test")] { + let mut harness = Harness::start(|_| {}); harness .server - .send(json!({"jsonrpc":"2.0","id":id,"result":reply})); - let ended = harness - .await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }) - .clone(); - assert_eq!(ended.error.as_ref().map(|error| error.code.as_str()), code); - if code.is_none() { - assert_eq!(ended.result.unwrap()["value"], reply); + .complete_initialize(json!({"experimental":{"litheBuild":{"version":version}}})); + harness.await_state(LspLifecycleState::Ready); + for (index, (reply, code)) in [ + (json!({"exitCode":7,"output":"failed build"}), None), + ( + json!({"exitCode":"bad"}), + Some("plugin.build.invalidResult"), + ), + ] + .into_iter() + .enumerate() + { + let operation = build_request(&harness, method, json!({}), None).unwrap(); + let id = harness.server.await_request_at(method, index).unwrap(); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":reply})); + let ended = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert_eq!(ended.error.as_ref().map(|error| error.code.as_str()), code); + if code.is_none() { + assert_eq!(ended.result.unwrap()["value"], reply); + } } - } - let operation = - build_request(&harness, "lithe/build/execute", json!({}), Some(1000)).unwrap(); - harness - .server - .await_request_at("lithe/build/execute", 2) - .unwrap(); - harness - .session() - .expire_deadlines_at(Instant::now() + Duration::from_secs(2)); - assert_eq!( + let operation = build_request(&harness, method, json!({}), Some(1000)).unwrap(); + harness.server.await_request_at(method, 2).unwrap(); harness - .await_event(|event| event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str())) - .error - .as_ref() - .unwrap() - .code, - "requestTimeout" - ); - assert!(harness.snapshot().pending_operation_ids.is_empty()); + .session() + .expire_deadlines_at(Instant::now() + Duration::from_secs(2)); + assert_eq!( + harness + .await_event(|event| event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str())) + .error + .as_ref() + .unwrap() + .code, + "requestTimeout" + ); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + } } /// A process that answered build-provider protocol version 1. @@ -11457,7 +11505,7 @@ public class Main { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"]["litheBuild"], - json!({ "versions": [2, 1] }) + json!({ "versions": [3, 2, 1] }) ); let error = build_request(&harness, "lithe/build/model", json!({}), None) @@ -11964,7 +12012,7 @@ public class Main { assert_eq!( initialize["params"]["capabilities"]["experimental"], json!({ - "litheBuild": { "versions": [2, 1] }, + "litheBuild": { "versions": [3, 2, 1] }, "litheFunctions": { "versions": [1, 2] }, "litheVcs": { "versions": [1] } }) diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index eab35527fe..a71947345b 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -66,7 +66,7 @@ pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; /// capability keeps the meaning its section had at API level 2. pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (BACKEND, &[1]), - (BUILD_PROVIDERS, &[1, 2]), + (BUILD_PROVIDERS, &[1, 2, 3]), (BUILD_SYSTEMS, &[1, 2]), (DEBUG_ADAPTERS, &[1]), (EXECUTION_PROVIDERS, &[1]), diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index fc9581fe14..7c21fd12e0 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -102,6 +102,10 @@ pub enum CoreCommand { BuildTaskPlan, /// Expands a build test run and its result location (`build.testPlan`). BuildTestPlan, + /// Plans provider-owned test discovery (`build.testDiscoveryPlan`). + BuildTestDiscoveryPlan, + /// Validates provider-owned test selectors (`build.decodeTestDiscovery`). + BuildDecodeTestDiscovery, /// Expands the command that prints a project's dependencies (`build.dependencyPlan`). BuildDependencyPlan, /// Normalizes a build model and derives its run entries (`build.decodeModel`). @@ -518,6 +522,8 @@ impl CoreCommand { "build.modelPlan" => Some(Self::BuildModelPlan), "build.taskPlan" => Some(Self::BuildTaskPlan), "build.testPlan" => Some(Self::BuildTestPlan), + "build.testDiscoveryPlan" => Some(Self::BuildTestDiscoveryPlan), + "build.decodeTestDiscovery" => Some(Self::BuildDecodeTestDiscovery), "build.dependencyPlan" => Some(Self::BuildDependencyPlan), "build.decodeModel" => Some(Self::BuildDecodeModel), "build.decodeTestResults" => Some(Self::BuildDecodeTestResults), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 2c10d2c9e5..7ab00de665 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -255,6 +255,16 @@ fn execute(request: &str) -> CoreResponse { CoreCommand::BuildTestPlan => { plugin_response(id, parsed.payload, crate::build::build_test_plan_command) } + CoreCommand::BuildTestDiscoveryPlan => plugin_response( + id, + parsed.payload, + crate::build::build_test_discovery_plan_command, + ), + CoreCommand::BuildDecodeTestDiscovery => plugin_response( + id, + parsed.payload, + crate::build::build_decode_test_discovery_command, + ), CoreCommand::BuildDependencyPlan => plugin_response( id, parsed.payload, diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index 7a5e79c210..1e410b7af7 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -5,9 +5,10 @@ use super::support::temporary_root; use crate::build::{ build_check_tool_version_command, build_decode_dependencies_command, build_decode_diagnostics_command, build_decode_model_command, - build_decode_test_results_command, build_dependency_plan_command, build_detect_command, - build_invocation_plan_command, build_model_plan_command, build_resolve_options_command, - build_sync_fingerprint_command, build_task_plan_command, build_test_plan_command, + build_decode_test_discovery_command, build_decode_test_results_command, + build_dependency_plan_command, build_detect_command, build_invocation_plan_command, + build_model_plan_command, build_resolve_options_command, build_sync_fingerprint_command, + build_task_plan_command, build_test_discovery_plan_command, build_test_plan_command, }; use crate::plugins::declarative::{language_server_launch_plan_command, validate_command}; use crate::plugins::marketplace::install_review_command; @@ -1850,3 +1851,166 @@ fn provider_execution_tasks_are_typed_and_version_gated() { true ); } + +/// Test selection remains typed and report paths reuse the existing decoder; +/// no test selector is interpolated into a host command. +#[test] +fn provider_tests_preserve_filters_and_report_ownership() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/build-provider-tests.plugin.json")).unwrap(); + let fixture: Value = + serde_json::from_str(contract_file!("fixtures/build-test-protocol.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + let plan = build_test_plan_command( + json!({"manifest": manifest, "buildSystemID":"gradle", "request":fixture["request"]}), + ) + .unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!(plan["plan"]["provider"], fixture["provider"]); + assert_eq!(plan["plan"]["results"], fixture["results"]); + assert!(plan["plan"].get("arguments").is_none()); + let review = install_review_command(json!({"manifest":manifest})).unwrap(); + let program = review["review"]["programs"] + .as_array() + .unwrap() + .iter() + .find(|program| program["kind"] == "buildSystem") + .unwrap(); + assert_eq!( + program["test"], + json!({"source":"provider","arguments":[],"discovery":true}) + ); + + // A case-only filter is a typed value, not a CLI expression needing a suite. + let case_only = build_test_plan_command(json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"root":".","case":"exact"}})) + .unwrap(); + assert_eq!(case_only["status"], "ok", "{case_only}"); + for request in [ + json!({"root":"app","project":{"name":"escape","path":"../escape"}}), + json!({"root":".","target":{"name":"orphan","path":"app"}}), + json!({"root":".","suite":"bad\u{0}filter"}), + json!({"root":".","userArguments":vec!["arg";65]}), + ] { + let rejected = build_test_plan_command( + json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), + ) + .unwrap(); + assert_eq!(rejected["status"], "rejected", "{rejected}"); + } + let mut unsupported = manifest.clone(); + unsupported["buildSystems"][0]["test"]["filters"] = json!([]); + assert_eq!( + build_test_plan_command(json!({"manifest":unsupported,"buildSystemID":"gradle", + "request":fixture["request"]})) + .unwrap()["status"], + "rejected" + ); +} + +/// Older hosts/processes must reject provider tests rather than silently run +/// every test or fall back to host-owned business logic. +#[test] +fn provider_tests_require_version_three_and_an_owned_process() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/build-provider-tests.plugin.json")).unwrap(); + for version in [1, 2] { + let mut old = manifest.clone(); + old["requires"]["buildProviders"] = json!(version); + assert_eq!(validate(old)["status"], "rejected"); + } + let refused = validate_command(json!({"manifest":manifest,"hostCapabilities":{ + "buildProviders":[1,2],"buildSystems":[1],"serverProcess":[1],"settings":[1]}})) + .unwrap(); + assert_eq!(refused["error"]["field"], "requires.buildProviders"); + for (field, value) in [ + ("arguments", json!(["test"])), + ("source", json!("command")), + ("results", json!({"decoder":"hostIntegration"})), + ] { + let mut invalid = manifest.clone(); + invalid["buildSystems"][0]["test"][field] = value; + assert_eq!(validate(invalid)["status"], "rejected"); + } + // A provider used only for tests is valid; removing it must be rejected. + let mut only_tests = manifest.clone(); + for field in ["model", "dependencies", "tasks"] { + only_tests["buildSystems"][0] + .as_object_mut() + .unwrap() + .remove(field); + } + assert_eq!(validate(only_tests.clone())["status"], "ok"); + only_tests["buildSystems"][0] + .as_object_mut() + .unwrap() + .remove("provider"); + assert_eq!(validate(only_tests)["status"], "rejected"); + let mut shared = manifest; + shared["buildSystems"][0]["provider"] = + json!({"languageServer":"java", "executesProjectCode":true,"mayUseNetwork":false}); + assert_eq!(validate(shared)["status"], "rejected"); +} + +/// Discovery produces only exactly runnable selectors, preserving module +/// identity and rejecting malformed lists instead of silently hiding tests. +#[test] +fn provider_test_discovery_is_bounded_deterministic_and_runnable() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/build-provider-tests.plugin.json")).unwrap(); + let fixture: Value = + serde_json::from_str(contract_file!("fixtures/build-test-discovery.json")).unwrap(); + let plan = + build_test_discovery_plan_command(json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"root":".","refresh":true}})) + .unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!( + plan["plan"]["provider"]["method"], + "lithe/build/discoverTests" + ); + assert_eq!(plan["plan"]["provider"]["params"]["refresh"], true); + let decode = |result: Value| { + build_decode_test_discovery_command(json!({"manifest":manifest, + "buildSystemID":"gradle","request":{"root":".","result":result}})) + .unwrap() + }; + let original = decode(fixture["result"].clone()); + assert_eq!(original["status"], "ok", "{original}"); + let tests = original["discovery"]["tests"].as_array().unwrap(); + assert_eq!(tests.len(), 2); + assert_ne!(tests[0]["id"], tests[1]["id"]); + let mut reordered = fixture["result"].clone(); + reordered["tests"].as_array_mut().unwrap().reverse(); + reordered["tests"] + .as_array_mut() + .unwrap() + .push(fixture["result"]["tests"][0].clone()); + assert_eq!(decode(reordered), original); + for test in tests { + let mut request = test.clone(); + request.as_object_mut().unwrap().remove("id"); + request["root"] = json!("."); + let runnable = build_test_plan_command( + json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), + ) + .unwrap(); + assert_eq!(runnable["status"], "ok", "{runnable}"); + } + for invalid in [ + json!({"format":"litheTestDiscovery","version":2,"tests":[]}), + json!({"format":"litheTestDiscovery","version":1,"tests":vec![json!({});10001]}), + json!({"format":"litheTestDiscovery","version":1,"tests":[{"suite":"a"}]}), + json!({"format":"litheTestDiscovery","version":1,"tests":[{"suite":"a","case":"b","project":{"name":"escape","path":"../escape"}}]}), + json!({"format":"litheTestDiscovery","version":1,"tests":[{"suite":"a","case":"bad\u{0}"}]}), + ] { + let rejected = decode(invalid); + assert_eq!( + rejected["error"]["code"], "plugin.build.invalidOutput", + "{rejected}" + ); + } + let mut missing_filter = manifest; + missing_filter["buildSystems"][0]["test"]["filters"] = json!(["suite"]); + assert_eq!(validate(missing_filter)["status"], "rejected"); +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 363c0e5162..cc0bc6ecf5 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -406,7 +406,7 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | | `buildSystems` | Declarative build detection, models, and command plans | `1` | -| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; version 2 adds provider task execution | `1`, `2` | +| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; version 2 adds task execution, version 3 test execution | `1`, `2`, `3` | | `vcsProviders` | `vcsProviders[]` | `1` | | `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters) | `1` | | `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | @@ -454,17 +454,13 @@ in `plugins.declarative.validate`, `plugins.marketplace.validateIndex`, and therefore offered on a host that cannot run them. - `hostCapabilities` has no effect on levels 1 and 2. -The macOS host runs levels 1 to 3 and sends its capability table, including -`buildSystems: [1]`, with all three commands. It does not implement build -providers and does not declare `buildProviders`. Provider plugins are therefore -filtered from the market and refused by declarative validation and install -review; template-only build plugins remain available. It does not declare -`vcsProviders` either, so VCS provider plugins are filtered the same way until -the macOS host starts providers. It declares `backend: [1]`: it starts -plugin backends and serves their credentials from the Keychain. Core supports `backend: 1`, `buildProviders: 1`, and -`vcsProviders: 1` for validation and plans without a host table. Windows has -no declarative plugin host and sends no `hostCapabilities`, so it offers no -level 3 version. +The macOS host sends its capability table with validation, marketplace filtering +and install review. It advertises `buildSystems: [1, 2]` and +`buildProviders: [1, 2, 3]`; native Provider models, dependencies, tasks and tests +share workspace-owned sessions. Backend and VCS support use their own entries +in the same table. Core validates supported versions without a host table, +but admission must use the actual host table. Windows does not yet advertise +Build Provider orchestration; Core protocol support alone does not enable it. Rules for publishing across levels and capability versions: @@ -888,7 +884,7 @@ older hosts. Output that does not fit its decoder is `plugin.build.invalidOutput ### Provider task execution (buildProviders 2) -A task with `source: "provider"` requires `requires.buildProviders: 2`. +A task with `source: "provider"` requires `requires.buildProviders: 2` or newer. It keeps the ordinary task ID, scopes, target tags and model selection, but `arguments` must be `[]` and `execution` must be `task`. Application/service launch and test discovery are separate capabilities. Provider model-task @@ -909,7 +905,7 @@ same invocation resolver; explicit invalid tool/runtime selection is a failure, never permission for independent discovery. It must reject stale project facts rather than guess a target. -The host offers `litheBuild.versions: [2, 1]`. Version 2 adds only execution +The host offers `litheBuild.versions: [3, 2, 1]`. Version 2 adds task execution and its progress notification to the two version 1 methods. A process that chooses version 1 cannot receive execution; unsupported negotiation fails explicitly. A plugin that needs this behavior declares capability version 2 @@ -946,6 +942,73 @@ Canonical examples: `fixtures/build-provider-execution.plugin.json` and `fixtures/build-execution-protocol.json`. Protocol v2 is a construction primitive for #1495; it does not by itself migrate Maven or Java consumers. +### Provider test execution (buildProviders 3) + +A build system's `test` may declare `source: "provider"` with +`requires.buildProviders: 3`. It retains the existing scopes, target tags and +supported filters, but `arguments` must be `[]`. It requires a plugin-owned +Provider process; `results` may be omitted or use `junitXml`/`litheTestResults` +reports. `hostIntegration` is refused: the plugin owns test execution and +report production, and the host reuses the existing report decoder. + +`build.testPlan` returns `provider: { method: "lithe/build/test", params }` +and the usual `results` metadata instead of argv. The effective +`buildSystemID`, `root`, `options`, `optionArguments`, `environment`, bounded +`userArguments`, optional `project`/`target`, and optional `suite`/`case` are +typed fields. A case filter does not require a suite when the provider +supports it; providers must reject selectors they cannot honor rather than +widening the test run. Paths, scopes and declared filters are validated before +the request is sent. The host's report project list only resolves report globs; +it is not a second backend project model. + +Only a process choosing protocol version 3 receives `lithe/build/test`. +Versions 1 and 2 explicitly refuse tests. Version 3 retains every version 2 +task behavior. Tests use the same Core-assigned operation identity, +`lithe/build/progress`, tool hint, bounded `{ exitCode, output }` result, +timeout, cancellation and late-result rules as task execution. Reports are +read after execution using the existing freshness, path and size checks. +Workspace-wide reports still require a current project model when the build +system has one. Configuration validity is retained through diagnostic and +report decoding; late results cannot update a changed workspace/configuration. + +Java/Maven migration remains separate. No new resource/cache location is introduced. +Canonical examples: `fixtures/build-provider-tests.plugin.json` and +`fixtures/build-test-protocol.json`. + +#### Test discovery + +Provider tests may additionally declare `discovery: true`. This requires both +`suite` and `case` filters: every discovered selector must be exactly runnable. +`build.testDiscoveryPlan` accepts the common plan fields and optional `refresh` +and `tool`, returning a request for the whole instance: +`lithe/build/discoverTests` with its effective options. Project/output-file +selection is refused. This method also requires protocol version 3 and uses +the existing bounded request/cancellation lifecycle, without execution progress. + +The provider returns `{ "format": "litheTestDiscovery", "version": 1, +"tests": [{ "suite", "case", "project"?, "target"? }] }`. Project and target +are the usual typed selections with workspace-relative paths. There may be at +most 10,000 items and 32 MiB UTF-8 JSON. Unknown document/item fields are +ignored; malformed selectors, invalid paths/scopes, unsupported versions and +oversized lists reject the entire result with `plugin.build.invalidOutput`. +An empty array is a successful discovery with no tests. + +The host passes the reply and original plan configuration to +`build.decodeTestDiscovery`. Core validates every selector through the same +test-plan rules, deterministically orders the list, collapses exact duplicates, +and derives an opaque `id` including project/target identity. Equal suite/case +names in sibling modules therefore remain distinct. The returned `discovery` +contains `tests: [{ id, suite, case, project?, target? }]`. No source parsing, +framework detection, or second project graph occurs in the host/Core. + +Native discovery starts only on a user action, reuses the workspace Provider, +and is independent of plugin pages. Source edits, configuration changes, +package changes and workspace teardown revoke the cached list and in-flight +discovery. A native action must revalidate the selected ID against the current +list before submitting its exact selectors to `lithe/build/test`; stale +toolbar selections cannot run. The backend must reject a selector whose facts +have become stale externally. Canonical example: `fixtures/build-test-discovery.json`. + ### Build providers Some tools expose their model only through their own API (the Gradle Tooling @@ -998,10 +1061,10 @@ The manifest only says how to start the provider and which sources use it. Everything else is negotiated in the LSP `initialize` exchange, so a provider can add optional data within a negotiated protocol version: -- The host sends `capabilities.experimental.litheBuild: { "versions": [2, 1] }`, +- The host sends `capabilities.experimental.litheBuild: { "versions": [3, 2, 1] }`, the build-provider protocol versions it speaks. Version 1 is the two requests above with the params and result formats of this section; - version 2 adds the typed execution described above. + version 2 adds typed task execution; version 3 adds typed test execution. - The provider answers `capabilities.experimental.litheBuild: { "version": 1 }` with the version it chose from that list. A provider that answers no version, or one the host did not offer, is not used for build requests; a diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-tests.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-tests.plugin.json new file mode 100644 index 0000000000..3749a7a3fd --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-tests.plugin.json @@ -0,0 +1,195 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-tests", + "displayName": "Build Provider Tests Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 3, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project", + "target" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "results": { + "decoder": "junitXml", + "reports": [ + "build/test-results/**/*.xml" + ] + }, + "discovery": true + } + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-test-discovery.json b/shared/contracts/plugins/v1/fixtures/build-test-discovery.json new file mode 100644 index 0000000000..32b0496deb --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-test-discovery.json @@ -0,0 +1,24 @@ +{ + "result": { + "format": "litheTestDiscovery", + "version": 1, + "tests": [ + { + "suite": "ExampleSuite", + "case": "checks spaces # [exact]", + "project": { + "name": "app", + "path": "app" + } + }, + { + "suite": "ExampleSuite", + "case": "checks spaces # [exact]", + "project": { + "name": "lib", + "path": "lib" + } + } + ] + } +} diff --git a/shared/contracts/plugins/v1/fixtures/build-test-protocol.json b/shared/contracts/plugins/v1/fixtures/build-test-protocol.json new file mode 100644 index 0000000000..bd010d1110 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-test-protocol.json @@ -0,0 +1,48 @@ +{ + "request": { + "root": ".", + "options": { + "offline": true + }, + "project": { + "name": "app", + "path": "app" + }, + "suite": "ExampleSuite", + "case": "checks spaces # [exact]", + "userArguments": [ + "--rerun-tasks" + ] + }, + "provider": { + "method": "lithe/build/test", + "params": { + "buildSystemID": "gradle", + "root": ".", + "options": { + "offline": true + }, + "optionArguments": [ + "--offline" + ], + "environment": {}, + "project": { + "name": "app", + "path": "app", + "tags": [], + "properties": {} + }, + "suite": "ExampleSuite", + "case": "checks spaces # [exact]", + "userArguments": [ + "--rerun-tasks" + ] + } + }, + "results": { + "decoder": "junitXml", + "reports": [ + "app/build/test-results/**/*.xml" + ] + } +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 3fad6f6413..40308a4875 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -28,7 +28,7 @@ "additionalProperties": false, "properties": { "backend": { "description": "Required exactly when backend is present: the plugin's own process serving its web pages.", "enum": [1] }, - "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support.", "enum": [1, 2] }, + "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution and version 3 test execution.", "enum": [1, 2, 3] }, "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1] }, "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, @@ -2375,6 +2375,14 @@ "arguments" ], "properties": { + "discovery": { + "type": "boolean", + "description": "Opt in to lithe/build/discoverTests; requires provider test source and suite/case filters." + }, + "source": { + "description": "Omitted expands argv; provider sends lithe/build/test, requires buildProviders 3, empty arguments, and plugin-owned report decoding.", + "enum": ["provider"] + }, "scopes": { "$ref": "#/$defs/buildScopes" }, diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 537baac764..cf603d8f45 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1107,7 +1107,9 @@ package manager owns the download and Lithe does not infer bytes from logs. | `build.resolveOptions` | Check stored build options against the declaration and return effective values, choices, and the fingerprint | | `build.modelPlan` | Expand the command that prints a command-sourced build model, or the request a build provider answers | | `build.taskPlan` | Expand one declared or model task for a workspace, project, or target | -| `build.testPlan` | Expand a test run for a scope, suite, or case and say where its reports will be | +| `build.testPlan` | Plan a test run for a scope, suite, or case and say where its reports will be | +| `build.testDiscoveryPlan` | Plan opt-in Provider test discovery for an instance | +| `build.decodeTestDiscovery` | Validate, deduplicate and order Provider test selectors | | `build.dependencyPlan` | Expand the command that prints a project's dependency tree, or the request a build provider answers | | `build.decodeModel` | Decode model output or a provider result into projects, targets, tasks, content roots, option values, and run entries | | `build.decodeTestResults` | Decode a test run's reports and output into per-case results and a summary | @@ -2540,11 +2542,14 @@ and virtual-location representation as ordinary navigation. `lsp.buildRequest` accepts `{ sessionId, operationId?, method, params, timeoutMilliseconds? }` and returns `{ operationId }`. `method` and `params` are the `provider` field of a `build.modelPlan`, `build.dependencyPlan` or -provider-sourced `build.taskPlan` result. Version 1 accepts `lithe/build/model` -and `lithe/build/dependencies`; version 2 also accepts `lithe/build/execute`. -Other methods are `invalid_request`; execution on version 1 is `not_supported`. +provider-sourced `build.taskPlan`/`build.testPlan` result. Version 1 accepts +`lithe/build/model` and `lithe/build/dependencies`; version 2 adds +`lithe/build/execute`, version 3 adds `lithe/build/test` with the same bounded +execution lifecycle and `lithe/build/discoverTests` with the raw result lifecycle. +Other methods are `invalid_request`; tasks on version 1 +and tests on versions 1/2 are `not_supported`. Every session offers -`capabilities.experimental.litheBuild: { versions: [2, 1] }` in `initialize`; a +`capabilities.experimental.litheBuild: { versions: [3, 2, 1] }` in `initialize`; a session whose process did not answer one of those versions rejects the request with `not_supported`, so a shared provider that does not speak the protocol still serves its language. `timeoutMilliseconds` replaces the session's request @@ -3310,10 +3315,11 @@ has one build workflow and never branches on a build-system ID: Core plans each process, the host runs it with the discovered executable, and Core decodes what it printed or wrote. -A build system with a `provider` requires `buildProviders: 1`, or version 2 -when a task uses `source: "provider"`. Core validates and plans both versions; +A build system with a `provider` requires `buildProviders: 1`, version 2 when +a task uses `source: "provider"`, or version 3 when tests do. +Core validates and plans all three versions; a host advertises each only after implementing its lifecycle and requests. -macOS advertises both and routes provider tasks through the existing workspace +macOS advertises all three and routes provider tasks and tests through the existing workspace session. Windows does not yet advertise Provider lifecycle support. Except `build.detect`, every command accepts @@ -3354,7 +3360,9 @@ and the directories are absolute host paths used only as substitution text. | `build.resolveOptions` | `{ options, modelValues? }` | `options`: `{ options: [{ id, displayName, kind, value, source, choices, allowCustom }], dropped, fingerprint }` | | `build.modelPlan`, `build.dependencyPlan` | plan fields plus `project?` (dependencies), `outputFile?`, and for provider sources `refresh?` (boolean) and `tool?: { executable }` (absolute path) | `plan`: `{ buildSystemID, root, arguments, environment, workingDirectory, execution, optionFingerprint, output, decoder, executesProjectCode, mayUseNetwork }`; for a `provider` source `{ buildSystemID, root, provider: { method, params }, decoder, executesProjectCode, mayUseNetwork, optionFingerprint }`, where `method` is `lithe/build/model` or `lithe/build/dependencies` and `params` is `{ buildSystemID, root, options, optionArguments, environment, project?, refresh?, tool? }`; `refresh` appears only when `true` and `tool` only when the host sent it | | `build.taskPlan` | plan fields plus `task`, `project?`, `target?`, `modelTask?`, `userArguments?` | command `plan` with `arguments`, `environment`, `workingDirectory`, `execution`, and `optionFingerprint`; a provider task instead returns `provider: {method: "lithe/build/execute", params}` with effective options, selected task/entities, user arguments and effect metadata | -| `build.testPlan` | plan fields plus `project?`, `target?`, `suite?`, `case?`, `userArguments?`, `projects?` (the model's project directories) | `plan` with the task-plan fields and `results`: `null`, `{ decoder, reports }` with workspace-relative globs (for the selected project, or for the root and every listed project when none is selected), or `{ decoder: "hostIntegration" }`. A `suite` or `case` the templates cannot place on the command line under this selection, such as a case without its suite when the tool only addresses `Suite#case`, is `plugin.build.invalidSelection` rather than a wider run | +| `build.testPlan` | plan fields plus `project?`, `target?`, `suite?`, `case?`, `userArguments?`, `projects?` (the model's project directories) | command plan or `provider: {method: "lithe/build/test", params}` (buildProviders 3), plus `results`: `null`, `{ decoder, reports }` with workspace-relative globs (for the selected project, or for the root and every listed project when none is selected), or command-only `{ decoder: "hostIntegration" }`. Command filters that templates cannot place, such as a case without its suite when the tool only addresses `Suite#case`, are `plugin.build.invalidSelection`; Provider filters remain separate typed values and must be declared supported | +| `build.testDiscoveryPlan` | common plan fields plus `refresh?`, `tool?`; no project/output file | `plan.provider` for `lithe/build/discoverTests`, only with `test.discovery: true` (Provider v3), using the same effective options and effect metadata | +| `build.decodeTestDiscovery` | common plan fields plus canonical `result` | `discovery.tests` with validated `{id, suite, case, project?, target?}` selectors; bounded at 10,000 items/32 MiB, deterministic and exactly runnable; malformed replies reject in full | | `build.decodeModel` | `{ workspaceRoot, root, output?, outputFile?, result?, options? }`; `result` is the provider's JSON answer | `model`: `{ projects, targets, tasks, contentRoots, optionValues, run }`; `run` entries carry `buildSystem: { id, root, projectID, targetID?, optionFingerprint }` | | `build.decodeTestResults` | `{ workspaceRoot, root, project?, projects?, output, notBeforeMillis?, suite? }`; without `project` the reports of the root and every listed model project are read | `results`: `{ tests: [{ suite, case, status, message?, durationMillis?, path?, line?, invocations }], summary: { total, passed, failed, errors, skipped }, success }`; repeated invocations of one case merge into the most severe status | | `build.decodeDiagnostics` | `{ workspaceRoot, root, output }` | `diagnostics`: `[{ path, line, column?, severity, message }]`, deduplicated, inside the workspace only | diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index c8a30859a0..d4fc07488e 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9296,7 +9296,11 @@ "native.size": "Size", "native.thisFolderCannotBeRead": "This folder cannot be read.", "native.thisFolderIsEmpty": "This folder is empty", - "native.theLanguageServerThisBuildSystemUsesIsNotAvailable": "The language server this build system uses is not available. Open a file of the language and try again." + "native.theLanguageServerThisBuildSystemUsesIsNotAvailable": "The language server this build system uses is not available. Open a file of the language and try again.", + "native.loadModelBeforeWorkspaceTests": "Load or reload the project model before running all tests, so subproject reports are collected.", + "native.discoverTests": "Discover Tests", + "native.discoveredTests": "Discovered Tests", + "native.noTestsDiscovered": "No tests discovered." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 46346873bf..fb34ce4b25 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9296,7 +9296,11 @@ "native.size": "大小", "native.thisFolderCannotBeRead": "无法读取此文件夹。", "native.thisFolderIsEmpty": "此文件夹为空", - "native.theLanguageServerThisBuildSystemUsesIsNotAvailable": "此构建系统使用的语言服务器不可用。请打开该语言的文件后重试。" + "native.theLanguageServerThisBuildSystemUsesIsNotAvailable": "此构建系统使用的语言服务器不可用。请打开该语言的文件后重试。", + "native.loadModelBeforeWorkspaceTests": "运行全部测试前,请加载或重新加载项目模型,以便收集子项目的测试报告。", + "native.discoverTests": "发现测试", + "native.discoveredTests": "已发现的测试", + "native.noTestsDiscovered": "未发现测试。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 80e13c2df5..d037e4c6a3 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -88270,6 +88270,50 @@ "key": "The language server this build system uses is not available. Open a file of the language and try again.", "arguments": [] } + }, + { + "id": "native.loadModelBeforeWorkspaceTests", + "description": "Native UI: Load or reload the project model before running all tests, so subproject reports are collected.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load or reload the project model before running all tests, so subproject reports are collected.", + "arguments": [] + } + }, + { + "id": "native.discoverTests", + "description": "Native UI: Discover Tests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discover Tests", + "arguments": [] + } + }, + { + "id": "native.discoveredTests", + "description": "Native UI: Discovered Tests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discovered Tests", + "arguments": [] + } + }, + { + "id": "native.noTestsDiscovered", + "description": "Native UI: No tests discovered.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No tests discovered.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index 30df5225d6..a286c5400a 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -2,7 +2,7 @@ "id": "build-system-plugins", "area": "扩展", "group": "插件与主题", - "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退;buildProviders 2 原生任务执行复用工作区后端会话,带进度、取消、结果与配置失效隔离", + "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退;buildProviders 2/3 原生任务与测试执行复用工作区后端会话,带进度、取消、结果与配置失效隔离;协议 3 可按声明发现测试并在现有构建树按精确选择器运行,源码/设置变化撤销旧清单", "macos": { "evidence": [ "rust/lithe-core/src/build", @@ -32,7 +32,11 @@ "shared/contracts/plugins/v1/fixtures/build-provider-execution.plugin.json", "shared/contracts/plugins/v1/fixtures/build-execution-protocol.json", "macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift", - ".agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md" + ".agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md", + "shared/contracts/plugins/v1/fixtures/build-provider-tests.plugin.json", + "shared/contracts/plugins/v1/fixtures/build-test-protocol.json", + "rust/lithe-core/src/build/test_discovery.rs", + "shared/contracts/plugins/v1/fixtures/build-test-discovery.json" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -47,5 +51,5 @@ "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。" + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实工具及 Windows 工作流仍待验收。" } From 0545723cfaa752a836418b2034f1a2555663670a Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Tue, 27 Oct 2026 15:10:41 +0800 Subject: [PATCH 03/76] feat(plugins): prepare and persist provider application runs (#1495) --- ...2026-10-17-build-system-plugin-contract.md | 4 +- ...026-10-26-build-provider-task-execution.md | 65 ++++- macos/Resources/en.lproj/Localizable.strings | 12 + .../zh-Hans.lproj/Localizable.strings | 12 + .../IdeCapabilitiesFeatureModel.swift | 13 + .../Core/Rust/RustBuildSystemOperations.swift | 32 +++ .../Lithe/Core/Rust/RustCoreBridge.swift | 9 + .../AppModel/AppModel+RunConfiguration.swift | 18 +- .../Build/MacBuildProviderSessions.swift | 8 + .../Platform/MacOS/MacServiceContainer.swift | 34 +-- .../MacRunConfigurationStore.swift | 19 +- .../Views/Run/BuildSystemToolWindowView.swift | 15 +- macos/Sources/Lithe/Views/Run/RunView.swift | 40 ++- .../Execution/BuildSystemContracts.swift | 72 +++++- .../Execution/RunConfigurationContracts.swift | 4 + .../Execution/RunModels.swift | 9 +- .../Plugins/DeclarativePluginContracts.swift | 16 +- .../BuildSystemsFeatureModel.swift | 5 + .../Application/ExecutionFeatureModels.swift | 9 +- .../Module/ExecutionFeatureGraph.swift | 7 + .../Services/BuildSystemService.swift | 83 ++++++ .../Services/RunService.swift | 158 +++++++++++- .../LitheModuleAPI/Plugins/PluginTypes.swift | 2 +- .../BuildSystemServiceTests.swift | 164 +++++++++++- .../ExecutionModuleTests.swift | 140 +++++++++- .../BuildToolProviderResolutionTests.swift | 4 + .../LitheTests/IdeCapabilitiesTests.swift | 196 +++++++++++++- .../MacBuildProviderSessionsTests.swift | 24 +- .../RunConfigurationIntegrationTests.swift | 27 ++ .../RustBuildSystemOperationsTests.swift | 23 ++ rust/lithe-core/src/build/commands.rs | 24 ++ rust/lithe-core/src/build/manifest.rs | 40 ++- rust/lithe-core/src/build/mod.rs | 1 + rust/lithe-core/src/build/plans.rs | 11 +- rust/lithe-core/src/build/run_preparation.rs | 171 +++++++++++++ .../lithe-core/src/execution/configuration.rs | 241 +++++++++++++++++- .../src/lsp/interface/build_provider.rs | 26 +- rust/lithe-core/src/lsp/interface/client.rs | 3 + rust/lithe-core/src/lsp/interface/engine.rs | 74 +++++- rust/lithe-core/src/plugins/capabilities.rs | 2 +- rust/lithe-core/src/protocol/command.rs | 6 + rust/lithe-core/src/runtime/dispatcher.rs | 10 + rust/lithe-core/src/tests/build_systems.rs | 117 ++++++++- .../lithe-core/src/tests/run_configuration.rs | 113 ++++++++ shared/contracts/application-boundary.md | 13 + shared/contracts/plugins/v1/README.md | 75 +++++- ...build-provider-run-preparation.plugin.json | 208 +++++++++++++++ .../v1/fixtures/build-run-preparation.json | 39 +++ .../plugins/v1/plugin-manifest.schema.json | 6 +- .../run-configuration-v2.schema.json | 70 ++++- shared/contracts/rust-core-api.md | 36 ++- .../run-configuration/build-provider.json | 30 +++ shared/i18n/locales/en-US.json | 8 +- shared/i18n/locales/zh-CN.json | 8 +- shared/i18n/messages.json | 66 +++++ .../features/build-system-plugins.json | 16 +- 56 files changed, 2528 insertions(+), 110 deletions(-) create mode 100644 rust/lithe-core/src/build/run_preparation.rs create mode 100644 shared/contracts/plugins/v1/fixtures/build-provider-run-preparation.plugin.json create mode 100644 shared/contracts/plugins/v1/fixtures/build-run-preparation.json create mode 100644 shared/fixtures/run-configuration/build-provider.json diff --git a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md index 3939cf21d0..8bfb4b66d6 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md +++ b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md @@ -6,7 +6,7 @@ Lithe 现在用一套统一的构建模型描述所有项目级构建工具(Build Tool,例如 Maven、Gradle、Cargo、Go)。插件在 `plugin.json` 的 `buildSystems`(插件 API 级别 3)里用纯 JSON 声明:怎样认出工程、用哪个可执行文件、有哪些选项、任务、运行入口和测试,以及怎样读工具的输出。思路与 LSP 插件化相同:插件只提供数据,通用逻辑只在 Rust Core 实现一次。 -Rust Core 负责校验声明、识别工程、展开每一条命令行,并把输出解码成统一的形状,通过 13 个 `build.*` JSON 命令交给宿主。宿主只负责找可执行文件、启动进程、监听文件和保存用户选择,代码里不按构建系统的名字写分支。因此社区插件不改宿主代码就能加入一个新构建工具。 +Rust Core 负责校验声明、识别工程、展开每一条命令行,并把输出解码成统一的形状,通过 `build.*` JSON 命令交给宿主。宿主只负责找可执行文件、启动进程、监听文件和保存用户选择,代码里不按构建系统的名字写分支。因此社区插件不改宿主代码就能加入一个新构建工具。 映射(`map`)只从解码后的文档里挑值。挑值以外的逻辑由插件自己处理:读 JSON、YAML 清单,从工具读任务,计算父子关系,这些都交给插件自带的脚本输出规范格式(第 6 节)。声明和规范格式都表达不了的工具(Gradle Tooling API 等),由插件自带的 Provider 进程回答。Provider 就是语言服务器那种进程:共用启动字段、校验和启动计划,只多回答 `lithe/build/model`、`lithe/build/dependencies`,回复仍由 Core 解码(第 9 节)。 @@ -14,7 +14,7 @@ Rust Core 负责校验声明、识别工程、展开每一条命令行,并把 ### Provider 任务与测试执行补充(2026-10-26) -当前增加 `buildProviders: 2/3` 与 `litheBuild` 协议 2/3:原生 Build 任务和测试可通过各自的类型化请求进入同一个工作区 Provider,不要求网页,不使用插件私有函数。测试报告复用原有解析器,筛选参数由后端解释;协议 3 还允许插件提供可精确运行的测试清单,在原生共享树中展示。本文关于版本 1 仅有模型/依赖的说明仍适用于版本 1;具体边界、取消与删除条件见[Build Provider 原生任务执行](2026-10-26-build-provider-task-execution.md)。这项能力不代表 Maven/Java 的旧生产消费者已完成迁移。 +当前增加 `buildProviders: 2/3/4` 与 `litheBuild` 协议 2/3/4:原生 Build 任务、测试发现/执行和应用启动准备通过类型化请求进入同一个工作区 Provider,不要求网页,不使用插件私有函数。测试报告复用原有解析器,应用准备交给既有 Run 进程所有者。本文关于版本 1 仅有模型/依赖的说明仍适用于版本 1;具体版本准入、进度、取消与删除条件见[Build Provider 原生任务执行](2026-10-26-build-provider-task-execution.md)。这不代表命令模型运行配置、服务编排或 Java/Maven 业务迁移已经完成。 ## 问题 diff --git a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md index 82b41c45b0..eb0bcca254 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md +++ b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md @@ -4,7 +4,7 @@ ## 先说结论 -构建插件现在可以在自己的工作区 Provider(提供构建能力的后端进程)里执行任务、发现和运行测试,而不是把每个操作都展开为宿主命令。原生 Build 窗口复用现有工作区会话、输出、诊断、取消和进程清理,测试报告复用现有解析器,不要求插件增加网页或调用私有函数。这是 [#1495](https://github.com/1lck/Lithe-IDEA/issues/1495) 阶段 C 的构建与测试能力;Maven/Java 业务迁移、运行准备与公共调试注册接入仍须另行完成。 +构建插件现在可以在自己的工作区 Provider(提供构建能力的后端进程)里执行任务、发现和运行测试,而不是把每个操作都展开为宿主命令。原生 Build 窗口复用现有工作区会话、输出、诊断、取消和进程清理,测试报告复用现有解析器,不要求插件增加网页或调用私有函数。这是 [#1495](https://github.com/1lck/Lithe-IDEA/issues/1495) 阶段 C 的构建、测试与应用启动准备基础能力;Maven/Java 业务迁移、多服务编排,以及运行准备与既有调试适配器注册表的衔接不在本决策已实现的范围内。 ## 问题 @@ -62,6 +62,32 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 不要这样做:宿主通过 `functions.invoke("maven.build")` 猜测 Maven 私有函数;Provider 超时后再启动旧 `MavenService`;为获取工作区进程而创建空网页。它们分别绕过领域契约、保留旧业务回退和错误绑定页面生命周期。 +### 应用准备与应用进程分别管理 + +`buildProviders: 4` 与进程协商版本 4 增加应用启动准备。Provider 先使用自己的上游工程状态完成同步、构建并给出启动计划;Core 只检查目标身份、作用范围、参数大小、可执行工具引用与工作目录。比如后端从自己的工程引擎取得 classpath,返回分开的参数;宿主不要解析 POM 或源码再算另一份 classpath,也不要把启动命令拼成 shell 字符串。 + +本批只开放模型目标上的应用入口。运行入口不再生成一个空命令配置:Build 树按目标标签列出入口,点击时重新查询当前模型 ID。准备过程仍在 Build Output 显示进度,成功后交给现有 RunService,并切到 Run 窗口。RunService 继续持有进程、工具解析器、输出和 Java 长参数临时文件的租约;应用退出、停止或启动失败时释放租约。Provider 回复只是准备完成,不代表应用已经结束。 + +准备中的源码、模型、配置、插件或工作区变化使旧结果失效,解码完成后也要再次检查。应用启动后不因普通源码编辑就终止;停用所属插件或关闭工作区才按对应操作 ID 停止,旧操作 ID 不能停止新进程。运行目录只能是工作区内、属于当前实例的已有目录;符号链接解析后的目录也必须满足这一限制。没有新增运行资源目录、下载或跨工作树缓存,既有平台进程与临时参数 adapter 继续负责可写位置,安装包保持只读。 + +首次从 Build 树运行仍在 Build 窗口管理准备;从 Run 重启时,Run 的停止动作取消对应准备 ID,成功启动后改由进程 ID 管理。应用输出不再要求旧式检测器先生成 Run 配置,也不会因缺少 generated.json 被配置引导页遮住。多服务编排与调试目标注册仍未接入,不能把此增量表述为阶段 C 或 Java/Maven 迁移全部完成。 + +### 保存选择器,重启时重新准备 + +用户可以把当前 Provider 入口显式保存到已有 `.lithe/run/local.json`,不另建最近运行数据库或镜像设置。Core 只生成文档变更,平台适配器检查原文件快照后原子写入,并补齐本地文件的忽略规则;写入后重新读取,失败明确报告。相同插件/实例/目标/声明重复保存会更新原条目并保留 ID,不产生重复配置,也不改动其他条目和 JDK 默认值。 + +保存的是“由哪个插件的哪个实例准备哪个模型目标”,加上原始参数数组,不是后端上一次返回的 classpath、命令或环境。比如保存 `Main` 后重启,宿主重新查找当前已启用的插件和模型 ID,再发送 `prepareRun`;不能直接执行上次的 `java -cp ...`。首次保存不需要生成器建立一份空模型,读取时仅对明确保存的 Provider 入口允许空生成层;其他未识别工程保留原缺失语义。 + +原生点击和重启先等待工作区文档保存,保存失败或工作区代次改变便停止,不发送准备请求。准备失败、用户停止、插件停用和工作区切换各自终结对应身份;旧准备完成不能启动进程,旧终止事件不能结束替换准备。应用仍使用单个主运行槽,不宣称已经提供通用 Services 或多应用并行编排。 + +### SDK 使用同一组类型化入口 + +配套 Node/Java SDK 源码在独立的 `lithe-plugin-docs` 仓库实现任务、测试、发现与运行准备处理器,不复制进主仓库。SDK 选择宿主明确提供的最高共同协议版本;版本 1 不能调用执行、版本 2 不能调用测试、版本 3 不能调用运行准备,即使后端注册了对应处理器也一样。清单能力准入与进程协商是两层独立检查,SDK 不用私有函数代替领域方法。 + +执行、测试和运行准备的上下文保留 Core 分配的线缆操作身份,进度只带这份身份。处理器改动自己的参数对象不能换掉进度归属;取消和结束后不再报告。Node 的类型声明与 Java 的具名入口共同表达这条边界,Core 继续负责选择器、工作区路径与领域输出校验,SDK 不再维护一份工程引擎。 + +两套无页面示例使用虚构引擎展示协议,端到端回归通过真实 Core 检查构建退出码、精确筛选、运行前构建与准备计划、并行工作区隔离、取消后的工具清理,以及包含 SDK/JAR 的完整包字节不变。SDK 处理器属于开发中的源码,不代表已发布 SDK 或现有 Maven/Java 插件完成迁移;发布契约副本仍必须等主仓库合并后运行同步工具。 + ## 考虑过的备选方案 - **扩展独立页面 backend。** 它是全应用范围的私有服务;改为工作区服务需要重复已有 Provider 的作用域和清理。执行首先复用现有工作区机制。 @@ -77,7 +103,13 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 ## 验证 -共享 fixture 覆盖声明准入、请求/结果与无效输入。Core 回归验证版本 1 拒绝、版本 2 执行、进度映射、取消与迟到结果隔离,以及注入时间的超时。macOS 回归覆盖真实 Core 计划解码、原生服务不启动命令、非零退出码、配置/停用/工作区失效、同工作区会话复用及不同工作区隔离,读取与执行前后包资源字节不变。脚本化运行时验证不等于真实安装包或 Maven/Java 工程验收。 +共享 fixture 覆盖版本准入、类型化请求/回复、路径与大小限制,以及本地 Run 选择器。Core 回归使用独立计时进程验证旧协议拒绝新方法、进度映射、取消、注入时间的超时和晚到结果;保存回归验证不生成空模型、重复保存保留 ID、其他命名空间的元数据和工具链不丢失、缺省参数归一化、非法进程字段明确拒绝。 + +macOS 使用固定的 Swift 6.3.3 工具链和原生计时 harness。相关服务回归控制准备开始、解码暂停、停止或工作区切换的顺序;原生存储用真实 Core 和隔离临时目录验证保存后读回。Run 的对外操作身份从准备到应用退出保持不变,底层准备/进程各自按身份取消;Agent 启动返回同一主运行记录,授权仍沿用既有入口。插件停用、进程替换和工作区切换不能留下旧输出、订阅或可操作的旧主运行记录。 + +Agent 入口回归从真实本地 Run 配置读取选择器,用可控 Provider 准备与进程替身依次验证启动、准备中停止、重启、输出和退出状态。准备到进程的交接不得更换公开操作 ID;重启产生新 ID,旧 ID 的停止请求必须报告失效,不能取消新准备。替身不启动系统工具,这项证据不代替真实插件后端的端到端验收。 + +可复验的检查入口: ```bash ./scripts/verify-agent-notes.sh @@ -87,24 +119,37 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 ./scripts/verify-windows-boundaries.sh ./scripts/verify-rust-core-comments.sh ./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh -./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'BuildSystemServiceTests|MacBuildProviderSessionsTests|RustBuildSystemOperationsTests|BuildToolProviderResolutionTests|PluginWebBridgeTests' +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --jobs 6 --filter 'BuildSystemServiceTests|ExecutionModuleTests|MacBuildProviderSessionsTests|RustBuildSystemOperationsTests|BuildToolProviderResolutionTests|PluginWebBridgeTests|BuildSystemToolWindowTests|RunConfigurationIntegrationTests|RunWorkflowCoordinatorTests|IdeCapabilitiesTests' +cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib build_systems +cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib run_configuration +cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib lsp ``` -2026-10-26 本地验证:macOS 116 项与相关 Core/LSP/插件/构建 190 项通过,单项计时分别在 `macos-swift.json`、`core-provider-focused.json`;新增测试最长 74 毫秒。官方 SDK 工具对当前 Core 临时 Git 快照的 Node/Java/Rust 示例共 4 项端到端检查通过;这验证现有 SDK 的协议兼容,不代表 SDK 已有新执行处理器。i18n 35 项、共享契约、Rust 注释、Windows 边界、包只读静态检查和功能矩阵门禁均通过。 +当前本地逐项计时证据为 `saved-provider-complete-macos.json`、`saved-provider-core.json`、`provider-sdk-unit.json` 与 `provider-sdk-native.json`,对应 HTML/JUnit 保存在 `.artifacts/test-stability/`。SDK 兼容场景另外使用契约锁定的 Core,原生工作流场景使用显式工作区源码快照,不能把日志里的基线 SHA 当作完整已提交实现。Java 产物检查 Java 8 字节码;本地运行时和 Windows 实机证据分别记录,不能用编译替代运行。 -测试执行补充验证:macOS 119 项与相关 Core 194 项通过,计时分别记录于 `provider-tests-macos.json` 和 `core-provider-focused.json`。原生入口覆盖零/非零退出码、报告发布、报告解析期间改选项,以及任务/测试共用的取消和迟到输出隔离;会话回归对两个方法分别验证跨工作区隔离和包字节不变。诊断解析后的反向订阅断言对两个入口各观察 500 毫秒,聚合测试约 1.025 秒;报告解析对应断言约 520 毫秒,不使用睡眠同步。 +在 `lithe-plugin-docs` 运行以下入口可以复验 SDK 部分: -官方 SDK 工具在 `lithe-plugin-docs@2215a62b4d748a627bc5bb00ccec8bbf3e6ac749` 对当前 Core 的隔离快照完成 4 项端到端检查,已有版本 1 的模型/依赖及函数示例继续可用;这仍是兼容性证据,不代表三个 SDK 已提供版本 3 的测试处理器。i18n 35 项通过,功能矩阵继续保留真实安装包与 Windows 待验收状态。新增测试路径没有增加下载物或缓存资源,资源复用清单不变。 +```bash +node --test sdk/node/test/*.test.mjs scripts/*.test.mjs +npm ci --prefix sdk/node --ignore-scripts +npm run --prefix sdk/node test:types +mvn -B -ntp -f sdk/java/pom.xml verify +node scripts/sdk-e2e.mjs +node scripts/sdk-e2e.mjs --source /path/to/Lithe-IDEA --working-tree --build-workflows +``` -2026-10-27 测试发现先完成 macOS 167 项与 Core 196 项回归;合入 `preview@32021a86f0c6c4ad2964dbbe3c2bb7854d84ad67` 后,macOS 197 项、相关 Core 207 项、SDK 端到端 4 项与 i18n 35 项通过。`pr1517-merged-macos.json` 与 `pr1517-merged-core.json` 记录逐项计时,同时覆盖调试适配器与插件热激活能力。发现清单往返真实 Core、原生精确运行、源码/设置/选项失效、排队后立即替换、无后端/后端失败,以及解码期间取消/停用/切换工作区均有回归。五种迟到清单的反向订阅各观察 500 毫秒,聚合约 2.526 秒;这是有界的不应发布断言,不是靠睡眠等待成功。首次完整重编译在链接测试包前达到 600 秒套件期限,未开始执行测试;保留缓存、使用相同期限重跑后通过,原始超时记录保留。SDK 工具使用 `lithe-plugin-docs@aa9c4433cf3af268c506b1279391488f47703f3e` 对合并源码的隔离快照验证,SDK 源码与前述版本一致;协议 3 的真实插件/SDK 处理器与发行包实机验收不包含在这项兼容性证据中。 +工作区快照工具只复制 `rust/` 和 `shared/` 的暂存、未暂存与未跟踪源码,源目录和 Git index 保持只读,忽略的构建缓存不复制。SDK 验证使用独立构建目录,不与主工作树共享可变 Cargo 状态;这些临时验证输入不是产品运行资源或可跨工作树复用缓存。 -`verify-service-boundaries.sh` 仍报告本次未修改、基线已存在的 `macos/Sources/Lithe/Services/Java/RunLaunchEnvironment.swift:50` 硬编码系统路径,未放宽规则。真实发行包工作流与 Windows 实机验收未执行,矩阵继续保持 `pending`。契约副本合并后同步;新执行 SDK 与 Maven/Java 消费者继续单独迁移。 +这些证据不替代真实插件工程、发行包和 Windows 实机验收,功能矩阵保持 `pending`。没有新增下载物或产品资源目录;本地用户配置不属于可跨工作树共享的构建缓存。新增目录或依赖时仍须遵循资源复用和安装包只读规则。 ## 适用范围 - `rust/lithe-core/src/build/`、`rust/lithe-core/src/lsp/interface/`、`rust/lithe-core/src/plugins/` +- `rust/lithe-core/src/execution/configuration.rs` - `macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift` - `macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift` +- `macos/Sources/LitheExecutionModule/Services/RunService.swift` +- `macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift` - `macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift` - `macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift` - `macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift` @@ -112,3 +157,7 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 - `macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift` - `shared/contracts/plugins/v1/` - `shared/platform-feature-matrix/features/build-system-plugins.json` +- `shared/contracts/run-configuration-v2.schema.json` +- `shared/fixtures/run-configuration/build-provider.json` +- `macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift` +- `macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift` diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 23cb2c4dd3..28fe6011ce 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1940,6 +1940,7 @@ "Load more debugger variables" = "Load more debugger variables"; "Load or reload the project model before running all tests, so subproject reports are collected." = "Load or reload the project model before running all tests, so subproject reports are collected."; "Load the %@ model" = "Load the %@ model"; +"Load the current build model and enable its provider before running this configuration." = "Load the current build model and enable its provider before running this configuration."; "Load the dependencies again" = "Load the dependencies again"; "Load the dependencies of %@" = "Load the dependencies of %@"; "Load the project model" = "Load the project model"; @@ -3101,6 +3102,7 @@ "Run another ACP Agent you installed yourself. It must support signing in with a custom API key gateway." = "Run another ACP Agent you installed yourself. It must support signing in with a custom API key gateway."; "Run checked configurations without restarting running services" = "Run checked configurations without restarting running services"; "Run configuration" = "Run configuration"; +"Run configuration changed while saving. Reload and try again." = "Run configuration changed while saving. Reload and try again."; "Run configurations" = "Run configurations"; "Run configurations and Maven goals started in Lithe. They always stop when their project or window closes or Lithe quits, together with the processes they started; this only decides whether Lithe lists them and asks first." = "Run configurations and Maven goals started in Lithe. They always stop when their project or window closes or Lithe quits, together with the processes they started; this only decides whether Lithe lists them and asks first."; "Run configurations are unavailable. Enable the execution module and try again." = "Run configurations are unavailable. Enable the execution module and try again."; @@ -3168,6 +3170,7 @@ "Save local changes with" = "Save local changes with"; "Save message" = "Save message"; "Save or discard unsaved files before deleting this item" = "Save or discard unsaved files before deleting this item"; +"Save provider run configuration to .lithe/run/local.json" = "Save provider run configuration to .lithe/run/local.json"; "Save scope" = "Save scope"; "Save shared instructions" = "Save shared instructions"; "Save the active document" = "Save the active document"; @@ -3181,6 +3184,7 @@ "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git." = "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git."; "Saved to %@" = "Saved to %@"; "Saves, connects, and opens the remote folder in this window." = "Saves, connects, and opens the remote folder in this window."; +"Saving build provider run configurations is unavailable." = "Saving build provider run configurations is unavailable."; "Saving replaces the current GitHub metadata." = "Saving replaces the current GitHub metadata."; "Saving writes to" = "Saving writes to"; "Scala Support" = "Scala Support"; @@ -3704,6 +3708,7 @@ "The branch now contains the completed plan. Its original history remains available through the recovery reference." = "The branch now contains the completed plan. Its original history remains available through the recovery reference."; "The breakdown needs Model call statistics (on by default). After turning it on, reconnect and send a message." = "The breakdown needs Model call statistics (on by default). After turning it on, reconnect and send a message."; "The build provider failed to start." = "The build provider failed to start."; +"The build provider returned an invalid launch plan." = "The build provider returned an invalid launch plan."; "The build provider stopped before it was ready." = "The build provider stopped before it was ready."; "The build tool exited with code %lld." = "The build tool exited with code %lld."; "The build tool output is too large or not UTF-8." = "The build tool output is too large or not UTF-8."; @@ -3840,6 +3845,7 @@ "The repository will be opened in detached HEAD state." = "The repository will be opened in detached HEAD state."; "The request has no secret." = "The request has no secret."; "The response stopped at the model's token limit." = "The response stopped at the model's token limit."; +"The saved provider run configuration is unavailable." = "The saved provider run configuration is unavailable."; "The selected AI provider has an invalid API URL or model." = "The selected AI provider has an invalid API URL or model."; "The selected AI provider has no API key." = "The selected AI provider has no API key."; "The selected background image authorization is invalid." = "The selected background image authorization is invalid."; @@ -6156,6 +6162,7 @@ "native.interface" = "Interface"; "native.interrupt" = "Interrupt"; "native.interrupted" = "Interrupted"; +"native.invalidBuildProviderLaunchPlan" = "The build provider returned an invalid launch plan."; "native.invalidGitTagName" = "Invalid Git tag name."; "native.invalidHookFileGuards" = "Invalid Hook file guards."; "native.invalidIdeApiRequest" = "Invalid IDE API request"; @@ -6398,6 +6405,7 @@ "native.loadMoreCommits" = "Load more commits"; "native.loadMoreDebuggerVariables" = "Load more debugger variables"; "native.loadProjectModel" = "Load Project Model"; +"native.loadProviderModelBeforeRun" = "Load the current build model and enable its provider before running this configuration."; "native.loadTheDependenciesAgain" = "Load the dependencies again"; "native.loadTheDependenciesOf" = "Load the dependencies of %1$@"; "native.loadTheModel" = "Load the %1$@ model"; @@ -7210,6 +7218,8 @@ "native.providerDetails" = "Provider details"; "native.providerName" = "Provider name"; "native.providerRequiresAnAPIKey" = "Provider requires an API key"; +"native.providerRunSaveConflict" = "Run configuration changed while saving. Reload and try again."; +"native.providerRunSavingUnavailable" = "Saving build provider run configurations is unavailable."; "native.providers" = "Providers"; "native.provides" = "Provides"; "native.pruneStaleRecords" = "Prune Stale Records"; @@ -7629,6 +7639,7 @@ "native.saveMetadata" = "Save Metadata"; "native.saveOrDiscardUnsavedFilesBeforeDeletingThisItem" = "Save or discard unsaved files before deleting this item"; "native.savePatch" = "Save Patch…"; +"native.saveProviderRunConfiguration" = "Save provider run configuration to .lithe/run/local.json"; "native.saveScope" = "Save scope"; "native.saveSharedInstructions" = "Save shared instructions"; "native.saveTheActiveDocument" = "Save the active document"; @@ -7640,6 +7651,7 @@ "native.savedInLitheRunConfigurationsJsonForTheWholeTeamLocalJDKPathsAreNeverShared" = "Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared."; "native.savedInLitheRunLocalJsonAndExcludedFromGit" = "Saved in .lithe/run/local.json and excluded from Git."; "native.savedOnlyForThisProjectOnThisComputerInLitheRunLocalJsonAndExcludedFromGit" = "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git."; +"native.savedProviderRunUnavailable" = "The saved provider run configuration is unavailable."; "native.savedTo" = "Saved to %1$@"; "native.savesConnectsAndOpensTheRemoteFolderInThisWindow" = "Saves, connects, and opens the remote folder in this window."; "native.savingReplacesTheCurrentGitHubMetadata" = "Saving replaces the current GitHub metadata."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 219ba69230..f2355995c2 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1940,6 +1940,7 @@ "Load more debugger variables" = "加载更多调试变量"; "Load or reload the project model before running all tests, so subproject reports are collected." = "运行全部测试前,请加载或重新加载项目模型,以便收集子项目的测试报告。"; "Load the %@ model" = "加载 %@ 的模型"; +"Load the current build model and enable its provider before running this configuration." = "请先加载当前构建模型并启用对应提供方,再运行此配置。"; "Load the dependencies again" = "重新加载依赖"; "Load the dependencies of %@" = "加载 %@ 的依赖"; "Load the project model" = "加载项目模型"; @@ -3101,6 +3102,7 @@ "Run another ACP Agent you installed yourself. It must support signing in with a custom API key gateway." = "运行你自行安装的其他 ACP Agent。它必须支持通过自定义 API 密钥网关登录。"; "Run checked configurations without restarting running services" = "启动勾选的配置,已运行的服务保持运行"; "Run configuration" = "运行配置"; +"Run configuration changed while saving. Reload and try again." = "保存期间运行配置已更改。请重新加载后再试。"; "Run configurations" = "运行配置"; "Run configurations and Maven goals started in Lithe. They always stop when their project or window closes or Lithe quits, together with the processes they started; this only decides whether Lithe lists them and asks first." = "在 Lithe 中启动的运行配置和 Maven 目标。项目或窗口关闭、或退出 Lithe 时,它们总会连同自己启动的进程一起停止;这里只决定 Lithe 是否先列出并询问。"; "Run configurations are unavailable. Enable the execution module and try again." = "运行配置暂不可用,请启用执行模块后重试。"; @@ -3168,6 +3170,7 @@ "Save local changes with" = "本地更改保存方式"; "Save message" = "保存信息"; "Save or discard unsaved files before deleting this item" = "删除此项目之前,请先保存或丢弃未保存的文件"; +"Save provider run configuration to .lithe/run/local.json" = "将提供方运行配置保存到 .lithe/run/local.json"; "Save scope" = "保存范围"; "Save shared instructions" = "保存共享指令"; "Save the active document" = "保存当前文档"; @@ -3181,6 +3184,7 @@ "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git." = "仅为这台电脑上的此项目保存在 .lithe/run/local.json,并已从 Git 中排除。"; "Saved to %@" = "已保存到 %@"; "Saves, connects, and opens the remote folder in this window." = "保存、连接,并在当前窗口打开远程文件夹。"; +"Saving build provider run configurations is unavailable." = "当前无法保存构建提供方运行配置。"; "Saving replaces the current GitHub metadata." = "保存后将替换当前 GitHub 元数据。"; "Saving writes to" = "保存将写入"; "Scala Support" = "Scala 支持"; @@ -3704,6 +3708,7 @@ "The branch now contains the completed plan. Its original history remains available through the recovery reference." = "操作计划已执行完成。仍可通过恢复引用查看分支的原始历史。"; "The breakdown needs Model call statistics (on by default). After turning it on, reconnect and send a message." = "分项需要开启“模型调用统计”(默认开启)。开启后请重新连接并发送消息。"; "The build provider failed to start." = "构建 Provider 启动失败。"; +"The build provider returned an invalid launch plan." = "构建提供方返回了无效的启动计划。"; "The build provider stopped before it was ready." = "构建 Provider 在就绪前已停止。"; "The build tool exited with code %lld." = "构建工具已退出,退出码 %lld。"; "The build tool output is too large or not UTF-8." = "构建工具的输出过大或不是 UTF-8 编码。"; @@ -3840,6 +3845,7 @@ "The repository will be opened in detached HEAD state." = "仓库将以 detached HEAD 状态打开。"; "The request has no secret." = "请求不包含密钥。"; "The response stopped at the model's token limit." = "回复已达到模型的 token 上限,已停止。"; +"The saved provider run configuration is unavailable." = "已保存的提供方运行配置不可用。"; "The selected AI provider has an invalid API URL or model." = "所选 AI 服务商的 API 地址或模型无效。"; "The selected AI provider has no API key." = "所选 AI 服务商没有 API 密钥。"; "The selected background image authorization is invalid." = "所选背景图片的授权无效。"; @@ -6156,6 +6162,7 @@ "native.interface" = "界面"; "native.interrupt" = "中断"; "native.interrupted" = "已中断"; +"native.invalidBuildProviderLaunchPlan" = "构建提供方返回了无效的启动计划。"; "native.invalidGitTagName" = "Git 标签名称无效。"; "native.invalidHookFileGuards" = "Hook 文件校验信息无效。"; "native.invalidIdeApiRequest" = "IDE API 请求无效"; @@ -6398,6 +6405,7 @@ "native.loadMoreCommits" = "加载更多提交"; "native.loadMoreDebuggerVariables" = "加载更多调试变量"; "native.loadProjectModel" = "加载项目模型"; +"native.loadProviderModelBeforeRun" = "请先加载当前构建模型并启用对应提供方,再运行此配置。"; "native.loadTheDependenciesAgain" = "重新加载依赖"; "native.loadTheDependenciesOf" = "加载 %1$@ 的依赖"; "native.loadTheModel" = "加载 %1$@ 的模型"; @@ -7210,6 +7218,8 @@ "native.providerDetails" = "服务商详情"; "native.providerName" = "服务商名称"; "native.providerRequiresAnAPIKey" = "服务商需要 API 密钥"; +"native.providerRunSaveConflict" = "保存期间运行配置已更改。请重新加载后再试。"; +"native.providerRunSavingUnavailable" = "当前无法保存构建提供方运行配置。"; "native.providers" = "供应商管理"; "native.provides" = "提供"; "native.pruneStaleRecords" = "清理陈旧记录"; @@ -7629,6 +7639,7 @@ "native.saveMetadata" = "保存元数据"; "native.saveOrDiscardUnsavedFilesBeforeDeletingThisItem" = "删除此项目之前,请先保存或丢弃未保存的文件"; "native.savePatch" = "保存补丁…"; +"native.saveProviderRunConfiguration" = "将提供方运行配置保存到 .lithe/run/local.json"; "native.saveScope" = "保存范围"; "native.saveSharedInstructions" = "保存共享指令"; "native.saveTheActiveDocument" = "保存当前文档"; @@ -7640,6 +7651,7 @@ "native.savedInLitheRunConfigurationsJsonForTheWholeTeamLocalJDKPathsAreNeverShared" = "保存在 .lithe/run/configurations.json,供整个团队使用。本机 JDK 路径不会共享。"; "native.savedInLitheRunLocalJsonAndExcludedFromGit" = "保存在 .lithe/run/local.json,并已从 Git 中排除。"; "native.savedOnlyForThisProjectOnThisComputerInLitheRunLocalJsonAndExcludedFromGit" = "仅为这台电脑上的此项目保存在 .lithe/run/local.json,并已从 Git 中排除。"; +"native.savedProviderRunUnavailable" = "已保存的提供方运行配置不可用。"; "native.savedTo" = "已保存到 %1$@"; "native.savesConnectsAndOpensTheRemoteFolderInThisWindow" = "保存、连接,并在当前窗口打开远程文件夹。"; "native.savingReplacesTheCurrentGitHubMetadata" = "保存后将替换当前 GitHub 元数据。"; diff --git a/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift b/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift index f78ea3078d..4c733afc2d 100644 --- a/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift @@ -237,9 +237,22 @@ final class IdeCapabilitiesFeatureModel: ObservableObject { guard let configuration = run.configurations.first(where: { $0.id == id && $0.kind != .currentFile }) else { return failed("NOT_FOUND", "Choose an existing project run configuration") } guard !run.moduleSessions.contains(where: { $0.configurationID == id && $0.isRunning }) else { return failed("BUSY", "This configuration is already running") } if run.isRunning && run.lastConfiguration?.id == id { return failed("BUSY", "This configuration is already running") } + let previousPrimary = run.primaryExecutionID let previous = run.moduleSessions.first { $0.configurationID == id }?.executionID await model.startAPIConfiguration(configuration) try check(model, identity, grant: grant) + if configuration.buildProvider != nil { + guard let operationID = run.primaryExecutionID, operationID != previousPrimary, + run.lastConfiguration?.id == id else { + return failed("LAUNCH_FAILED", "Launch did not start. Check the IDE project readiness and any pending launch decision.") + } + if !run.isRunning, let exitCode = run.lastExitCode, exitCode != 0 { + var result = failed("LAUNCH_FAILED", "Launch failed. Read the operation output for details.") + result["operationID"] = "run:" + operationID + return result + } + return ["operationID": "run:" + operationID] + } guard let session = run.moduleSessions.first(where: { $0.configurationID == id }), session.executionID != previous else { return failed("LAUNCH_FAILED", "Launch did not start. Check the IDE project readiness and any pending launch decision.") } if !session.isRunning, let exitCode = session.exitCode, exitCode != 0 { var result = failed("LAUNCH_FAILED", "Launch failed. Read the operation output for details.") diff --git a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift index 9dc1b114d1..089cb93e27 100644 --- a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift @@ -1,5 +1,6 @@ import Foundation import LitheCoreContracts +import LitheModuleAPI /// Typed `build.*` operations. Rust Core owns detection, command expansion, /// and every decoder for both products; this adapter only translates @@ -120,6 +121,37 @@ struct RustBuildSystemOperations: BuildSystemPlanning { return request } + func prepareRunPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { + struct Request: Encodable { + let root: String + let pluginDirectory: String? + let workspaceStateDirectory: String? + let options: [String: ToolingJSONValue] + let run: String + let project: BuildSelection + let target: BuildSelection + let userArguments: [String] + } + let outcome: Outcome = try execute("build.prepareRunPlan", target: target, + request: Request(root: context.root, pluginDirectory: context.pluginDirectory?.path, + workspaceStateDirectory: context.workspaceStateDirectory?.path, options: context.options, run: run, + project: project, target: selectedTarget, userArguments: userArguments)) + guard case .provider(let request) = try outcome.value.plan.outputPlan() else { + throw DeclarativePluginRejection(code: "plugin.build.invalidOutput", message: L10n.string("The build provider returned an invalid launch plan.")) + } + return request + } + + func decodeRunPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, + result: ToolingJSONValue) throws -> BuildRunPreparation { + struct Request: Encodable { let workspaceRoot: String; let root: String; let result: ToolingJSONValue } + struct Reply: Decodable { let launch: BuildRunPreparation } + let outcome: Outcome = try execute("build.decodeRunPreparation", target: target, + request: Request(workspaceRoot: workspaceURL.path, root: root, result: result)) + return outcome.value.launch + } + func decodeTestDiscovery( _ target: BuildSystemTarget, context: BuildPlanContext, result: ToolingJSONValue ) throws -> BuildTestDiscovery { diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 4f837e5b44..d453533235 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -693,6 +693,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { struct Extensions: Codable, Sendable { let maven: Maven? let java: Java? + var build: BuildProviderRunReference? = nil } let id: String let name: String @@ -2192,6 +2193,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let type: String let module: String let mainClass: String + var build: BuildProviderRunReference? = nil } private struct LaunchPlanRequest: Encodable { struct JavaLaunch: Encodable { @@ -2934,6 +2936,13 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } + func createBuildProviderRunConfiguration(at rootURL: URL, reference: BuildProviderRunReference, + name: String) -> Result { + executeResult(command: "runConfig.createUserConfiguration", payload: RunConfigurationCreateUserRequest( + root: rootURL.standardizedFileURL.path, scope: "local", name: name, type: "buildProvider", + module: "", mainClass: "", build: reference)) + } + func createUserRunConfiguration( at rootURL: URL, draft: RunConfigurationDraft diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift index 37f91d78a7..75915c81aa 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift @@ -6,6 +6,16 @@ import LitheModuleAPI /// Run configuration entry points and workspace readiness gates. @MainActor extension AppModel { + func runBuildProviderApplication(entryID: String, request: BuildRunRequest, title: String) { + Task { [weak self] in + guard let self, let identity = currentWorkspaceIdentity, + await saveAllDocuments(), isCurrentWorkspace(identity), + let feature = buildSystemsFeatureIfActive else { return } + feature.runApplication(entryID, request: request, title: title) + showToolWindow(.buildOutput) + } + } + func updateRunningService(_ session: RunSession) { guard let feature = runFeatureIfActive, let identity = currentWorkspaceIdentity else { return } Task { [weak self] in @@ -287,6 +297,9 @@ extension AppModel { return } guard let configuration = runFeature.lastConfiguration else { return } + if runFeature.lastConfiguration?.buildProvider != nil { + guard await saveAllDocuments(), isCurrentWorkspace(identity) else { return } + } if !(await activateLanguageRunExtensionIfNeeded( for: configuration, currentFileURL: runFeature.lastRunFileURL, @@ -389,6 +402,9 @@ extension AppModel { case .stale: return } + if configuration.buildProvider != nil { + guard await saveAllDocuments(), isCurrentWorkspace(identity) else { return } + } guard await activateLanguageRunExtensionIfNeeded( for: configuration, currentFileURL: activeDocument?.url, @@ -539,7 +555,7 @@ extension AppModel { func stopSelectedRun() { if let feature = runFeatureIfActive, let configuration = feature.selectedConfiguration, - configuration.kind != .currentFile { + configuration.kind != .currentFile, configuration.buildProvider == nil { if let session = feature.moduleSessions.first(where: { $0.configurationID == configuration.id }) { feature.stopModule(session) } diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift index e99d7fff9a..7999c5b03e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift @@ -130,6 +130,14 @@ final class MacBuildProviderSessions: BuildProviderRequesting { return try JSONDecoder().decode(BuildProviderExecutionResult.self, from: JSONEncoder().encode(value)) } + func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + let session = try await ownedSession(for: target) + try Task.checkCancellation() + return try await session.buildRequest(method: request.method, params: request.params, + timeout: Self.seconds(timeout), progress: progress) + } + func pluginFunctions(of target: BuildProviderTarget) async throws -> [String] { try await ownedSession(for: target).functionIDs } diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 752fcdec28..6bf9316dde 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -500,6 +500,22 @@ final class MacServiceContainer { toolchainRegistry: runToolchainRegistry, metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner) ) + let runService = RunService( + runtime: runtimeService, + process: MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution), + processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution) }, + fileAccess: MacRunFileAccess(storage: fileStorage), + preferences: MacRunPreferenceStore(store: store), + serverPortParser: javaMavenOperations, + runConfigurationOperations: providedRunConfigurationOperations ?? runConfigurationStore, + executableResolver: executableResolver, + languageProviderCatalog: languagePackRegistry.catalog, + languageRunProviders: languagePackRegistry.runProviders, + extensionRequiredLanguageIDs: runExtensionLanguageIDs, + languageSupports: installedLanguageSupports, + dependencyStore: MacWorkspaceDependencyStore(storage: fileStorage), + javaLaunchArgumentPreparer: rustCore + ) let buildSystemPlugins = pluginStartup.registeredPlugins.filter { !$0.descriptor.buildSystems.isEmpty } let buildSystems = buildSystemPlugins.isEmpty ? nil : BuildSystemService( planner: RustBuildSystemOperations(core: rustCore), @@ -515,6 +531,7 @@ final class MacServiceContainer { ), runner: MacBuildCommandRunner(processRegistry: processRegistry), providers: buildProviderSessions, + runLauncher: runService, settings: { pluginSettings.values(forPlugin: $0) }, sources: { // Enablement is read on every detection, so turning a @@ -543,22 +560,7 @@ final class MacServiceContainer { configurationStore: MacMavenConfigurationStore(storage: fileStorage), dependencyOutputs: MacMavenDependencyOutputStore() ), - run: RunService( - runtime: runtimeService, - process: MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution), - processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution) }, - fileAccess: MacRunFileAccess(storage: fileStorage), - preferences: MacRunPreferenceStore(store: store), - serverPortParser: javaMavenOperations, - runConfigurationOperations: providedRunConfigurationOperations ?? runConfigurationStore, - executableResolver: executableResolver, - languageProviderCatalog: languagePackRegistry.catalog, - languageRunProviders: languagePackRegistry.runProviders, - extensionRequiredLanguageIDs: runExtensionLanguageIDs, - languageSupports: installedLanguageSupports, - dependencyStore: MacWorkspaceDependencyStore(storage: fileStorage), - javaLaunchArgumentPreparer: rustCore - ), + run: runService, tests: LanguageTestService( catalog: languagePackRegistry.catalog, registry: languagePackRegistry.testProviders, diff --git a/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift b/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift index a2aced93ee..e635255745 100644 --- a/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift @@ -122,7 +122,8 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable sourcePath: java?.source, mavenReactorPath: maven?.reactorPath, debugAdapter: value.debug?.adapter, - disabled: value.disabled + disabled: value.disabled, + buildProvider: value.extensions?.build ), options: RunOptions( javaHomePath: java?.homePath ?? "", @@ -319,6 +320,22 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable } } + func saveBuildProviderConfiguration(_ reference: BuildProviderRunReference, name: String, at projectURL: URL) throws -> String { + let root = projectURL.standardizedFileURL + let url = root.appendingPathComponent(".lithe/run/local.json") + let baseline = storage.fileExists(at: url) ? try storage.readData(from: url, options: []) : nil + let result = try core.createBuildProviderRunConfiguration(at: root, reference: reference, name: name).get() + guard let id = result.id, let data = result.document.data(using: .utf8) else { throw MacRunConfigurationStoreError.generationFailed } + try ensureLocalRunIgnored(at: root) + let current = storage.fileExists(at: url) ? try storage.readData(from: url, options: []) : nil + guard current == baseline else { + throw MacRunConfigurationStoreError.invalidConfiguration(L10n.string("Run configuration changed while saving. Reload and try again.")) + } + try writeMutation(RunConfigurationDocumentMutation(configurationID: id, document: data), + to: root.appendingPathComponent(".lithe/run/local.json"), root: root) + return id + } + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { let root = projectURL.standardizedFileURL let fileName = draft.scope == .local ? "local.json" : "configurations.json" diff --git a/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift b/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift index 3962d1e6f1..8753b179e3 100644 --- a/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift +++ b/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift @@ -83,6 +83,9 @@ struct BuildSystemToolWindowView: View { .onChange(of: entries.map(\.id)) { _ in clearSelection(); resetOptionEditor() } .onChange(of: entries.map { feature.testDiscovery($0.id) }) { _ in clearSelection() } .onChange(of: feature.workspaceURL) { _ in clearSelection(); optionEntryID = nil; resetOptionEditor() } + .onReceive(feature.applicationLaunched) { entryID in + if entries.contains(where: { $0.id == entryID }) { model.showToolWindow(.run) } + } .sheet(item: $argumentsPrompt) { prompt in BuildArgumentsSheet(prompt: prompt) { arguments in argumentsPrompt = nil @@ -518,12 +521,19 @@ struct BuildSystemToolWindowView: View { !$0.fromModel && $0.scopes.contains(.target) && Self.matches(target, tags: $0.targetTags) } let testable = declaration.testScopes.contains(.target) && Self.matches(target, tags: declaration.testTargetTags) - if !tasks.isEmpty || testable { + let runs = declaration.providerRuns.filter { Self.matches(target, tags: $0.targetTags) } + if !tasks.isEmpty || testable || !runs.isEmpty { treeNode( id: nodeID, title: target.name, subtitle: target.tags.isEmpty ? nil : target.tags.joined(separator: ", "), iconPath: "expui/nodes/runnableMark.svg" ) { + for run in runs { + leafRow(id: nodeID + ":run:" + run.id, title: run.displayName, help: nil, + iconPath: "expui/run/run.svg", entryID: entry.id, project: project, + runnable: .application(BuildRunRequest(run: run.id, projectID: project.id, targetID: target.id), + title: Self.title(run.displayName, scope: target.name)), target: target) + } if testable { leafRow( id: nodeID + ":tests", title: L10n.string("Run Tests"), help: nil, @@ -929,6 +939,7 @@ struct BuildSystemToolWindowView: View { case task(BuildTaskRequest, title: String, needsArguments: Bool) case tests(BuildTestRequest, title: String) case discoveredTest(String, title: String) + case application(BuildRunRequest, title: String) } private struct Selection: Equatable { @@ -957,6 +968,8 @@ struct BuildSystemToolWindowView: View { feature.runTask(entryID, request: request, title: title) case .tests(let request, let title): feature.runTests(entryID, request: request, title: title) + case .application(let request, let title): + model.runBuildProviderApplication(entryID: entryID, request: request, title: title) case .discoveredTest(let testID, let title): feature.runDiscoveredTest(entryID, testID: testID, title: title) } diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index 24b93f2f93..961086425c 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -39,6 +39,9 @@ struct RunView: View { Text(message).font(.caption).textSelection(.enabled).padding(8) } + if feature.hasBuildProviderRun, let error = feature.configurationSaveError { + LitheBanner(status: .warning, layout: .editor) { Text(verbatim: error).textSelection(.enabled) } + } if !feature.portConflicts.isEmpty { portConflictBanner } @@ -47,9 +50,9 @@ struct RunView: View { configurationNoticeBanner(notice) } - if feature.configurationStatus != .ready { + if feature.configurationStatus != .ready && !feature.hasBuildProviderRun { configurationSetupView - } else if !hasRunnableConfigurations { + } else if !hasRunnableConfigurations || (feature.hasBuildProviderRun && selectedSessionID == nil) { OutputTextView( output: feature.output, searchRoots: feature.sourceSearchRoots, @@ -328,11 +331,11 @@ struct RunView: View { LitheContextMenuItem.separator LitheContextMenuItem.action( "Clear run output", - isEnabled: selectedSessionID == nil || selectedModuleSession != nil + isEnabled: (primaryOutputSelected && primaryRunMatchesSelection) || selectedModuleSession != nil ) { if let session = selectedModuleSession { feature.clearModuleOutput(session) - } else if selectedSessionID == nil { + } else if primaryOutputSelected && primaryRunMatchesSelection { feature.clearOutput() } } @@ -422,6 +425,16 @@ struct RunView: View { .accessibilityLabel(selectedSessionHasRun ? "Restart run" : "Run configuration") .disabled(feature.isLoadingProject) + if feature.hasBuildProviderRun && primaryOutputSelected && primaryRunMatchesSelection { + Button { feature.saveBuildProviderConfiguration() } label: { + Image(systemName: "square.and.arrow.down") + } + .buttonStyle(runActionButtonStyle) + .help("Save provider run configuration to .lithe/run/local.json") + .accessibilityLabel("Save provider run configuration to .lithe/run/local.json") + .disabled(feature.isLoadingProject) + } + Button { if let session = selectedModuleSession { feature.stopModule(session) @@ -531,25 +544,34 @@ struct RunView: View { return runnableConfigurations.first(where: { $0.id == selectedSessionID }) } + private var primaryRunMatchesSelection: Bool { + selectedRunnableConfiguration?.buildProvider == nil + || selectedRunnableConfiguration?.id == feature.lastConfiguration?.id + } + + private var primaryOutputSelected: Bool { + selectedSessionID == nil || selectedRunnableConfiguration?.buildProvider != nil + } + private var selectedSessionHasRun: Bool { - selectedSessionID == nil - ? feature.isRunning || feature.lastExitCode != nil + primaryOutputSelected + ? primaryRunMatchesSelection && (feature.isRunning || feature.lastExitCode != nil || feature.hasBuildProviderRun) : selectedModuleSession != nil } private var selectedSessionIsRunning: Bool { - guard selectedSessionID != nil else { return feature.isRunning } + guard !primaryOutputSelected else { return primaryRunMatchesSelection && feature.isRunning } return selectedModuleSession?.isRunning ?? false } private var selectedOutput: String { - guard selectedSessionID != nil else { return feature.output } + guard !primaryOutputSelected else { return primaryRunMatchesSelection ? feature.output : "" } return selectedModuleSession?.output ?? "" } @ViewBuilder private var selectedConfigurationContent: some View { - if let configuration = selectedRunnableConfiguration { + if let configuration = selectedRunnableConfiguration, configuration.buildProvider == nil { configurationContent(configuration) } else { OutputTextView( diff --git a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift index ae253e1565..a390c285cb 100644 --- a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift @@ -102,6 +102,15 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { public let testScopes: [BuildScope] /// Tags a target must carry to run its tests. public let testTargetTags: [String] + public struct ProviderRun: Equatable, Identifiable, Sendable { + public let id: String + public let displayName: String + public let targetTags: [String] + public init(id: String, displayName: String, targetTags: [String]) { + self.id = id; self.displayName = displayName; self.targetTags = targetTags + } + } + public let providerRuns: [ProviderRun] public let testDiscovery: Bool /// Empty when the build system lists no dependencies. public let dependencyScopes: [BuildScope] @@ -112,13 +121,14 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { public init( id: String, displayName: String, icon: PluginUIIcon? = nil, modelSource: ModelSource?, options: [Option] = [], tasks: [Task], testScopes: [BuildScope], testTargetTags: [String] = [], - dependencyScopes: [BuildScope], declaresProvider: Bool = false, testDiscovery: Bool = false + dependencyScopes: [BuildScope], declaresProvider: Bool = false, testDiscovery: Bool = false, providerRuns: [ProviderRun] = [] ) { self.id = id; self.displayName = displayName; self.icon = icon; self.modelSource = modelSource self.options = options; self.tasks = tasks self.testScopes = testScopes; self.testTargetTags = testTargetTags; self.dependencyScopes = dependencyScopes self.declaresProvider = declaresProvider self.testDiscovery = testDiscovery + self.providerRuns = providerRuns } public func option(_ id: String) -> Option? { @@ -661,6 +671,59 @@ package struct BuildTaskRequest: Equatable, Sendable { } } +/// Model identities are resolved again immediately before preparing a launch. +package struct BuildRunRequest: Equatable, Sendable { + package let run: String + package let projectID: String + package let targetID: String + package var userArguments: [String] = [] + package init(run: String, projectID: String, targetID: String, userArguments: [String] = []) { + self.run = run; self.projectID = projectID; self.targetID = targetID; self.userArguments = userArguments + } +} + +/// A persisted selector. Every launch asks the current Provider to prepare again. +package struct BuildProviderRunReference: Codable, Hashable, Sendable { + package let pluginID: String + package let buildSystemID: String + package let root: String + package let run: String + package let projectID: String + package let targetID: String + package let userArguments: [String] + package init(pluginID: String, buildSystemID: String, root: String, run: String, + projectID: String, targetID: String, userArguments: [String] = []) { + self.pluginID = pluginID; self.buildSystemID = buildSystemID; self.root = root; self.run = run + self.projectID = projectID; self.targetID = targetID; self.userArguments = userArguments + } + package var request: BuildRunRequest { + BuildRunRequest(run: run, projectID: projectID, targetID: targetID, userArguments: userArguments) + } +} + +/// Only Core-validated, portable process data crosses into the Run host. +package struct BuildRunPreparation: Codable, Equatable, Sendable { + package struct Executable: Codable, Equatable, Sendable { + package enum Kind: String, Codable, Sendable { case command, toolchain } + package let kind: Kind + package let value: String + } + package let executable: Executable + package let arguments: [String] + package let workingDirectory: String + package let environment: [String: String] + package var launchPlan: SharedLaunchPlan { + SharedLaunchPlan(executable: executable.kind == .command ? .command(executable.value) : .toolchain(executable.value), + arguments: arguments, workingDirectory: workingDirectory, environment: environment) + } +} + +@MainActor +package protocol BuildRunLaunching: AnyObject { + func launchPreparedRun(_ plan: BuildRunPreparation, workspaceURL: URL, title: String, configuration: RunConfiguration?) throws -> String + func stopPreparedRun(_ operationID: String) +} + package struct BuildTestRequest: Equatable, Sendable { package let project: BuildSelection? package let target: BuildSelection? @@ -972,6 +1035,10 @@ package protocol BuildSystemPlanning: Sendable { -> BuildOutputPlan func testPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTestRequest) throws -> BuildTestPlan + func prepareRunPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest + func decodeRunPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, + result: ToolingJSONValue) throws -> BuildRunPreparation func testDiscoveryPlan(_ target: BuildSystemTarget, context: BuildPlanContext) throws -> BuildProviderRequest func decodeTestDiscovery( _ target: BuildSystemTarget, context: BuildPlanContext, result: ToolingJSONValue @@ -1061,6 +1128,9 @@ package protocol BuildProviderRequesting: AnyObject { to target: BuildProviderTarget, timeout: Duration ) async throws -> ToolingJSONValue + /// Prepares portable launch data, retaining execution progress and cancellation. + func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue /// Runs a typed provider task in the same workspace session. func execute( _ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index a7f14a676d..0afd066775 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -295,6 +295,7 @@ package struct ProjectToolchainSelection: Codable, Equatable, Sendable { } package protocol RunConfigurationOperations: Sendable { + func saveBuildProviderConfiguration(_ reference: BuildProviderRunReference, name: String, at projectURL: URL) throws -> String /// Saves project defaults without modifying any service or requiring generated configurations. func saveProjectToolchain(_ toolchain: ProjectToolchainSelection, at projectURL: URL) throws func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection @@ -354,6 +355,9 @@ package protocol RunConfigurationOperations: Sendable { } package extension RunConfigurationOperations { + func saveBuildProviderConfiguration(_: BuildProviderRunReference, name: String, at _: URL) throws -> String { + throw RunConfigurationOperationFailure(message: L10n.string("Saving build provider run configurations is unavailable.")) + } func inspect( at projectURL: URL, checkFingerprint _: Bool, diff --git a/macos/Sources/LitheCoreContracts/Execution/RunModels.swift b/macos/Sources/LitheCoreContracts/Execution/RunModels.swift index 03d3fb8fcb..f7072ae3c3 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunModels.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunModels.swift @@ -158,8 +158,8 @@ package enum RunConfigurationKind: Hashable, Identifiable, Sendable { .workingDirectory, .arguments, .environment, .javaRuntime, .javaVMArguments, .mavenProfiles, .mavenSkipTests, .jdwpDebug ] - case .process: - return .process + case .process(let provider): + return provider == "build.provider" ? [] : .process } } @@ -245,6 +245,7 @@ package struct RunConfiguration: Identifiable, Hashable, Sendable { package let mavenReactorPath: String? package let debugAdapter: String? package let disabled: Bool + package let buildProvider: BuildProviderRunReference? package var usesCurrentEditorFile: Bool { kind == .currentFile } @@ -263,7 +264,8 @@ package struct RunConfiguration: Identifiable, Hashable, Sendable { sourcePath: String? = nil, mavenReactorPath: String? = nil, debugAdapter: String? = nil, - disabled: Bool = false + disabled: Bool = false, + buildProvider: BuildProviderRunReference? = nil ) { self.id = id self.name = name @@ -275,6 +277,7 @@ package struct RunConfiguration: Identifiable, Hashable, Sendable { self.mavenReactorPath = mavenReactorPath self.debugAdapter = debugAdapter self.disabled = disabled + self.buildProvider = buildProvider } package var systemImage: String { kind.systemImage } diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index d1769981b1..d731b2a1aa 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -1224,6 +1224,13 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let presentation: String? let icon: PluginUIIcon? } + struct ProviderRun: Decodable { + struct Targets: Decodable { let tags: [String] } + let id: String + let source: String? + let name: String? + let fromTargets: Targets? + } struct Test: Decodable { let scopes: [BuildScope] let targetTags: [String]? @@ -1237,6 +1244,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let model: Source? let tasks: [Task] let test: Test? + let run: [ProviderRun] let dependencies: Source? let declaresProvider: Bool @@ -1249,12 +1257,13 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { model = try values.decodeIfPresent(Source.self, forKey: .model) tasks = try values.decodeIfPresent([Task].self, forKey: .tasks) ?? [] test = try values.decodeIfPresent(Test.self, forKey: .test) + run = try values.decodeIfPresent([ProviderRun].self, forKey: .run) ?? [] dependencies = try values.decodeIfPresent(Source.self, forKey: .dependencies) declaresProvider = values.contains(.provider) } private enum CodingKeys: String, CodingKey { - case id, displayName, icon, options, model, tasks, test, dependencies, provider + case id, displayName, icon, options, model, tasks, test, dependencies, provider, run } func declaration() -> DeclarativeBuildSystem { @@ -1284,7 +1293,10 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { // project; the other sources declare their scopes. dependencyScopes: dependencies.map { $0.scopes ?? [.workspace, .project] } ?? [], declaresProvider: declaresProvider, - testDiscovery: test?.discovery ?? false + testDiscovery: test?.discovery ?? false, + providerRuns: run.filter { $0.source == "provider" }.map { + .init(id: $0.id, displayName: $0.name ?? $0.id, targetTags: $0.fromTargets?.tags ?? []) + } ) } } diff --git a/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift b/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift index 4107067102..ca16b1471a 100644 --- a/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift +++ b/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift @@ -30,6 +30,7 @@ package final class BuildSystemsFeatureModel: ObservableObject { package var isRunning: Bool { service.isRunning } package var currentRunID: UUID? { service.currentRunID } package var runEnded: AnyPublisher { service.runEnded.eraseToAnyPublisher() } + package var applicationLaunched: AnyPublisher { service.applicationLaunched.eraseToAnyPublisher() } package var hasActiveWork: Bool { service.hasActiveWork } package var hasBuildSystemPlugins: Bool { service.hasBuildSystemPlugins } package var workspaceURL: URL? { service.workspaceURL } @@ -68,6 +69,10 @@ package final class BuildSystemsFeatureModel: ObservableObject { package func runTests(_ entryID: String, request: BuildTestRequest, title: String) -> UUID? { service.runTests(entryID, request: request, title: title) } + @discardableResult + package func runApplication(_ entryID: String, request: BuildRunRequest, title: String) -> UUID? { + service.runApplication(entryID, request: request, title: title) + } package func testDiscovery(_ entryID: String) -> BuildTestDiscoveryState? { service.testDiscovery(entryID) } package func discoverTests(_ entryID: String) { service.discoverTests(entryID) } package func runDiscoveredTest(_ entryID: String, testID: String, title: String) { diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index cb6eb89b53..504f0ac28c 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -261,20 +261,23 @@ package final class RunFeatureModel: ObservableObject { package var lastRunFileURL: URL? { service.lastRunFileURL } package var lastConfiguration: RunConfiguration? { service.lastConfiguration } /// Project configurations share session identity across toolbar and log selection. - /// Current File alone uses the primary output stream. + /// Provider applications and Current File use the primary output stream. package var selectedProjectSessionID: String? { guard let configuration = selectedConfiguration, configuration.kind != .currentFile else { return nil } return configuration.id } package var isSelectedConfigurationRunning: Bool { guard let configuration = selectedConfiguration else { return false } - if configuration.kind != .currentFile { + if configuration.kind != .currentFile && configuration.buildProvider == nil { return moduleSessions.contains { $0.configurationID == configuration.id && $0.isRunning } } return isRunning && lastConfiguration?.id == configuration.id } package var isLoadingProject: Bool { service.isLoadingProject } - package var isRunning: Bool { service.isRunning } + package var isRunning: Bool { service.isRunning || service.isPreparingBuildRun } + package var hasBuildProviderRun: Bool { service.hasBuildProviderRun } + @discardableResult + package func saveBuildProviderConfiguration() -> Bool { service.saveBuildProviderConfiguration() } package var runningTitle: String? { service.runningTitle } package var output: String { service.output } package var lastExitCode: Int32? { service.lastExitCode } diff --git a/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift b/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift index 4cd9a5fde3..35ad2a6ff2 100644 --- a/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift +++ b/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift @@ -36,6 +36,13 @@ package final class ExecutionFeatureGraph: NSObject, ExecutionServiceGraph { run.configureMavenContextProvider { [weak maven] in maven?.launchContext } + run.configureBuildProviderRuns(start: { [weak buildSystems] configuration, workspace in + buildSystems?.runProviderConfiguration(configuration, workspace: workspace) + }, cancel: { [weak buildSystems] id in buildSystems?.cancelRun(id) }) + buildSystems?.runEnded.sink { [weak run] ended in + let message: String? = if case .failed(let message) = ended.state { message } else { nil } + run?.finishBuildPreparation(ended.runID, message: message) + }.store(in: &activityObservers) maven.onProjectReloaded = { [weak run] workspace, project in run?.acceptMavenProject(project, at: workspace) } diff --git a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift index f1b4472ed1..d4978dce9f 100644 --- a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift +++ b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift @@ -134,6 +134,8 @@ package final class BuildSystemService: ObservableObject { private let stateLocator: any BuildSystemStateLocating private let runner: any BuildCommandRunning private let providers: (any BuildProviderRequesting)? + private let runLauncher: (any BuildRunLaunching)? + private var preparedApplication: (source: BuildSystemPluginSource, operationID: String)? private let settings: @MainActor (String) -> [String: ToolingJSONValue] private let waitForRedetection: @Sendable () async throws -> Void private let sources: @MainActor () -> [BuildSystemPluginSource] @@ -152,6 +154,7 @@ package final class BuildSystemService: ObservableObject { private var fileRevision = 0 private var providerConfigurationRevision = 0 private var providerRunID: UUID? + private var preparingApplicationID: UUID? private var detectionTask: Task? private var redetectTask: Task? private var runTask: Task? @@ -196,6 +199,7 @@ package final class BuildSystemService: ObservableObject { stateLocator: any BuildSystemStateLocating, runner: any BuildCommandRunning, providers: (any BuildProviderRequesting)? = nil, + runLauncher: (any BuildRunLaunching)? = nil, settings: @escaping @MainActor (String) -> [String: ToolingJSONValue] = { _ in [:] }, waitForRedetection: @escaping @Sendable () async throws -> Void = { try await Task.sleep(for: .milliseconds(500)) @@ -208,6 +212,7 @@ package final class BuildSystemService: ObservableObject { self.stateLocator = stateLocator self.runner = runner self.providers = providers + self.runLauncher = runLauncher self.settings = settings self.waitForRedetection = waitForRedetection self.sources = sources @@ -216,6 +221,7 @@ package final class BuildSystemService: ObservableObject { /// Every run that ends, stopped or superseded runs included, so a caller /// that started one can tell how its own run ended. package let runEnded = PassthroughSubject() + package let applicationLaunched = PassthroughSubject() /// The task or test run in progress; nil when none runs. package var currentRunID: UUID? { runID } @@ -387,12 +393,14 @@ package final class BuildSystemService: ObservableObject { /// Ends what a now disabled plugin started and detects again with the /// plugins enabled now, so its instances leave the tool window. package func pluginsChanged() { + if preparingApplicationID != nil { stopRun() } if let runEntryID, testDiscoveries[runEntryID] == .loading { stopRun() } testDiscoveries = [:] discoverySnapshots = [:] let enabled = Set(sources().map(\.pluginID)) let removed = Set(entries.filter { !enabled.contains($0.instance.pluginID) }.map(\.id)) if let runEntryID, removed.contains(runEntryID) { stopRun() } + if let preparedApplication, !sources().contains(preparedApplication.source) { stopPreparedApplication() } for key in Array(modelTasks.keys) where removed.contains(key) { modelTasks.removeValue(forKey: key)?.cancel() } @@ -413,6 +421,7 @@ package final class BuildSystemService: ObservableObject { // A load can read a subproject file before the model lists it, so any // reported change invalidates a model load that is in flight. fileRevision += 1 + if preparingApplicationID != nil { stopRun() } let root = workspaceURL.path.hasSuffix("/") ? workspaceURL.path : workspaceURL.path + "/" let changed = Set(urls.compactMap { url -> String? in let path = url.standardizedFileURL.path @@ -554,6 +563,7 @@ package final class BuildSystemService: ObservableObject { /// may answer from its own snapshot. package func loadModel(_ entryID: String, reason: BuildLoadReason = .initial) { guard let workspaceURL, let entry = entry(entryID) else { return } + if preparingApplicationID != nil, runEntryID == entryID { stopRun() } switch entry.instance.modelSource { case .static?, .hostIntegration?: detect() @@ -671,6 +681,77 @@ package final class BuildSystemService: ObservableObject { // MARK: Tasks and tests + /// Saved references resolve against the currently enabled plugin and model. + @discardableResult + package func runProviderConfiguration(_ configuration: RunConfiguration, workspace: URL) -> UUID? { + guard workspace.standardizedFileURL == workspaceURL, let reference = configuration.buildProvider, + let entry = entries.first(where: { $0.instance.pluginID == reference.pluginID + && $0.instance.buildSystemID == reference.buildSystemID && $0.instance.root == reference.root }) else { return nil } + return runApplication(entry.id, request: reference.request, title: configuration.name, configuration: configuration) + } + + package func cancelRun(_ id: UUID) { if runID == id { stopRun() } } + + /// The provider owns build/launch facts; the Run host owns the application. + @discardableResult + package func runApplication(_ entryID: String, request: BuildRunRequest, title: String, configuration: RunConfiguration? = nil) -> UUID? { + guard let workspaceURL, let entry = entry(entryID), let model = entry.model, + !entry.isReloadRequired, entry.modelState == .loaded, + let project = model.projects.first(where: { $0.id == request.projectID }), + let target = model.targets.first(where: { $0.id == request.targetID && $0.projectID == project.id }), + entry.declaration.providerRuns.contains(where: { $0.id == request.run }) else { return nil } + let origin = configuration ?? RunConfiguration(id: "build:" + entryID + ":" + request.run + ":" + request.targetID, + name: title, kind: .process(provider: "build.provider"), execution: .application, modulePath: nil, mainClass: nil, + buildProvider: BuildProviderRunReference(pluginID: entry.instance.pluginID, buildSystemID: entry.instance.buildSystemID, + root: entry.instance.root, run: request.run, projectID: request.projectID, targetID: request.targetID, + userArguments: request.userArguments)) + stopPreparedApplication() + let sourceRevision = fileRevision + return startRun(entryID, title: title) { service, run in + guard let providers = service.providers, let launcher = service.runLauncher else { + throw BuildSystemServiceError(Self.providerUnsupportedMessage) + } + let context = try service.planContext(entry, workspaceURL: workspaceURL) + let planned = try service.planner.prepareRunPlan(entry.target, context: context, run: request.run, + project: BuildSelection(project: project), selectedTarget: BuildSelection(target: target), + userArguments: request.userArguments) + let validConfiguration = service.runValidity(.provider(planned), entry: entry, workspaceURL: workspaceURL, run: run) + let isValid: @MainActor @Sendable () -> Bool = { [weak service] in + guard let service else { return false } + return validConfiguration() && service.fileRevision == sourceRevision + && service.entry(entryID)?.model == model && service.entry(entryID)?.modelState == .loaded + && service.entry(entryID)?.isReloadRequired == false + } + service.providerRunID = run.id + service.preparingApplicationID = run.id + defer { if service.preparingApplicationID == run.id { service.preparingApplicationID = nil } } + defer { if service.providerRunID == run.id { service.providerRunID = nil } } + let resolved = try await service.providerRequestWithTool(planned, entry: entry, workspaceURL: workspaceURL, run: run) + guard isValid() else { throw CancellationError() } + let result = try await providers.prepareRun(resolved, on: Self.providerTarget(entry, workspaceURL: workspaceURL), + timeout: .seconds(3600)) { [weak service] progress in + guard let service, isValid() else { return } + if let output = progress.output { service.appendOutput(output, in: service.outputDirectory(entry)) } + else if let message = progress.message { service.appendOutput(message + "\n", in: service.outputDirectory(entry)) } + } + guard isValid() else { throw CancellationError() } + let planner = service.planner + let preparation = try await Task.detached(priority: .userInitiated) { + try planner.decodeRunPreparation(entry.target, workspaceURL: workspaceURL, root: context.root, result: result) + }.value + guard isValid() else { throw CancellationError() } + let operationID = try launcher.launchPreparedRun(preparation, workspaceURL: workspaceURL, title: title, configuration: origin) + service.preparedApplication = (entry.source, operationID) + service.applicationLaunched.send(entryID) + return 0 + } + } + + private func stopPreparedApplication() { + if let preparedApplication { runLauncher?.stopPreparedRun(preparedApplication.operationID) } + preparedApplication = nil + } + /// Discovery is user-triggered and independent of a plugin page. Cached /// selectors are visible/runnable only for their exact configuration. package func testDiscovery(_ entryID: String) -> BuildTestDiscoveryState? { @@ -817,6 +898,7 @@ package final class BuildSystemService: ObservableObject { runTask?.cancel() runTask = nil providerRunID = nil + preparingApplicationID = nil runID = nil runEntryID = nil if runState == .running { @@ -827,6 +909,7 @@ package final class BuildSystemService: ObservableObject { /// Ends every process this service started. package func stop() { + stopPreparedApplication() detectionTask?.cancel(); detectionTask = nil detectionID = nil redetectTask?.cancel(); redetectTask = nil diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index fbefc8f187..110205ee1b 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -5,7 +5,7 @@ import LitheCoreContracts import LitheModuleAPI @MainActor -package final class RunService: ObservableObject { +package final class RunService: ObservableObject, BuildRunLaunching { @Published package private(set) var configurations: [RunConfiguration] = [.currentFile] package private(set) var defaultConfigurationID: String? @Published package var selectedConfigurationID = RunConfiguration.currentFileID { @@ -41,6 +41,68 @@ package final class RunService: ObservableObject { @Published package private(set) var dependencyRevision = 0 @Published package private(set) var dependencyConfigurationSaveError: String? + @Published package private(set) var isPreparingBuildRun = false + private var buildPreparationID: UUID? + private var buildRunWorkspace: URL? + private var buildRunHandler: (@MainActor (RunConfiguration, URL) -> UUID?)? + private var cancelBuildRun: (@MainActor (UUID) -> Void)? + package var hasBuildProviderRun: Bool { lastRunConfiguration?.buildProvider != nil } + + package func configureBuildProviderRuns(start: @escaping @MainActor (RunConfiguration, URL) -> UUID?, + cancel: @escaping @MainActor (UUID) -> Void) { + buildRunHandler = start; cancelBuildRun = cancel + } + + package func finishBuildPreparation(_ id: UUID, message: String?) { + guard buildPreparationID == id else { return } + buildPreparationID = nil + isPreparingBuildRun = false + runningTitle = nil + if let message { fail(message) } + } + + private func startBuildProviderRun(_ configuration: RunConfiguration) { + let workspace = projectURL ?? buildRunWorkspace + let available = configurations.contains { $0.id == configuration.id && $0.buildProvider == configuration.buildProvider } + || (lastRunConfiguration == configuration && buildRunWorkspace == workspace) + stop() + primaryExecutionID = UUID().uuidString + configurationSaveError = nil + output = ""; lastExitCode = nil + lastRunConfiguration = configuration + runningTitle = configuration.name + isPreparingBuildRun = true + guard available, let workspace, let id = buildRunHandler?(configuration, workspace) else { + isPreparingBuildRun = false + fail(L10n.string("Load the current build model and enable its provider before running this configuration.")) + return + } + buildPreparationID = id + primaryExecutionID = id.uuidString + } + + @discardableResult + package func saveBuildProviderConfiguration() -> Bool { + guard let configuration = lastRunConfiguration, let reference = configuration.buildProvider, + let workspace = buildRunWorkspace ?? projectURL else { return false } + configurationSaveError = nil + do { + let id = try runConfigurationOperations.saveBuildProviderConfiguration(reference, name: configuration.name, at: workspace) + let resolution = try resolveWithServiceToolchains(operations: runConfigurationOperations, projectURL: workspace, + mavenProject: mavenProject, preferredConfigurationID: id) + guard let saved = resolution.configurations.first(where: { $0.configuration.id == id }), + saved.configuration.buildProvider == reference else { + throw RunConfigurationEditorSaveFailure(stage: .reload, + message: L10n.string("The saved provider run configuration is unavailable.")) + } + configurationStatus = .ready + configurationDiagnostics = resolution.diagnostics + apply(resolution.configurations, projectToolchain: resolution.projectToolchain, preferredConfigurationID: id) + lastRunConfiguration = saved.configuration + return true + } catch { configurationSaveError = error.localizedDescription; return false } + } + private let process: any StreamingProcess private let processFactory: () -> any StreamingProcess private let fileAccess: any RunFileAccess @@ -128,20 +190,25 @@ package final class RunService: ObservableObject { }) dependencyWriter = WorkspaceDependencyWriter(store: dependencyStore) self.executableResolver = executableResolver + } + + /// Platform callbacks may already be queued when a process is replaced. + /// Retain the operation that produced them through the main-actor hop. + private func configurePrimaryProcess(operationID: String) { process.onOutput = { [weak self] chunk in Task { @MainActor [weak self] in - self?.append(chunk) + guard let self, self.activeOperationID == operationID else { return } + self.append(chunk) } } process.onTermination = { [weak self] exitCode in Task { @MainActor [weak self] in - self?.finishProcess(exitCode: exitCode) + guard let self, self.activeOperationID == operationID else { return } + self.finishProcess(exitCode: exitCode) } } process.onStateChange = { [weak self] event in - Task { @MainActor [weak self] in - self?.consumeLifecycle(event) - } + Task { @MainActor [weak self] in self?.consumeLifecycle(event) } } } @@ -407,6 +474,15 @@ package final class RunService: ObservableObject { let modelRevision = mavenModelRevision projectLoadID = loadID let workspace = projectURL.standardizedFileURL + if let buildRunWorkspace, buildRunWorkspace != workspace { + stop() + self.buildRunWorkspace = nil + if lastRunConfiguration?.buildProvider != nil { + lastRunConfiguration = nil + primaryExecutionID = nil + output = ""; lastExitCode = nil + } + } defaultConfigurationID = nil isLoadingProject = true projectLoadState = .loading(workspace: workspace) @@ -836,6 +912,68 @@ package final class RunService: ObservableObject { run(configuration: configuration, currentFileURL: currentFileURL, javaLaunch: javaLaunch) } + /// A build provider has already prepared and Core has validated the launch. + /// Reuse the native resolver/process/argument lease without a shadow run configuration. + package func launchPreparedRun(_ preparation: BuildRunPreparation, workspaceURL: URL, title: String, configuration: RunConfiguration? = nil) throws -> String { + let plan = preparation.launchPlan + let workspace = workspaceURL.standardizedFileURL + if let projectURL, projectURL != workspace { throw CancellationError() } + let directory = workspace.appendingPathComponent(plan.workingDirectory, isDirectory: true).standardizedFileURL + guard (directory == workspace || directory.path.hasPrefix(workspace.path + "/")), + fileAccess.isDirectory(at: directory) else { + throw RunConfigurationOperationFailure(message: L10n.string("The build provider returned an invalid launch plan.")) + } + // Read the workspace's actual defaults even before Run configurations + // are generated; a cached selection may belong to a different workspace. + let inspection = runConfigurationOperations.inspect(at: workspace, checkFingerprint: false, javaEntrypoints: nil) + if case .invalid(let message) = inspection.status { + throw RunConfigurationOperationFailure(message: message) + } + let toolchain = inspection.projectToolchain ?? ProjectToolchainSelection() + let options = RunOptions(javaHomePath: toolchain.javaHomePath, + mavenExecutablePath: toolchain.mavenExecutablePath, mavenJavaHomePath: toolchain.mavenJavaHomePath) + let resolved = try executableResolver.resolve(plan, projectURL: workspace, options: options) + let arguments = try prepareJavaLaunch(executablePath: resolved.executableURL.path, arguments: plan.arguments) + // The preparation is handing off successfully, so Stop must not cancel + // the provider request that is currently returning this application. + let runIdentity = buildPreparationID == nil ? nil : primaryExecutionID + buildPreparationID = nil + isPreparingBuildRun = false + stop() + output = "" + lastExitCode = nil + lastRunConfiguration = configuration + buildRunWorkspace = workspace + selectedConfigurationID = configurations.first(where: { $0.id == configuration?.id })?.id ?? RunConfiguration.currentFileID + lastCurrentFileURL = nil + let operationID = UUID().uuidString + primaryExecutionID = runIdentity ?? operationID + activeOperationID = operationID + configurePrimaryProcess(operationID: operationID) + activeLaunchArgumentLease = arguments.lease + runningTitle = title + isRunning = true + append("$ " + resolved.executableURL.lastPathComponent + " " + plan.arguments.joined(separator: " ") + "\n\n") + do { + try process.start(ProcessRequest(operationID: operationID, executablePath: resolved.executableURL.path, + arguments: arguments.arguments, workingDirectory: directory.path, environment: resolved.environment)) + return operationID + } catch { + activeLaunchArgumentLease = nil + activeOperationID = nil + isRunning = false + runningTitle = nil + lastExitCode = 1 + append(error.localizedDescription + "\n") + throw error + } + } + + package func stopPreparedRun(_ operationID: String) { + guard activeOperationID == operationID else { return } + stop() + } + package func restart(javaLaunch: JavaDebugLaunchTarget? = nil) { guard let lastRunConfiguration else { return } run( @@ -850,6 +988,7 @@ package final class RunService: ObservableObject { currentFileURL: URL?, javaLaunch: JavaDebugLaunchTarget? = nil ) { + if configuration.buildProvider != nil { startBuildProviderRun(configuration); return } stop() output = "" lastExitCode = nil @@ -974,6 +1113,7 @@ package final class RunService: ObservableObject { let operationID = UUID().uuidString activeOperationID = operationID + configurePrimaryProcess(operationID: operationID) // The main process starts only after every compile step exits zero, so a // standalone Java file is compiled with `javac` before `java `. let startMain: @MainActor () -> Void = { [weak self] in @@ -1040,6 +1180,7 @@ package final class RunService: ObservableObject { _ configuration: RunConfiguration, javaLaunch: JavaDebugLaunchTarget? = nil ) { + if configuration.buildProvider != nil { startBuildProviderRun(configuration); return } guard configuration.kind != .currentFile else { return } stopModule(sessionID: configuration.id) startModuleSession(configuration, javaLaunch: javaLaunch) @@ -1085,6 +1226,10 @@ package final class RunService: ObservableObject { } package func stop() { + let preparation = buildPreparationID + buildPreparationID = nil + isPreparingBuildRun = false + if let preparation { cancelBuildRun?(preparation) } activeLanguageExecutionSession?.stop() activeLanguageExecutionSession = nil activePreLaunchProcess?.stop() @@ -1101,6 +1246,7 @@ package final class RunService: ObservableObject { stopAllServices() projectLoadID = UUID() projectURL = nil + buildRunWorkspace = nil projectLoadState = .idle selectedConfigurationIDsByProject = [:] projectFiles = [] diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 9000cf750b..b64bfe86d4 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -504,7 +504,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { /// not running it, so an entry is added only with its host support. public static let hostCapabilities: [String: [Int]] = [ "backend": [1], - "buildProviders": [1, 2, 3], + "buildProviders": [1, 2, 3, 4], "buildSystems": [1, 2], "debugAdapters": [1], "executableResolution": [1], "executionProviders": [1], "languageServers": [1, 2], diff --git a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift index 5e82672e36..9f4fd47c03 100644 --- a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift @@ -1343,6 +1343,122 @@ struct BuildSystemServiceTests { }) } + @Test + func providerRunHandsValidatedPlanToNativeRunAndStopsOnDisable() async throws { + let (planner, providers, launcher, harness) = try Self.runPreparationHarness() + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + try #require(harness.service.runApplication(Self.instanceID, request: Self.runRequest, title: "Main") != nil) + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(providers.sent.map(\.request.method) == ["lithe/build/prepareRun"]) + #expect(launcher.launched.count == 1) + #expect(launcher.launched.first?.arguments == ["example.Main", "a b"]) + #expect(planner.diagnosticOutputs.isEmpty) + harness.sources = [] + // Detection may already have removed the model before plugin disable + // is delivered. Process ownership must survive that disappearance. + harness.service.detect() + try #require(await harness.waitForEntries { $0.isEmpty }) + harness.service.pluginsChanged() + #expect(launcher.stopped == ["prepared-app"]) + } + + @Test + func savedRunRoutesOnlyToItsEnabledWorkspaceInstance() async throws { + let (_, providers, launcher, harness) = try Self.runPreparationHarness() + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + let reference = BuildProviderRunReference(pluginID: "dev.example", buildSystemID: "fixture", root: "app", + run: "application", projectID: "p", targetID: "t", userArguments: ["a b"]) + let configuration = RunConfiguration(id: "user:main", name: "Saved Main", kind: .process(provider: "build.provider"), + modulePath: nil, mainClass: nil, buildProvider: reference) + #expect(harness.service.runProviderConfiguration(configuration, workspace: URL(fileURLWithPath: "/other")) == nil) + try #require(harness.service.runProviderConfiguration(configuration, workspace: Self.workspace) != nil) + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(launcher.lastConfiguration == configuration) + #expect(providers.sent.count == 1) + harness.sources = [] + harness.service.pluginsChanged() + try #require(await harness.waitForEntries { $0.isEmpty }) + #expect(harness.service.runProviderConfiguration(configuration, workspace: Self.workspace) == nil) + #expect(providers.sent.count == 1) + } + + /// A reply from a cancelled or outdated build must never start an application. + @Test(arguments: ["stop", "workspace", "options", "disable", "source", "reload"]) + func providerRunLateReplyCannotLaunch(change: String) async throws { + let (_, providers, launcher, harness) = try Self.runPreparationHarness() + let release = BuildTestGate() + providers.replyGate = release + defer { release.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.runApplication(Self.instanceID, request: Self.runRequest, title: "Main") + try #require(await providers.requestStarted.waitUntilOpen()) + switch change { + case "workspace": harness.service.reset(workspaceURL: URL(fileURLWithPath: "/other")) + case "options": harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + case "disable": harness.sources = []; harness.service.pluginsChanged() + case "source": harness.service.workspaceFilesChanged([Self.workspace.appendingPathComponent("app/Main.java")]) + case "reload": harness.service.loadModel(Self.instanceID, reason: .refresh) + default: harness.service.stopRun() + } + providers.executionProgress?(BuildProviderProgress(output: "stale launch output")) + release.open() + try #require(await providers.requestFinished.waitUntilOpen()) + #expect(await launcher.didLaunch.waitUntilOpen(timeout: .milliseconds(200)) == false) + #expect(launcher.launched.isEmpty) + #expect(!harness.service.output.contains("stale launch output")) + } + + @Test + func providerRunCancellationDuringCoreDecodePreventsHandoff() async throws { + let (planner, _, launcher, harness) = try Self.runPreparationHarness() + let gate = GatedBuildDecode() + planner.runPreparationGate = gate + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.runApplication(Self.instanceID, request: Self.runRequest, title: "Main") + try #require(await gate.entered.waitUntilOpen()) + harness.service.stopRun() + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + #expect(await launcher.didLaunch.waitUntilOpen(timeout: .milliseconds(200)) == false) + } + + @Test + func providerRunRejectsStaleTargetAndPreparationFailure() async throws { + let (_, providers, launcher, harness) = try Self.runPreparationHarness() + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + #expect(harness.service.runApplication(Self.instanceID, + request: BuildRunRequest(run: "application", projectID: "p", targetID: "gone"), title: "Main") == nil) + providers.failure = BuildSystemServiceTestsError(message: "build failed") + harness.service.runApplication(Self.instanceID, request: Self.runRequest, title: "Main") + try #require(await harness.waitForRunState { if case .failed = $0 { true } else { false } }) + #expect(launcher.launched.isEmpty) + #expect(harness.service.output.contains("build failed")) + } + + private static let runRequest = BuildRunRequest(run: "application", projectID: "p", targetID: "t") + private static func runPreparationHarness() throws -> (FakeBuildPlanner, FakeBuildProviders, FakeBuildRunLauncher, Harness) { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try instance(modelSource: "provider", model: + #"{"projects":[{"id":"p","name":"app","path":"app"}],"targets":[{"id":"t","projectID":"p","name":"Main","path":"app","tags":["application"]}]}"#)]) + planner.providerRequest = providerRequest(method: "lithe/build/prepareRun") + let providers = FakeBuildProviders() + providers.reply = try decode(ToolingJSONValue.self, + #"{"executable":{"kind":"command","value":"java"},"arguments":["example.Main","a b"],"workingDirectory":"app","environment":{}}"#) + let launcher = FakeBuildRunLauncher() + let source = fixtureSource(providerRuns: [.init(id: "application", displayName: "Run", targetTags: ["application"])]) + return (planner, providers, launcher, Harness(planner: planner, sources: [source], providers: providers, runLauncher: launcher)) + } + @Test(arguments: [Int32(0), Int32(7)]) func providerTaskRunsWithoutPageOrHostCommand(exitCode: Int32) async throws { let planner = FakeBuildPlanner() @@ -1995,14 +2111,14 @@ struct BuildSystemServiceTests { } } -private func fixtureSource(pluginID: String = "dev.example", testDiscovery: Bool = false) -> BuildSystemPluginSource { +private func fixtureSource(pluginID: String = "dev.example", testDiscovery: Bool = false, providerRuns: [DeclarativeBuildSystem.ProviderRun] = []) -> BuildSystemPluginSource { BuildSystemPluginSource( pluginID: pluginID, manifest: Data("{}".utf8), packageURL: URL(fileURLWithPath: "/plugins/\(pluginID)"), buildSystems: [DeclarativeBuildSystem( id: "fixture", displayName: "Fixture", modelSource: .command, tasks: [], - testScopes: [], dependencyScopes: [], testDiscovery: testDiscovery + testScopes: [], dependencyScopes: [], testDiscovery: testDiscovery, providerRuns: providerRuns )] ) } @@ -2030,6 +2146,7 @@ private final class Harness { runner: any BuildCommandRunning = FakeBuildRunner(), sources: [BuildSystemPluginSource] = [fixtureSource()], providers: (any BuildProviderRequesting)? = nil, + runLauncher: (any BuildRunLaunching)? = nil, settings: @escaping @MainActor (String) -> [String: ToolingJSONValue] = { _ in [:] }, waitForRedetection: @escaping @Sendable () async throws -> Void = {} ) { @@ -2042,6 +2159,7 @@ private final class Harness { stateLocator: stateLocator, runner: runner, providers: providers, + runLauncher: runLauncher, settings: settings, waitForRedetection: waitForRedetection, sources: { enabledSources.value } @@ -2207,6 +2325,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { var testPlanDeclaresResults = false var discovery: BuildTestDiscovery? var discoveryGate: GatedBuildDecode? + var runPreparationGate: GatedBuildDecode? var testRequests: [BuildTestRequest] = [] var optionsResolution: BuildOptionsResolution? var loadedOptionsResolution: BuildOptionsResolution? @@ -2242,6 +2361,10 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { get { access { $0.providerRequest } } set { access { $0.providerRequest = newValue } } } + var runPreparationGate: GatedBuildDecode? { + get { access { $0.runPreparationGate } } + set { access { $0.runPreparationGate = newValue } } + } var decodedDependencyOutputs: [BuildCommandOutput] { access { $0.decodedDependencyOutputs } } var diagnostics: [BuildDiagnostic] { get { access { $0.diagnostics } } @@ -2426,6 +2549,22 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { ) } + func prepareRunPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { + guard let providerRequest else { throw CancellationError() } + return providerRequest + } + + func decodeRunPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, + result: ToolingJSONValue) throws -> BuildRunPreparation { + if let gate = access({ let gate = $0.runPreparationGate; $0.runPreparationGate = nil; return gate }) { + gate.entered.open() + guard gate.waitForRelease() else { throw GatedDecodeError.releaseTimedOut } + gate.finished.open() + } + return try JSONDecoder().decode(BuildRunPreparation.self, from: JSONEncoder().encode(result)) + } + func testDiscoveryPlan(_ target: BuildSystemTarget, context: BuildPlanContext) throws -> BuildProviderRequest { guard let providerRequest else { throw CancellationError() } return providerRequest @@ -2785,6 +2924,12 @@ private final class FakeBuildProviders: BuildProviderRequesting { return executionResult } + func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + executionProgress = progress + return try await send(request, to: target, timeout: timeout) + } + func retainProviders(_ keep: (BuildProviderTarget) -> Bool) { for target in running where !keep(target) { running.remove(target) @@ -2797,3 +2942,18 @@ private struct BuildSystemServiceTestsError: LocalizedError { let message: String var errorDescription: String? { message } } + +@MainActor +private final class FakeBuildRunLauncher: BuildRunLaunching { + var lastConfiguration: RunConfiguration? + var launched: [BuildRunPreparation] = [] + let didLaunch = BuildTestGate() + var stopped: [String] = [] + func launchPreparedRun(_ plan: BuildRunPreparation, workspaceURL: URL, title: String, configuration: RunConfiguration?) throws -> String { + lastConfiguration = configuration + launched.append(plan) + didLaunch.open() + return "prepared-app" + } + func stopPreparedRun(_ operationID: String) { stopped.append(operationID) } +} diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index 1baaf43d5f..a1d1e69577 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -8,6 +8,121 @@ import Testing @MainActor struct ExecutionModuleTests { + @Test + func providerLaunchUsesNativeProcessAndOldOwnerCannotStopReplacement() async throws { + let process = TestStreamingProcess() + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let resolver = TestExecutableResolver() + let service = RunService(runtime: TestRuntime(), process: process, processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(directories: [root.appendingPathComponent("app", isDirectory: true)]), + preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), + runConfigurationOperations: TestRunConfigurationOperations(toolchain: ProjectToolchainSelection(javaHomePath: "/fixture/jdk"), + rejectsResolutionWithoutGeneratedDocument: true), + executableResolver: resolver, + languageProviderCatalog: .compatibilityFallback, languageRunProviders: .standard(catalog: .compatibilityFallback)) + defer { service.reset() } + let launch = try JSONDecoder().decode(BuildRunPreparation.self, from: Data( + #"{"executable":{"kind":"command","value":"java"},"arguments":["Main","a b"],"workingDirectory":"app","environment":{}}"#.utf8)) + let old = try service.launchPreparedRun(launch, workspaceURL: root, title: "Provider Main") + let oldOutput = process.onOutput + let oldTermination = process.onTermination + let current = try service.launchPreparedRun(launch, workspaceURL: root, title: "Replacement") + let request = try #require(process.startRequests.last) + #expect(request.operationID == current) + #expect(request.arguments == ["Main", "a b"]) + #expect(request.workingDirectory == "/workspace/app") + #expect(service.primaryExecutionID == current) + #expect(resolver.lastOptions?.javaHomePath == "/fixture/jdk") + #expect(service.lastConfiguration == nil) + let received = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let observation = service.$output.sink { if $0.contains("replacement output") { received.continuation.yield(()) } } + defer { observation.cancel(); received.continuation.finish() } + oldOutput?("stale output") + oldTermination?(7) + process.onOutput?("replacement output") + try await awaitSignal(received.stream) + #expect(!service.output.contains("stale output")) + #expect(service.lastExitCode == nil) + service.stopPreparedRun(old) + #expect(service.isRunning) + service.stopPreparedRun(current) + #expect(!service.isRunning) + #expect(!process.isRunning) + } + + @Test(arguments: [false, true]) + func providerLaunchFailureLeavesNoRunningProcess(invalidDirectory: Bool) throws { + let process = TestStreamingProcess() + process.startFailure = ReloadTestError.failed + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let service = RunService(runtime: TestRuntime(), process: process, processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(directories: invalidDirectory ? [] : [root]), + preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), + runConfigurationOperations: TestRunConfigurationOperations(), executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, languageRunProviders: .standard(catalog: .compatibilityFallback)) + defer { service.reset() } + let launch = try JSONDecoder().decode(BuildRunPreparation.self, from: Data( + #"{"executable":{"kind":"command","value":"java"},"arguments":[],"workingDirectory":".","environment":{}}"#.utf8)) + #expect(throws: (any Error).self) { _ = try service.launchPreparedRun(launch, workspaceURL: root, title: "Main") } + #expect(!service.isRunning) + #expect(!process.isRunning) + } + + @Test + func savedProviderRunRepreparesAndStopCancelsOnlyItsPreparation() async throws { + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let process = TestStreamingProcess() + let reference = BuildProviderRunReference(pluginID: "dev.example", buildSystemID: "fixture", root: ".", + run: "application", projectID: "p", targetID: "t", userArguments: ["a b"]) + let configuration = RunConfiguration(id: "user:provider", name: "Main", kind: .process(provider: "build.provider"), + modulePath: nil, mainClass: nil, buildProvider: reference) + let service = RunService(runtime: TestRuntime(), process: process, processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(directories: [root]), preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), + runConfigurationOperations: TestRunConfigurationOperations(savedProvider: reference), executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, languageRunProviders: .standard(catalog: .compatibilityFallback)) + defer { service.reset() } + let launch = try JSONDecoder().decode(BuildRunPreparation.self, from: Data( + #"{"executable":{"kind":"command","value":"java"},"arguments":["Main"],"workingDirectory":".","environment":{}}"#.utf8)) + _ = try service.launchPreparedRun(launch, workspaceURL: root, title: "Main", configuration: configuration) + #expect(service.saveBuildProviderConfiguration()) + #expect(service.lastConfiguration?.id == "user:provider") + let feature = RunFeatureModel(service: service) + #expect(feature.configurations.first { $0.id == "user:provider" }?.buildProvider == reference) + var calls: [BuildProviderRunReference] = [] + var cancellations: [UUID] = [] + var id = UUID() + service.configureBuildProviderRuns(start: { config, workspace in + #expect(workspace == root) + if let reference = config.buildProvider { calls.append(reference) } + return id + }, cancel: { cancellations.append($0) }) + service.restart() + #expect(feature.isRunning) + #expect(service.isPreparingBuildRun) + #expect(service.primaryExecutionID == id.uuidString) + #expect(!process.isRunning) + let old = id + service.stop() + #expect(cancellations == [old]) + #expect(!feature.isRunning) + id = UUID() + service.restart() + service.finishBuildPreparation(old, message: "stale failure") + #expect(service.isPreparingBuildRun) + _ = try service.launchPreparedRun(launch, workspaceURL: root, title: "Main", configuration: configuration) + #expect(service.primaryExecutionID == id.uuidString) + #expect(!service.isPreparingBuildRun) + #expect(cancellations == [old]) + #expect(calls == [reference, reference]) + #expect(process.startRequests.count == 2) + #expect(feature.isSelectedConfigurationRunning) + await service.loadProject(at: URL(fileURLWithPath: "/other"), files: [], mavenProject: nil) + #expect(!process.isRunning) + #expect(!service.hasBuildProviderRun) + #expect(service.lastConfiguration == nil) + #expect(service.primaryExecutionID == nil) + } + @Test(arguments: ["services/alpha", "services/beta"]) func moduleRunPassesMavenContextOnlyToItsOwningReactor(reactor: String) async { let configuration = RunConfiguration( @@ -3149,6 +3264,7 @@ private final class TestRuntime: MavenRuntimePort, RunRuntimePort { private final class TestStreamingProcess: StreamingProcess, @unchecked Sendable { var isRunning = false + var startFailure: (any Error)? private(set) var startRequests: [ProcessRequest] = [] var onOutput: (@Sendable (String) -> Void)? var onTermination: (@Sendable (Int32) -> Void)? @@ -3157,6 +3273,7 @@ private final class TestStreamingProcess: StreamingProcess, @unchecked Sendable /// moment the main process launches once every pre-launch step exits zero. var onStart: (@Sendable () -> Void)? func start(_ request: ProcessRequest) throws { + if let startFailure { throw startFailure } startRequests.append(request) isRunning = true onStart?() @@ -3625,14 +3742,23 @@ private struct SingleRunConfigurationOperations: RunConfigurationOperations { @MainActor private final class TestExecutableResolver: RunExecutableResolving { + private(set) var lastOptions: RunOptions? func resolve(_ plan: SharedLaunchPlan, projectURL: URL, options: RunOptions) throws -> ResolvedRunExecutable { - ResolvedRunExecutable(executableURL: URL(fileURLWithPath: "/test"), environment: [:]) + lastOptions = options + return ResolvedRunExecutable(executableURL: URL(fileURLWithPath: "/test"), environment: [:]) } func refreshCandidates(projectURL: URL) async {} func candidates(projectURL: URL) -> [ProjectToolchainCandidate] { [] } } private struct TestRunConfigurationOperations: RunConfigurationOperations { + var toolchain = ProjectToolchainSelection() + var rejectsResolutionWithoutGeneratedDocument = false + var savedProvider: BuildProviderRunReference? = nil + func saveBuildProviderConfiguration(_ reference: BuildProviderRunReference, name: String, at projectURL: URL) throws -> String { + guard reference == savedProvider else { throw ReloadTestError.failed } + return "user:provider" + } var inspectionGate: ReloadScanGate? = nil func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { if let inspectionGate { @@ -3641,7 +3767,8 @@ private struct TestRunConfigurationOperations: RunConfigurationOperations { Issue.record("Run inspection was not released before its deadline") } } - return ProjectRunConfigurationInspection(status: .missing, diagnostics: []) + return ProjectRunConfigurationInspection(status: .missing, diagnostics: [], + projectToolchain: toolchain == ProjectToolchainSelection() ? nil : toolchain) } func generate( at projectURL: URL, @@ -3652,7 +3779,14 @@ private struct TestRunConfigurationOperations: RunConfigurationOperations { RunConfigurationGenerationResult(entryCount: 0) } func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { - RunConfigurationResolution(configurations: [], diagnostics: [], defaultConfigurationID: nil) + if rejectsResolutionWithoutGeneratedDocument { throw ReloadTestError.failed } + if let savedProvider { + let configuration = RunConfiguration(id: "user:provider", name: "Main", kind: .process(provider: "build.provider"), + modulePath: nil, mainClass: nil, buildProvider: savedProvider) + return RunConfigurationResolution(configurations: [.init(configuration: configuration, options: RunOptions())], + diagnostics: [], defaultConfigurationID: nil) + } + return RunConfigurationResolution(configurations: [], diagnostics: [], defaultConfigurationID: nil, projectToolchain: toolchain) } func launchPlan(at projectURL: URL, configurationID: String, currentFile: String?, classPath: String?, debugPort: Int?) throws -> SharedLaunchPlan { throw RunConfigurationOperationFailure(message: "Unavailable in lifecycle test") diff --git a/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift b/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift index bc2c7501e9..a117c8b6f2 100644 --- a/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift +++ b/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift @@ -144,6 +144,10 @@ private final class ProviderRequestRecorder: BuildProviderRequesting { return BuildProviderExecutionResult(exitCode: 0, output: "") } + func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + try await send(request, to: target, timeout: timeout) + } func retainProviders(_ keep: (BuildProviderTarget) -> Bool) {} } diff --git a/macos/Tests/LitheTests/IdeCapabilitiesTests.swift b/macos/Tests/LitheTests/IdeCapabilitiesTests.swift index 5dd796356b..f8dac45d67 100644 --- a/macos/Tests/LitheTests/IdeCapabilitiesTests.swift +++ b/macos/Tests/LitheTests/IdeCapabilitiesTests.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import LitheCoreContracts import LitheExecutionModule @@ -45,6 +46,95 @@ struct IdeCapabilitiesTests { #expect(updated.executionID == first.executionID) #expect(replacement.executionID != first.executionID) } + + @Test + func savedProviderRunKeepsItsAgentIdentityAcrossPreparationAndLaunch() async throws { + let fixture = try ProviderCapabilityExecutionFixture() + defer { fixture.remove() } + await fixture.run.loadProject(at: fixture.root, files: [], mavenProject: nil) + var preparations: [UUID] = [] + var cancelled: [UUID] = [] + let reference = fixture.reference + let root = fixture.root + fixture.run.configureBuildProviderRuns(start: { configuration, workspace in + #expect(configuration.buildProvider == reference) + #expect(workspace == root) + let id = UUID() + preparations.append(id) + return id + }, cancel: { cancelled.append($0) }) + let actions = CapabilityActionsFixture(root: fixture.root) + actions.execution = fixture.execution + actions.startConfiguration = { fixture.run.startConfiguration($0) } + let feature = IdeCapabilitiesFeatureModel(model: actions) + feature.allowExecute = true + feature.enable() + + do { + // Exercise the public Agent entry points against a configuration read + // from disk. A primary run has no module-session fallback to hide a + // lost operation identity during the provider-to-process handoff. + let started = await feature.call("lithe_run_start", arguments: ["configurationID": fixture.configurationID]) + let firstID = try #require(started["operationID"] as? String) + #expect(firstID == "run:" + (try #require(preparations.first)).uuidString) + #expect(fixture.run.isPreparingBuildRun) + #expect(fixture.run.moduleSessions.isEmpty) + let busy = await feature.call("lithe_run_start", arguments: ["configurationID": fixture.configurationID]) + #expect((busy["error"] as? [String: String])?["code"] == "BUSY") + #expect(preparations.count == 1) + let stopped = await feature.call("lithe_operation_stop", arguments: ["operationID": firstID]) + #expect(stopped["stopRequested"] as? Bool == true) + #expect(cancelled == preparations) + #expect(!fixture.run.isPreparingBuildRun) + + let restarted = await feature.call("lithe_run_restart", arguments: ["operationID": firstID]) + let secondID = try #require(restarted["operationID"] as? String) + #expect(secondID != firstID) + #expect(preparations.count == 2) + let preparation = try JSONDecoder().decode(BuildRunPreparation.self, from: Data( + #"{"executable":{"kind":"command","value":"fixture"},"arguments":["Main"],"workingDirectory":".","environment":{}}"#.utf8)) + let configuration = try #require(fixture.run.configurations.first { $0.id == fixture.configurationID }) + _ = try fixture.run.launchPreparedRun(preparation, workspaceURL: fixture.root, title: "Main", configuration: configuration) + #expect(!fixture.run.isPreparingBuildRun) + #expect(fixture.process.isRunning) + #expect(cancelled.count == 1) + let listed = await feature.call("lithe_operations_list", arguments: [:]) + let operation = try #require((listed["operations"] as? [[String: Any]])?.first) + #expect(operation["operationID"] as? String == secondID) + #expect(operation["configurationID"] as? String == fixture.configurationID) + #expect(operation["state"] as? String == "running") + + let outputReceived = TestGate() + let finished = TestGate() + let outputObserver = fixture.run.$output.sink { if $0.contains("provider output") { outputReceived.open() } } + let runningObserver = fixture.run.$isRunning.dropFirst().sink { if !$0 { finished.open() } } + defer { outputObserver.cancel(); runningObserver.cancel() } + fixture.process.onOutput?("provider output\n") + #expect(await outputReceived.waitUntilOpen(timeout: .seconds(5))) + fixture.process.complete(exitCode: 0) + #expect(await finished.waitUntilOpen(timeout: .seconds(5))) + let completed = await feature.call("lithe_operation_output", arguments: ["operationID": secondID]) + #expect(completed["operationID"] as? String == secondID) + #expect(completed["state"] as? String == "completed") + #expect(completed["exitCode"] as? Int32 == 0) + #expect((completed["output"] as? String)?.contains("provider output") == true) + + let third = await feature.call("lithe_run_restart", arguments: ["operationID": secondID]) + let thirdID = try #require(third["operationID"] as? String) + #expect(thirdID != secondID) + let expired = await feature.call("lithe_operation_stop", arguments: ["operationID": secondID]) + #expect((expired["error"] as? [String: String])?["code"] == "OPERATION_EXPIRED") + #expect(fixture.run.isPreparingBuildRun) + #expect(cancelled.count == 1) + _ = await feature.call("lithe_operation_stop", arguments: ["operationID": thirdID]) + #expect(cancelled.map { "run:" + $0.uuidString } == [firstID, thirdID]) + } catch { + await feature.shutdown() + throw error + } + await feature.shutdown() + #expect(actions.transport.closed) + } } @MainActor @@ -57,6 +147,7 @@ private final class CapabilityTransportFixture: IdeHostTransport { func control(_ action: String, arguments: [String: Any]) throws -> [String: Any] { if action == "validate" { return validation } if action == "close" { closed = true } + if action == "output" { return arguments["snapshot"] as? [String: Any] ?? [:] } return ["requests": []] } } @@ -65,21 +156,118 @@ private final class CapabilityTransportFixture: IdeHostTransport { private final class CapabilityActionsFixture: IdeCapabilityActions { let id = UUID() let projectName = "Fixture" - let root = URL(fileURLWithPath: "/fixture/project") + let root: URL let transport = CapabilityTransportFixture() var activationCount = 0 + var execution: ExecutionFeatureAccess? + var startConfiguration: ((RunConfiguration) -> Void)? var currentWorkspaceIdentity: WorkspaceIdentity? var runtimeFeature: RuntimeSettingsFeatureModel { preconditionFailure("Authorization tests must not inspect runtimes") } var ideHostTransport: (any IdeHostTransport)? { transport } - init() { currentWorkspaceIdentity = WorkspaceIdentity(url: root, generation: 1) } + init(root: URL = URL(fileURLWithPath: "/fixture/project")) { + self.root = root + currentWorkspaceIdentity = WorkspaceIdentity(url: root, generation: 1) + } func isCurrentWorkspace(_ identity: WorkspaceIdentity) -> Bool { identity == currentWorkspaceIdentity } - func activateExecutionModule() async -> ExecutionFeatureAccess? { activationCount += 1; return nil } + func activateExecutionModule() async -> ExecutionFeatureAccess? { activationCount += 1; return execution } func ensureRunProjectReady(_ run: RunFeatureModel, for identity: WorkspaceIdentity) async -> RunProjectReadiness { .stale } func prepareProjectRuntimeSettings() async {} func persistProjectRuntimeSettings() {} func reloadMavenProject(rescan: Bool) async {} - func startAPIConfiguration(_ configuration: RunConfiguration) async {} + func startAPIConfiguration(_ configuration: RunConfiguration) async { startConfiguration?(configuration) } func registerIdeCapabilities(_ feature: IdeCapabilitiesFeatureModel) {} func unregisterIdeCapabilities() {} func copyIdeConfiguration(_ text: String) {} } + +@MainActor +private final class ProviderCapabilityExecutionFixture { + let root: URL + let reference = BuildProviderRunReference(pluginID: "dev.example", buildSystemID: "fixture", root: ".", + run: "application", projectID: "project", targetID: "main", userArguments: []) + let configurationID: String + let process = CapabilityProcessFixture() + let run: RunService + let execution: ExecutionFeatureAccess + + init() throws { + let core = RustCoreBridge() + try #require(core.isAvailable) + let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-agent-" + UUID().uuidString, isDirectory: true) + self.root = root + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + do { + let preferences = CapabilityPreferencesFixture() + let store = MacRunConfigurationStore(core: core, storage: MacFileStorage(), preferences: preferences) + configurationID = try store.saveBuildProviderConfiguration(reference, name: "Main", at: root) + let runtime = CapabilityRuntimeFixture() + let resolver = CapabilityExecutableFixture() + run = RunService(runtime: runtime, process: process, processFactory: { CapabilityProcessFixture() }, + fileAccess: MacRunFileAccess(storage: MacFileStorage()), preferences: preferences, + serverPortParser: CapabilityPortParserFixture(), runConfigurationOperations: store, + executableResolver: resolver, languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback)) + let maven = MavenFeatureModel(service: MavenService(runtimeService: runtime, process: CapabilityProcessFixture(), + dependencyProcess: CapabilityProcessFixture(), mavenOperations: RustJavaMavenOperations(core: core))) + let runFeature = RunFeatureModel(service: run) + execution = ExecutionFeatureAccess(mavenFeature: maven, runFeature: runFeature, + tests: LanguageTestService(executableResolver: resolver, processFactory: { CapabilityProcessFixture() }), + projectDevelopment: ProjectDevelopmentFeatureModel(mavenFeature: maven, runFeature: runFeature), buildSystems: nil) + } catch { + try? FileManager.default.removeItem(at: root) + throw error + } + } + + func remove() { + run.reset() + execution.mavenFeature.reset() + execution.tests.stop() + try? FileManager.default.removeItem(at: root) + } +} + +private final class CapabilityProcessFixture: StreamingProcess, @unchecked Sendable { + var isRunning = false + var onOutput: (@Sendable (String) -> Void)? + var onTermination: (@Sendable (Int32) -> Void)? + var onStateChange: (@Sendable (ProcessLifecycleEvent) -> Void)? + func start(_ request: ProcessRequest) throws { isRunning = true } + func send(_ input: Data) throws {} + func stop() { isRunning = false } + func complete(exitCode: Int32) { isRunning = false; onTermination?(exitCode) } +} + +@MainActor +private final class CapabilityRuntimeFixture: RunRuntimePort, MavenRuntimePort { + func mavenExecutable(for project: MavenProject, overridePath: String?) -> URL? { nil } + func mavenProcessEnvironment(javaHomePath: String?) -> [String: String] { [:] } + func setActiveServiceJavaHomePath(_ path: String) {} + func javaHomeURL(overridePath: String?) -> URL? { nil } + func mavenJavaHomeURL(overridePath: String?) -> URL? { nil } + func runConfigurationToolchainCandidates(for project: MavenProject?, projectRoot: URL?, + javaHomeOverride: String?, mavenExecutableOverride: String?) -> [ProjectToolchainCandidate] { [] } + func loadRunConfigurationToolchainCandidates(for project: MavenProject?, projectRoot: URL?, + javaHomeOverride: String?, mavenExecutableOverride: String?) async throws -> [ProjectToolchainCandidate] { [] } +} + +@MainActor +private final class CapabilityExecutableFixture: RunExecutableResolving { + func resolve(_ plan: SharedLaunchPlan, projectURL: URL, options: RunOptions) throws -> ResolvedRunExecutable { + ResolvedRunExecutable(executableURL: URL(fileURLWithPath: "/fixture/launcher"), environment: [:]) + } +} + +private final class CapabilityPreferencesFixture: KeyValueStore, RunPreferenceStore, @unchecked Sendable { + nonisolated func data(forKey key: String) -> Data? { nil } + nonisolated func object(forKey key: String) -> Any? { nil } + nonisolated func string(forKey key: String) -> String? { nil } + nonisolated func stringArray(forKey key: String) -> [String]? { nil } + nonisolated func set(_ value: Any?, forKey key: String) {} + nonisolated func setData(_ data: Data, forKey key: String) {} + nonisolated func setString(_ value: String, forKey key: String) {} +} + +private struct CapabilityPortParserFixture: RunServerPortParsing { + func serverPort(content: String, fileExtension: String) -> Int? { nil } +} diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index 8d3196c9b4..5ccdea9ed2 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -42,6 +42,24 @@ struct MacBuildProviderSessionsTests { #expect(fixture.core.stopCalls.count == 2) } + @Test + func nativeRunPreparationReusesProviderAndKeepsPackageReadOnly() async throws { + let fixture = try ProviderFixture() + defer { fixture.remove() } + let before = try fixture.packageSnapshot() + _ = try await fixture.load() + let progress = BuildProgressRecorder() + let result = try await fixture.sessions.prepareRun(BuildProviderRequest( + method: "lithe/build/prepareRun", params: .object([:]), optionFingerprint: "", + executesProjectCode: true, mayUseNetwork: true), on: fixture.target, timeout: .seconds(5)) { progress.values.append($0) } + #expect(result == .object(["executable": .object(["kind": .string("command"), "value": .string("java")]), + "arguments": .array([]), "workingDirectory": .string("."), "environment": .object([:])])) + #expect(progress.values == [BuildProviderProgress(percentage: 50, output: "building")]) + #expect(fixture.core.startCalls.count == 1) + #expect(fixture.core.buildRequests == ["lithe/build/model", "lithe/build/prepareRun"]) + #expect(try fixture.packageSnapshot() == before) + } + /// A second request reuses the process; stopping the plugin's providers, /// which the plugin manager does before replacing its package, ends it /// and the next request starts a new one. @@ -672,13 +690,15 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked _buildRequests.append(method) _buildParams.append(params) let operationID = "\(sessionID)-\(_buildRequests.count)" - if ["lithe/build/execute", "lithe/build/test"].contains(method) { + if ["lithe/build/execute", "lithe/build/test", "lithe/build/prepareRun"].contains(method) { events[sessionID, default: []].append(LanguageServerRuntimeEvent( type: "requestProgress", operationID: operationID, result: .object(["percentage": .integer(50), "output": .string("building")]) )) } - let value: ToolingJSONValue = ["lithe/build/execute", "lithe/build/test"].contains(method) + let launch: ToolingJSONValue = .object(["executable": .object(["kind": .string("command"), "value": .string("java")]), + "arguments": .array([]), "workingDirectory": .string("."), "environment": .object([:])]) + let value: ToolingJSONValue = method == "lithe/build/prepareRun" ? launch : ["lithe/build/execute", "lithe/build/test"].contains(method) ? .object(["exitCode": .integer(0), "output": .string("built")]) : .object(["session": .string(sessionID)]) events[sessionID, default: []].append(LanguageServerRuntimeEvent( diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index b15f176a9a..a021548ee2 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -9,6 +9,33 @@ import Testing @Suite("Run configuration integration") @MainActor struct RunConfigurationIntegrationTests { + @Test + func savedBuildProviderReferenceWorksWithoutGeneratedConfiguration() throws { + let core = RustCoreBridge() + try #require(core.isAvailable) + let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-config-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root.appendingPathComponent("app"), withIntermediateDirectories: true) + let store = MacRunConfigurationStore(core: core, storage: MacFileStorage(), preferences: RunTestKeyValueStore()) + let reference = BuildProviderRunReference(pluginID: "dev.example.gradle", buildSystemID: "gradle", root: "app", + run: "application", projectID: "p", targetID: "t", userArguments: ["a b", "--x=one two"]) + let id = try store.saveBuildProviderConfiguration(reference, name: "Main", at: root) + #expect(store.inspect(at: root).status == .ready) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe/run/generated.json").path)) + let resolved = try store.resolve(at: root, toolchainCandidates: []) + #expect(resolved.configurations.first?.configuration.buildProvider == reference) + #expect(resolved.configurations.first?.configuration.id == id) + #expect(try store.saveBuildProviderConfiguration(reference, name: "Renamed", at: root) == id) + #expect(try store.resolve(at: root, toolchainCandidates: []).configurations.count == 1) + let data = try Data(contentsOf: root.appendingPathComponent(".lithe/run/local.json")) + let document = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let entry = try #require((document["configurations"] as? [[String: Any]])?.first) + #expect(entry["command"] == nil) + #expect(entry["args"] == nil) + let ignore = try String(contentsOf: root.appendingPathComponent(".lithe/.gitignore"), encoding: .utf8) + #expect(ignore.contains("run/local.json")) + } + @Test func projectEnvironmentSavesWithoutGeneratedConfigurationsAndPreservesOverrides() throws { // The unit lane does not link Rust Core; the Rust test with the same diff --git a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift index 40a58a8fb8..afabb878ae 100644 --- a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift +++ b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift @@ -120,6 +120,29 @@ struct RustBuildSystemOperationsTests { #expect(request.mayUseNetwork) } + @Test + func providerRunPreparationRoundTripsThroughRealCore() throws { + let descriptor = try Self.descriptor("build-provider-run-preparation") + #expect(descriptor.buildSystems.first?.providerRuns.map(\.id) == ["application"]) + let target = try Self.target("build-provider-run-preparation", "gradle") + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: workspace) } + try FileManager.default.createDirectory(at: workspace.appendingPathComponent("app"), withIntermediateDirectories: true) + let plan = try operations.prepareRunPlan(target, context: BuildPlanContext(root: ".", options: ["offline": .bool(true)]), + run: "application", project: BuildSelection(name: "app", path: "app"), + selectedTarget: BuildSelection(name: "Main", path: "app", tags: ["application"]), userArguments: ["a b"]) + #expect(plan.method == "lithe/build/prepareRun") + guard case .object(let params) = plan.params else { Issue.record("Missing provider params"); return } + #expect(params["userArguments"] == .array([.string("a b")])) + let result: ToolingJSONValue = .object(["executable": .object(["kind": .string("toolchain"), "value": .string("project-jdk")]), + "arguments": .array([.string("Main"), .string("a b")]), "workingDirectory": .string("app"), "environment": .object([:])]) + let launch = try operations.decodeRunPreparation(target, workspaceURL: workspace, root: ".", result: result) + #expect(launch.executable.kind == .toolchain) + #expect(launch.executable.value == "project-jdk") + #expect(launch.arguments == ["Main", "a b"]) + #expect(launch.workingDirectory == "app") + } + @Test func providerTestPlanKeepsTypedFiltersAndReportPatterns() throws { let target = try Self.target("build-provider-tests", "gradle") diff --git a/rust/lithe-core/src/build/commands.rs b/rust/lithe-core/src/build/commands.rs index 6718d1ee4c..f611043140 100644 --- a/rust/lithe-core/src/build/commands.rs +++ b/rust/lithe-core/src/build/commands.rs @@ -195,6 +195,30 @@ pub(crate) fn build_decode_test_discovery_command(payload: Value) -> Result Result { + with_build_system( + payload, + "build.prepareRunPlan", + "plan", + |build_system, request: &super::run_preparation::PrepareRunRequest| { + super::run_preparation::plan(build_system, request) + }, + ) +} + +/// `build.decodeRunPreparation`: validates portable process launch data. +pub(crate) fn build_decode_run_preparation_command(payload: Value) -> Result { + with_build_system( + payload, + "build.decodeRunPreparation", + "launch", + |build_system, request: &super::run_preparation::DecodeRunPreparationRequest| { + super::run_preparation::decode(build_system, request) + }, + ) +} + /// `build.dependencyPlan`: the command that prints a project's dependencies. pub(crate) fn build_dependency_plan_command(payload: Value) -> Result { with_build_system( diff --git a/rust/lithe-core/src/build/manifest.rs b/rust/lithe-core/src/build/manifest.rs index 6d2a29e035..23cced1d32 100644 --- a/rust/lithe-core/src/build/manifest.rs +++ b/rust/lithe-core/src/build/manifest.rs @@ -632,6 +632,9 @@ fn task_execution() -> BuildExecution { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct RunDeclaration { pub(crate) id: String, + /// Provider runs prepare a launch plan; they never become empty argv entries. + #[serde(default, skip_serializing_if = "TaskSource::is_command")] + pub(crate) source: TaskSource, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) from_targets: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1310,11 +1313,16 @@ impl BuildSystemDeclaration { } fn validate_provider(&self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { + let provider_runs = self + .run + .iter() + .any(|run| run.source == TaskSource::Provider); let provider_tests = self .test .as_ref() .is_some_and(|test| test.source == TaskSource::Provider); let uses = matches!(self.model, Some(ModelSource::Provider)) + || provider_runs || matches!(self.dependencies, Some(DependenciesSource::Provider { .. })) || provider_tests || self @@ -1337,7 +1345,14 @@ impl BuildSystemDeclaration { if provider_tests && context.build_providers_version < 3 { return Err(invalid(&field, "Provider tests require buildProviders 3.")); } - if provider_tests + if provider_runs && context.build_providers_version < 4 { + return Err(invalid( + &field, + "Provider run preparation requires buildProviders 4.", + )); + } + if provider_runs + || provider_tests || self .tasks .iter() @@ -1775,6 +1790,29 @@ impl BuildSystemDeclaration { "Run IDs are unique identifiers that start with a letter.", )); } + if run.source == TaskSource::Provider { + let valid_targets = run.from_targets.as_ref().is_some_and(|from| { + !from.tags.is_empty() + && from.tags.len() <= MAX_LIST + && from.tags.iter().all(|tag| is_tag(tag)) + }); + if !valid_targets + || run.from_tasks.is_some() + || !run.arguments.is_empty() + || !run.extension.is_empty() + || run + .execution + .is_some_and(|execution| execution != BuildExecution::Application) + || run.name.as_ref().is_some_and(|name| { + name.trim().is_empty() || name.len() > MAX_TEXT || name.contains("${") + }) + { + return Err(invalid(&field, "Provider runs require target tags, application execution, and no host command templates.")); + } + let from = run.from_targets.as_mut().expect("validated target source"); + from.tags = sorted_unique(std::mem::take(&mut from.tags)); + continue; + } let from_tasks = match (&mut run.from_targets, &mut run.from_tasks) { (Some(targets), None) => { if targets.tags.is_empty() || targets.tags.iter().any(|tag| !is_tag(tag)) { diff --git a/rust/lithe-core/src/build/mod.rs b/rust/lithe-core/src/build/mod.rs index fcc9c1fdc5..f2b1e0ac73 100644 --- a/rust/lithe-core/src/build/mod.rs +++ b/rust/lithe-core/src/build/mod.rs @@ -26,6 +26,7 @@ mod model; mod options; mod outputs; mod plans; +mod run_preparation; mod select; mod template; mod test_discovery; diff --git a/rust/lithe-core/src/build/plans.rs b/rust/lithe-core/src/build/plans.rs index 1da22b6916..cfc9f64f28 100644 --- a/rust/lithe-core/src/build/plans.rs +++ b/rust/lithe-core/src/build/plans.rs @@ -154,7 +154,7 @@ pub(crate) struct ProviderTool { /// Validates what the host adds to a provider request and returns the /// params it contributes: `refresh` only when set and `tool` only when /// resolved, so a request without them matches a host that predates both. -fn provider_hints(request: &OutputPlanRequest) -> Validation> { +pub(crate) fn provider_hints(request: &OutputPlanRequest) -> Validation> { let mut params = Map::new(); if request.refresh { params.insert("refresh".to_string(), json!(true)); @@ -349,7 +349,7 @@ impl<'a> PlanContext<'a> { /// would contribute to a command line, so the provider can hand either /// to the tool. `params` already holds the source's own fields and the /// host's `refresh` and `tool` hints. - fn provider_plan( + pub(crate) fn provider_plan( &self, provider: &ProviderDeclaration, method: &str, @@ -401,7 +401,7 @@ fn check_user_arguments(arguments: &[String], declared: &[BuildArgument]) -> Val } /// Provider calls accept raw argument values without a CLI placeholder. -fn check_argument_limits(arguments: &[String]) -> Validation<()> { +pub(crate) fn check_argument_limits(arguments: &[String]) -> Validation<()> { if arguments.len() > MAX_USER_ARGUMENTS || arguments .iter() @@ -979,6 +979,11 @@ pub(crate) fn run_entries( .collect(); let mut parts = Vec::new(); for run in &build_system.run { + // A provider-owned launch is prepared on demand; it must not be + // persisted as a command or emitted with an empty argument list. + if run.source == TaskSource::Provider { + continue; + } if let Some(from) = &run.from_targets { for target in model .targets diff --git a/rust/lithe-core/src/build/run_preparation.rs b/rust/lithe-core/src/build/run_preparation.rs new file mode 100644 index 0000000000..fafdb26d65 --- /dev/null +++ b/rust/lithe-core/src/build/run_preparation.rs @@ -0,0 +1,171 @@ +//! Provider-owned run preparation and validation of portable launch plans. +//! Upstream providers own build/classpath facts; Core only validates selectors, +//! bounded arguments, executable references, and workspace path containment. + +use super::decode::{self, Workspace}; +use super::manifest::{BuildSystemDeclaration, TaskSource}; +use super::plans::{self, OutputPlanRequest, SelectedEntity}; +use crate::plugins::declarative::{is_bare_command, is_identifier, Validation}; +use serde::Deserialize; +use serde_json::{json, Value}; + +const MAX_ARGUMENTS: usize = 1024; +const MAX_ARGUMENT_BYTES: usize = 65536; +const MAX_ENVIRONMENT: usize = 64; + +/// Host-selected model target and the run declaration that can prepare it. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PrepareRunRequest { + #[serde(flatten)] + pub(crate) base: OutputPlanRequest, + pub(crate) run: String, + pub(crate) target: SelectedEntity, + #[serde(default)] + pub(crate) user_arguments: Vec, +} + +/// Builds the public request without inferring language-specific launch facts. +pub(crate) fn plan( + build_system: &BuildSystemDeclaration, + request: &PrepareRunRequest, +) -> Validation { + let declaration = build_system + .run + .iter() + .find(|run| run.id == request.run && run.source == TaskSource::Provider) + .ok_or_else(|| plans::unavailable(build_system, "this provider run"))?; + if request.base.output_file.is_some() { + return Err(plans::invalid_selection( + "Provider run preparation does not use an output file.", + )); + } + let context = plans::PlanContext::new(build_system, &request.base.base)?; + let project = request + .base + .project + .as_ref() + .ok_or_else(|| plans::invalid_selection("A run target needs its project."))?; + let project = plans::selection(project, &context.root, None, "project")?; + let target = plans::selection( + &request.target, + &context.root, + Some(&project.path), + "target", + )?; + if !declaration + .from_targets + .as_ref() + .is_some_and(|from| target.tags.iter().any(|tag| from.tags.contains(tag))) + { + return Err(plans::invalid_selection( + "This run does not apply to the selected target.", + )); + } + plans::check_argument_limits(&request.user_arguments)?; + let mut params = plans::provider_hints(&request.base)?; + params.insert("run".to_string(), json!(declaration.id)); + params.insert("userArguments".to_string(), json!(request.user_arguments)); + for (name, selected) in [("project", project), ("target", target)] { + params.insert( + name.to_string(), + json!({"name":selected.name,"path":selected.path, + "tags":selected.tags,"properties":selected.properties}), + ); + } + let provider = build_system + .provider + .as_ref() + .ok_or_else(|| plans::unavailable(build_system, "a provider"))?; + Ok(context.provider_plan( + provider, + "lithe/build/prepareRun", + "litheRunPreparation", + params, + )) +} + +/// The platform supplies its writable workspace root, never an installation path. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DecodeRunPreparationRequest { + pub(crate) workspace_root: String, + pub(crate) root: String, + pub(crate) result: Value, +} + +/// Validates all launch data before a platform executable resolver sees it. +pub(crate) fn decode( + build_system: &BuildSystemDeclaration, + request: &DecodeRunPreparationRequest, +) -> Validation { + if !build_system + .run + .iter() + .any(|run| run.source == TaskSource::Provider) + { + return Err(plans::unavailable(build_system, "run preparation")); + } + let invalid = || decode::invalid_output("Invalid provider run preparation result."); + let value = &request.result; + if serde_json::to_vec(value).map_or(usize::MAX, |bytes| bytes.len()) > decode::MAX_OUTPUT_BYTES + { + return Err(invalid()); + } + let executable = value.get("executable").ok_or_else(invalid)?; + let kind = executable["kind"].as_str().ok_or_else(invalid)?; + let name = executable["value"].as_str().ok_or_else(invalid)?; + if name.len() > 120 + || !(kind == "command" && is_bare_command(name) + || kind == "toolchain" && is_identifier(name)) + { + return Err(invalid()); + } + let args = value["arguments"] + .as_array() + .filter(|args| args.len() <= MAX_ARGUMENTS) + .ok_or_else(invalid)?; + if args.iter().any(|arg| { + !arg.as_str() + .is_some_and(|text| text.len() <= MAX_ARGUMENT_BYTES && !text.contains('\0')) + }) { + return Err(invalid()); + } + let root = decode::request_path(&request.root, "root")?; + let cwd = value["workingDirectory"].as_str().ok_or_else(invalid)?; + let cwd = decode::request_path(cwd, "workingDirectory").map_err(|_| invalid())?; + let workspace = Workspace::new(&request.workspace_root)?; + let instance = workspace + .read_path(&root) + .filter(|path| path.is_dir()) + .ok_or_else(invalid)?; + if !decode::contains(&root, &cwd) + || !workspace + .read_path(&cwd) + .is_some_and(|path| path.is_dir() && path.starts_with(&instance)) + { + return Err(invalid()); + } + let environment = value["environment"] + .as_object() + .filter(|env| env.len() <= MAX_ENVIRONMENT) + .ok_or_else(invalid)?; + for (key, value) in environment { + let valid_key = !key.is_empty() + && key.len() <= 128 + && key.bytes().enumerate().all(|(index, byte)| { + byte == b'_' || byte.is_ascii_alphabetic() || index > 0 && byte.is_ascii_digit() + }); + if !valid_key + || !value + .as_str() + .is_some_and(|text| text.len() <= MAX_ARGUMENT_BYTES && !text.contains('\0')) + { + return Err(invalid()); + } + } + Ok( + json!({"executable":{"kind":kind,"value":name},"arguments":args, + "workingDirectory":cwd,"environment":environment}), + ) +} diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 87910c5954..54c99365b0 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -191,6 +191,62 @@ pub struct CreateUserConfigurationRequest { pub module: String, #[serde(default)] pub main_class: String, + /// A typed selector, never a cached executable or classpath. + #[serde(default)] + pub build: Option, +} + +/// Persisted workspace identity for re-preparing a build-provider application. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BuildProviderRunReference { + #[serde(rename = "pluginID")] + pub plugin_id: String, + #[serde(rename = "buildSystemID")] + pub build_system_id: String, + pub root: String, + pub run: String, + #[serde(rename = "projectID")] + pub project_id: String, + #[serde(rename = "targetID")] + pub target_id: String, + #[serde(default)] + pub user_arguments: Vec, +} + +impl BuildProviderRunReference { + /// Persistence validates identities only; current model membership is checked at launch. + fn validate(&self) -> Result<(), CoreError> { + let identifier = |value: &str| { + !value.is_empty() + && value.len() <= 200 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + }; + if !identifier(&self.plugin_id) + || !identifier(&self.build_system_id) + || !identifier(&self.run) + || [&self.project_id, &self.target_id] + .iter() + .any(|id| id.is_empty() || id.len() > 2048 || id.contains('\0')) + || self.user_arguments.len() > 64 + || self + .user_arguments + .iter() + .any(|arg| arg.len() > 4096 || arg.contains('\0')) + || invalid_relative_path(&self.root) + || self.root.is_empty() + || self.root.contains('\\') + || self.root.contains('\0') + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid build provider run reference", + )); + } + Ok(()) + } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -507,7 +563,7 @@ pub fn inspect(request: InspectRequest) -> Result { } } Ok(json!({ - "status": if generated.is_some() { "ready" } else { "missing" }, + "status": if generated.is_some() || has_saved_build_run(&local) { "ready" } else { "missing" }, "generated": generated, "toolchainRequirements": requirements, "localToolchains": local_toolchains, @@ -1097,15 +1153,19 @@ fn is_nested_checkout_path(value: &str) -> bool { /// Merges generated, team, and local layers and selects compatible toolchains. pub fn resolve(request: ResolveRequest) -> Result { let root = existing_root(&request.root)?; - let generated = read_document_value(&root, "run/generated.json")?.ok_or_else(|| { - CoreError::new( - ErrorCode::WorkspaceNotFound, - "Run configuration has not been generated", - ) - })?; let team = read_document_value(&root, "run/configurations.json")? .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); let local = local_layer_document(&root, request.local_document)?; + let generated = match read_document_value(&root, "run/generated.json")? { + Some(document) => document, + None if has_saved_build_run(&local) => json!({"version": VERSION, "configurations": []}), + None => { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + "Run configuration has not been generated", + )) + } + }; let local_toolchains = read_local_toolchains(&root)?; let manifest = read_document_value(&root, "project.json")?; validate_version_value(&generated)?; @@ -1141,6 +1201,23 @@ pub fn resolve(request: ResolveRequest) -> Result { )?); for configuration in &mut configurations { validate_configuration(configuration)?; + if configuration.provider == "build.provider" { + // Materialize optional defaults in the shared output so native + // consumers receive the same shape as a newly saved selector. + let reference: BuildProviderRunReference = serde_json::from_value( + configuration.extensions["build"].clone(), + ) + .map_err(|_| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid build provider run reference", + ) + })?; + configuration.extensions.insert( + "build".to_string(), + serde_json::to_value(reference).expect("reference encodes"), + ); + } if configuration.disabled { diagnostics.push(json!({ "id": configuration.id, @@ -1562,11 +1639,133 @@ fn remove_maven_skip_tests_override(configuration: &mut serde_json::Map bool { + document["configurations"].as_array().is_some_and(|items| { + items.iter().any(|item| { + item["provider"] == "build.provider" + && item["id"] + .as_str() + .is_some_and(|id| id.starts_with("user:")) + }) + }) +} + +/// Upserts a local reference while preserving unrelated settings and stable IDs. +fn create_build_provider_configuration( + root: &Path, + request: &CreateUserConfigurationRequest, +) -> Result { + if request.scope != "local" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Build provider references must be saved locally", + )); + } + let reference = request.build.as_ref().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Missing build provider run reference", + ) + })?; + reference.validate()?; + normalize_project_directory(root, &reference.root)?; + let name = request.name.trim(); + if name.is_empty() || name.len() > 256 || name.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid run configuration name", + )); + } + let mut document = read_document_value(root, "run/local.json")? + .unwrap_or_else(|| json!({"version":VERSION,"configurations":[]})); + validate_version_value(&document)?; + let mut ids: std::collections::BTreeSet = + configuration_ids(&document)?.into_keys().collect(); + for relative in ["run/generated.json", "run/configurations.json"] { + if let Some(other) = read_document_value(root, relative)? { + validate_version_value(&other)?; + ids.extend(configuration_ids(&other)?.into_keys()); + } + } + let encoded = serde_json::to_value(reference).expect("reference encodes"); + let mut identity = encoded.clone(); + identity.as_object_mut().unwrap().remove("userArguments"); + let items = document["configurations"] + .as_array_mut() + .ok_or_else(|| CoreError::new(ErrorCode::ParseFailed, "configurations must be an array"))?; + let existing = items.iter().position(|item| { + if item["provider"] != "build.provider" { + return false; + } + let mut saved = item["extensions"]["build"].clone(); + if let Some(object) = saved.as_object_mut() { + object.remove("userArguments"); + } + saved == identity + }); + let id = existing + .and_then(|index| items[index]["id"].as_str()) + .map(str::to_string) + .unwrap_or_else(|| unique_user_configuration_id(name, &ids)); + let mut configuration = existing + .map(|index| items[index].clone()) + .unwrap_or_else(|| json!({})); + let object = configuration.as_object_mut().ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration must be an object", + ) + })?; + let managed = json!({"id":id,"name":name,"provider":"build.provider","execution":"application", + "cwd":reference.root,"confidence":"declared"}); + object.extend( + managed + .as_object() + .expect("managed fields are an object") + .clone(), + ); + // Other namespaces may hold user/plugin metadata; replacing the whole + // entry would erase data unrelated to this selector update. + let extensions = object + .entry("extensions") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration extensions must be an object", + ) + })?; + extensions.insert("build".to_string(), encoded); + let typed: RunConfiguration = serde_json::from_value(configuration.clone()) + .map_err(|_| CoreError::new(ErrorCode::ParseFailed, "Invalid saved run configuration"))?; + validate_configuration(&typed)?; + if let Some(index) = existing { + items[index] = configuration; + } else { + items.push(configuration); + } + items.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str())); + Ok( + json!({"id":id,"document":serde_json::to_string_pretty(&document).expect("document encodes")}), + ) +} + /// Creates a user configuration while preserving stable IDs in existing layers. pub fn create_user_configuration( request: CreateUserConfigurationRequest, ) -> Result { let root = existing_root(&request.root)?; + if request.kind == "buildProvider" { + return create_build_provider_configuration(&root, &request); + } + if request.build.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Build reference requires a buildProvider configuration", + )); + } let relative = scope_document(&request.scope)?; let name = request.name.trim(); if name.is_empty() { @@ -2551,6 +2750,34 @@ fn validate_configuration(configuration: &RunConfiguration) -> Result<(), CoreEr ) .with_details(configuration.provider.clone())); } + if configuration.provider == "build.provider" { + let reference: BuildProviderRunReference = serde_json::from_value( + configuration + .extensions + .get("build") + .cloned() + .unwrap_or(Value::Null), + ) + .map_err(|_| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid build provider run reference", + ) + })?; + reference.validate()?; + if configuration.command.is_some() + || !configuration.args.is_empty() + || !configuration.env.is_empty() + || !configuration.toolchains.is_empty() + || configuration.debug.is_some() + || configuration.execution != Execution::Application + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Build provider configurations store selectors, not process launch data", + )); + } + } let module = configuration.module().unwrap_or_else(|| ".".to_string()); for (field, value) in [ ("module", module.as_str()), diff --git a/rust/lithe-core/src/lsp/interface/build_provider.rs b/rust/lithe-core/src/lsp/interface/build_provider.rs index 459ffea419..a02bc5ef5f 100644 --- a/rust/lithe-core/src/lsp/interface/build_provider.rs +++ b/rust/lithe-core/src/lsp/interface/build_provider.rs @@ -15,7 +15,7 @@ use serde_json::{json, Value}; const EXPERIMENTAL_KEY: &str = "litheBuild"; /// Build-provider protocol versions this client speaks, in preference order. -const SUPPORTED_VERSIONS: [u64; 3] = [3, 2, 1]; +const SUPPORTED_VERSIONS: [u64; 4] = [4, 3, 2, 1]; /// Feature name recorded in the session's server capabilities once a /// provider answered a supported version. @@ -23,6 +23,10 @@ pub(crate) const BUILD_PROVIDER_FEATURE: &str = "litheBuild"; /// Version 2 uses the same workspace session and operation lifecycle. pub(crate) const EXECUTE_METHOD: &str = "lithe/build/execute"; +/// Version 4 prepares a bounded launch, while the host owns the application. +pub(crate) const PREPARE_RUN_METHOD: &str = "lithe/build/prepareRun"; +/// Admission feature for the version 4 method. +pub(crate) const RUN_PREPARATION_FEATURE: &str = "litheRunPreparation"; /// Version 3 adds tests while retaining the bounded execution lifecycle. pub(crate) const TEST_METHOD: &str = "lithe/build/test"; /// Version 3 returns exact selectors; decoding remains a separate Core command. @@ -57,6 +61,7 @@ pub(crate) fn is_build_method(method: &str) -> bool { VERSION_1_METHODS.contains(&method) || is_execution_method(method) || method == DISCOVER_TESTS_METHOD + || method == PREPARE_RUN_METHOD } /// Whether the method shares bounded output, progress, and final result handling. @@ -69,7 +74,7 @@ pub(crate) fn execution_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 2 | 3)) + .is_some_and(|version| matches!(version, 2 | 3 | 4)) } /// Tests require an explicit version 3 agreement, independent of task support. @@ -77,7 +82,20 @@ pub(crate) fn tests_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - == Some(3) + .is_some_and(|version| matches!(version, 3 | 4)) +} + +/// A preparation has progress and cancellation, but its reply is a launch plan. +pub(crate) fn is_progress_method(method: &str) -> bool { + is_execution_method(method) || method == PREPARE_RUN_METHOD +} + +/// Run preparation requires an explicit version 4 agreement. +pub(crate) fn run_preparation_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + == Some(4) } /// Normalizes bounded progress; the engine checks the pending request identity. @@ -149,7 +167,7 @@ mod tests { &json!({ "experimental": { "litheBuild": {} } }) )); assert!(!negotiated( - &json!({ "experimental": { "litheBuild": { "version": 4 } } }) + &json!({ "experimental": { "litheBuild": { "version": 5 } } }) )); assert!(!negotiated( &json!({ "experimental": { "litheBuild": { "version": "1" } } }) diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 273f359e76..2c70807400 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -1756,6 +1756,9 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { if super::build_provider::tests_negotiated(capabilities) { insert_unique(&mut values, super::build_provider::TEST_EXECUTION_FEATURE); } + if super::build_provider::run_preparation_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::RUN_PREPARATION_FEATURE); + } if plugin_function::negotiate(capabilities).is_some() { insert_unique(&mut values, plugin_function::FUNCTIONS_FEATURE); } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 387157cfe4..955c5c15d9 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -2129,8 +2129,21 @@ impl RuntimeSession { ) .with_details("plugin.build.unavailable")); } + if request.method == super::build_provider::PREPARE_RUN_METHOD + && !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::RUN_PREPARATION_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate run preparation.", + ) + .with_details("plugin.build.unavailable")); + } let mut params = request.params; - if super::build_provider::is_execution_method(&request.method) { + if super::build_provider::is_progress_method(&request.method) { let object = params.as_object_mut().ok_or_else(|| { CoreError::new( ErrorCode::InvalidRequest, @@ -5588,7 +5601,7 @@ fn push_build_progress_locked( let Some(pending) = state.pending.get(id) else { return; }; - if !super::build_provider::is_execution_method(&pending.method) { + if !super::build_provider::is_progress_method(&pending.method) { return; } let Some(operation_id) = pending.operation_id.clone() else { @@ -11259,6 +11272,39 @@ public class Main { } } + #[test] + fn provider_run_preparation_requires_version_four_and_returns_raw_plan() { + for version in [1, 2, 3] { + let harness = plugin_backend(json!({"litheBuild":{"version":version}})); + let error = + build_request(&harness, "lithe/build/prepareRun", json!({}), None).unwrap_err(); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/prepareRun")); + } + let mut harness = plugin_backend(json!({"litheBuild":{"version":4}})); + let operation = build_request(&harness, "lithe/build/prepareRun", json!({}), None).unwrap(); + let id = harness + .server + .await_request("lithe/build/prepareRun") + .unwrap(); + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/build-run-preparation.json" + )) + .unwrap(); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":fixture["result"]})); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], fixture["result"]); + } + #[test] fn provider_test_discovery_uses_the_existing_request_lifecycle() { let mut harness = plugin_backend(json!({"litheBuild":{"version":3}})); @@ -11289,7 +11335,11 @@ public class Main { /// and replies are drained before checking that cancellation stayed final. #[test] fn provider_execution_progress_cancel_and_late_results_are_isolated() { - for (version, method) in [(2, "lithe/build/execute"), (3, "lithe/build/test")] { + for (version, method) in [ + (2, "lithe/build/execute"), + (3, "lithe/build/test"), + (4, "lithe/build/prepareRun"), + ] { let mut harness = Harness::start(|_| {}); harness .server @@ -11356,7 +11406,11 @@ public class Main { /// may contribute progress to that new call. #[test] fn provider_execution_reused_host_ids_reject_old_wire_progress() { - for (version, method) in [(2, "lithe/build/execute"), (3, "lithe/build/test")] { + for (version, method) in [ + (2, "lithe/build/execute"), + (3, "lithe/build/test"), + (4, "lithe/build/prepareRun"), + ] { let mut harness = plugin_backend(json!({"litheBuild":{"version":version}})); let operation = "reused-build".to_string(); let send = |harness: &Harness| { @@ -11408,7 +11462,11 @@ public class Main { #[test] fn provider_execution_validates_results_and_expires_without_sleeping() { - for (version, method) in [(2, "lithe/build/execute"), (3, "lithe/build/test")] { + for (version, method) in [ + (2, "lithe/build/execute"), + (3, "lithe/build/test"), + (4, "lithe/build/prepareRun"), + ] { let mut harness = Harness::start(|_| {}); harness .server @@ -11435,6 +11493,8 @@ public class Main { && event.operation_id.as_deref() == Some(operation.as_str()) }) .clone(); + // Preparation returns raw launch data; the build decoder validates it. + let code = if version == 4 { None } else { code }; assert_eq!(ended.error.as_ref().map(|error| error.code.as_str()), code); if code.is_none() { assert_eq!(ended.result.unwrap()["value"], reply); @@ -11505,7 +11565,7 @@ public class Main { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"]["litheBuild"], - json!({ "versions": [3, 2, 1] }) + json!({ "versions": [4, 3, 2, 1] }) ); let error = build_request(&harness, "lithe/build/model", json!({}), None) @@ -12012,7 +12072,7 @@ public class Main { assert_eq!( initialize["params"]["capabilities"]["experimental"], json!({ - "litheBuild": { "versions": [3, 2, 1] }, + "litheBuild": { "versions": [4, 3, 2, 1] }, "litheFunctions": { "versions": [1, 2] }, "litheVcs": { "versions": [1] } }) diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index a71947345b..c7c39cb94e 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -66,7 +66,7 @@ pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; /// capability keeps the meaning its section had at API level 2. pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (BACKEND, &[1]), - (BUILD_PROVIDERS, &[1, 2, 3]), + (BUILD_PROVIDERS, &[1, 2, 3, 4]), (BUILD_SYSTEMS, &[1, 2]), (DEBUG_ADAPTERS, &[1]), (EXECUTION_PROVIDERS, &[1]), diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index d7fbc4850e..b423c116ec 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -106,6 +106,10 @@ pub enum CoreCommand { BuildTaskPlan, /// Expands a build test run and its result location (`build.testPlan`). BuildTestPlan, + /// Plans typed provider run preparation (`build.prepareRunPlan`). + BuildPrepareRunPlan, + /// Validates prepared launch data (`build.decodeRunPreparation`). + BuildDecodeRunPreparation, /// Plans provider-owned test discovery (`build.testDiscoveryPlan`). BuildTestDiscoveryPlan, /// Validates provider-owned test selectors (`build.decodeTestDiscovery`). @@ -528,6 +532,8 @@ impl CoreCommand { "build.modelPlan" => Some(Self::BuildModelPlan), "build.taskPlan" => Some(Self::BuildTaskPlan), "build.testPlan" => Some(Self::BuildTestPlan), + "build.prepareRunPlan" => Some(Self::BuildPrepareRunPlan), + "build.decodeRunPreparation" => Some(Self::BuildDecodeRunPreparation), "build.testDiscoveryPlan" => Some(Self::BuildTestDiscoveryPlan), "build.decodeTestDiscovery" => Some(Self::BuildDecodeTestDiscovery), "build.dependencyPlan" => Some(Self::BuildDependencyPlan), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 75b71c61f5..83dfbbe455 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -262,6 +262,16 @@ fn execute(request: &str) -> CoreResponse { CoreCommand::BuildTaskPlan => { plugin_response(id, parsed.payload, crate::build::build_task_plan_command) } + CoreCommand::BuildPrepareRunPlan => plugin_response( + id, + parsed.payload, + crate::build::build_prepare_run_plan_command, + ), + CoreCommand::BuildDecodeRunPreparation => plugin_response( + id, + parsed.payload, + crate::build::build_decode_run_preparation_command, + ), CoreCommand::BuildTestPlan => { plugin_response(id, parsed.payload, crate::build::build_test_plan_command) } diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index 1e410b7af7..e6b01d768b 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -5,10 +5,11 @@ use super::support::temporary_root; use crate::build::{ build_check_tool_version_command, build_decode_dependencies_command, build_decode_diagnostics_command, build_decode_model_command, - build_decode_test_discovery_command, build_decode_test_results_command, - build_dependency_plan_command, build_detect_command, build_invocation_plan_command, - build_model_plan_command, build_resolve_options_command, build_sync_fingerprint_command, - build_task_plan_command, build_test_discovery_plan_command, build_test_plan_command, + build_decode_run_preparation_command, build_decode_test_discovery_command, + build_decode_test_results_command, build_dependency_plan_command, build_detect_command, + build_invocation_plan_command, build_model_plan_command, build_prepare_run_plan_command, + build_resolve_options_command, build_sync_fingerprint_command, build_task_plan_command, + build_test_discovery_plan_command, build_test_plan_command, }; use crate::plugins::declarative::{language_server_launch_plan_command, validate_command}; use crate::plugins::marketplace::install_review_command; @@ -2014,3 +2015,111 @@ fn provider_test_discovery_is_bounded_deterministic_and_runnable() { missing_filter["buildSystems"][0]["test"]["filters"] = json!(["suite"]); assert_eq!(validate(missing_filter)["status"], "rejected"); } + +/// Preparation is a typed provider request, never an empty argv configuration. +#[test] +fn provider_run_preparation_selects_targets_and_validates_launch_data() { + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-run-preparation.plugin.json" + )) + .unwrap(); + let fixture: Value = + serde_json::from_str(contract_file!("fixtures/build-run-preparation.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + let plan = |request: Value| { + build_prepare_run_plan_command( + json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), + ) + .unwrap() + }; + let prepared = plan(fixture["request"].clone()); + assert_eq!(prepared["status"], "ok", "{prepared}"); + assert_eq!( + prepared["plan"]["provider"]["method"], + "lithe/build/prepareRun" + ); + assert_eq!( + prepared["plan"]["provider"]["params"]["userArguments"], + json!(["a b"]) + ); + assert_eq!( + prepared["plan"]["provider"]["params"]["target"]["tags"], + json!(["application"]) + ); + assert!(prepared["plan"].get("arguments").is_none()); + for (field, value) in [ + ( + "target", + json!({"name":"Other","path":"app","tags":["test"]}), + ), + ("project", json!({"name":"escape","path":"../escape"})), + ("userArguments", json!(["bad\u{0}"])), + ("outputFile", json!("out.json")), + ] { + let mut invalid = fixture["request"].clone(); + invalid[field] = value; + assert_eq!(plan(invalid)["status"], "rejected"); + } + for version in [1, 2, 3] { + let mut old = manifest.clone(); + old["requires"]["buildProviders"] = json!(version); + assert_eq!(validate(old)["status"], "rejected"); + } + for (field, value) in [ + ("fromTasks", json!({"templates":["run"]})), + ("arguments", json!(["Main"])), + ("execution", json!("service")), + ("extension", json!({"main":"Main"})), + ] { + let mut invalid = manifest.clone(); + invalid["buildSystems"][0]["run"][0][field] = value; + assert_eq!(validate(invalid)["status"], "rejected"); + } + let workspace = TemporaryWorkspace::new("provider-run-launch"); + workspace.write("app/Main.java", "class Main {}"); + let decode = |result: Value| { + build_decode_run_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"workspaceRoot":workspace.root(),"root":"app","result":result}})) + .unwrap() + }; + let valid = decode(fixture["result"].clone()); + assert_eq!(valid["status"], "ok", "{valid}"); + assert_eq!(valid["launch"], fixture["result"]); + for (field, value) in [ + ("executable", json!({"kind":"command","value":"/bin/java"})), + ("executable", json!({"kind":"absolute","value":"java"})), + ("arguments", json!(["bad\u{0}"])), + ("arguments", json!(vec!["arg"; 1025])), + ("workingDirectory", json!("../escape")), + ("workingDirectory", json!(".")), + ("workingDirectory", json!("app/missing")), + ("environment", json!({"1INVALID":"x"})), + ("environment", json!({"A":"bad\u{0}"})), + ("environment", json!({"A":1})), + ] { + let mut invalid = fixture["result"].clone(); + invalid[field] = value; + let result = decode(invalid); + assert_eq!( + result["error"]["code"], "plugin.build.invalidOutput", + "{result}" + ); + } +} + +/// A workspace alias cannot make a launch execute from outside the workspace. +#[cfg(unix)] +#[test] +fn provider_run_working_directory_rejects_external_symlinks() { + let workspace = TemporaryWorkspace::new("provider-run-symlink"); + let outside = TemporaryWorkspace::new("provider-run-outside"); + std::os::unix::fs::symlink(&outside.0, workspace.0.join("escape")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-run-preparation.plugin.json" + )) + .unwrap(); + let result=build_decode_run_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"workspaceRoot":workspace.root(),"root":".","result":{"executable":{"kind":"command","value":"java"}, + "arguments":[],"workingDirectory":"escape","environment":{}}}})).unwrap(); + assert_eq!(result["error"]["code"], "plugin.build.invalidOutput"); +} diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index 7a48f10033..640f68d8a8 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -4280,3 +4280,116 @@ fn automatic_java_selection_uses_shared_fixture_and_current_requirements() { } } } + +/// Saving a selector never stores an executable, generates a model, or loses local defaults. +#[test] +fn build_provider_configurations_persist_before_generation_and_reuse_identity() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/run-configuration/build-provider.json" + )) + .unwrap(); + let root = temporary_root("saved-build-provider"); + fs::create_dir_all(root.join("app")).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + let local = root.join(".lithe/run/local.json"); + fs::write(&local, fixture["unrelated"].to_string()).unwrap(); + let call = |command: &str, payload: Value| -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({"id":"saved-provider","command":command,"payload":payload}) + .to_string(), + )) + .unwrap() + }; + let save = |reference: Value| { + call( + "runConfig.createUserConfiguration", + serde_json::json!({"root":root,"scope":"local","type":"buildProvider","name":fixture["name"],"build":reference}), + ) + }; + let saved = save(fixture["reference"].clone()); + assert_eq!(saved["ok"], true, "{saved}"); + let mut document: Value = + serde_json::from_str(saved["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!(document["toolchain"], fixture["unrelated"]["toolchain"]); + assert_eq!( + document["configurations"][0]["extensions"]["build"], + fixture["reference"] + ); + assert!(document["configurations"][0].get("command").is_none()); + // The Core mutation is read-only until the platform atomically commits it. + assert_eq!( + serde_json::from_slice::(&fs::read(&local).unwrap()).unwrap(), + fixture["unrelated"] + ); + document["configurations"][0]["extensions"]["annotations"] = + serde_json::json!({"label":"keep user metadata"}); + fs::write(&local, document.to_string()).unwrap(); + let inspected = call( + "runConfig.inspect", + serde_json::json!({"root":root,"checkFingerprint":false}), + ); + assert_eq!( + inspected["data"]["status"], fixture["expected"]["inspectionStatus"], + "{inspected}" + ); + let resolved = call( + "runConfig.resolve", + serde_json::json!({"root":root,"toolchainCandidates":[]}), + ); + assert_eq!(resolved["ok"], true, "{resolved}"); + assert_eq!( + resolved["data"]["configurations"].as_array().unwrap().len(), + 1 + ); + assert!(!root.join(".lithe/run/generated.json").exists()); + let mut changed = fixture["reference"].clone(); + changed["userArguments"] = serde_json::json!(["new argument"]); + let updated = save(changed); + assert_eq!(updated["data"]["id"], saved["data"]["id"]); + let updated_doc: Value = + serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!(updated_doc["configurations"].as_array().unwrap().len(), 1); + assert_eq!( + updated_doc["configurations"][0]["extensions"]["annotations"], + serde_json::json!({"label":"keep user metadata"}) + ); + for (field, value) in [ + ("root", serde_json::json!("../escape")), + ("root", serde_json::json!("/outside")), + ("root", serde_json::json!("app\\bad")), + ("projectID", serde_json::json!("")), + ("userArguments", serde_json::json!(["bad\u{0}"])), + ("userArguments", serde_json::json!(vec!["a"; 65])), + ("command", serde_json::json!("java")), + ] { + let mut invalid = fixture["reference"].clone(); + invalid[field] = value; + assert_eq!(save(invalid)["ok"], false, "{field}"); + } + let mut omitted_arguments = document.clone(); + omitted_arguments["configurations"][0]["extensions"]["build"] + .as_object_mut() + .unwrap() + .remove("userArguments"); + fs::write(&local, omitted_arguments.to_string()).unwrap(); + let normalized = call("runConfig.resolve", serde_json::json!({"root":root})); + assert_eq!(normalized["ok"], true, "{normalized}"); + assert_eq!( + normalized["data"]["configurations"][0]["extensions"]["build"]["userArguments"], + serde_json::json!([]) + ); + let mut invalid = document.clone(); + invalid["configurations"][0]["command"] = serde_json::json!("java"); + fs::write(&local, invalid.to_string()).unwrap(); + assert_eq!( + call("runConfig.resolve", serde_json::json!({"root":root}))["ok"], + false + ); +} diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index d4b37cf69c..470413b0ce 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -809,3 +809,16 @@ current conversation has used a skill. Explicit create/import operations write only to native user/project directories and preserve existing skills. Explicit toggles write native settings: Codex by discovered path via its official App Server API, Claude by name via skillOverrides in user or project-local settings. Claude states are configured visibility, not effective managed/session policy. Never persist a separate Lithe enabled flag, move packages to simulate disablement, or claim that a change revokes already loaded session content. File contents remain untrusted document data. Settings discovery and file management must not create conversations, send prompts, or interrupt existing Agent work. + +Build-provider application preparation and execution have separate owners. The +workspace Build session validates a current target and prepares it; the native +Run host owns the application process, output and stop. Explicit persistence +uses the existing local Run v2 layer and stores a typed selector rather than +launch facts. Reopening or restarting re-resolves that selector in the enabled +workspace Provider and prepares afresh. Save-before-run completes before taking +the preparation snapshot; source/configuration changes revoke pending results. +The public Run operation ID spans preparation and process handoff, so UI and Agent +queries keep one execution identity. Stop targets the owned preparation ID before +launch and the owned native process ID afterward. Missing or stale references fail without a CLI or old domain fallback. +A Provider application's native output remains visible even without generated +Run configurations. See `../fixtures/run-configuration/build-provider.json`. diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index c80d085d25..f87c95f7cc 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -961,7 +961,7 @@ widening the test run. Paths, scopes and declared filters are validated before the request is sent. The host's report project list only resolves report globs; it is not a second backend project model. -Only a process choosing protocol version 3 receives `lithe/build/test`. +Only a process choosing protocol version 3 or 4 receives `lithe/build/test`. Versions 1 and 2 explicitly refuse tests. Version 3 retains every version 2 task behavior. Tests use the same Core-assigned operation identity, `lithe/build/progress`, tool hint, bounded `{ exitCode, output }` result, @@ -982,7 +982,7 @@ Provider tests may additionally declare `discovery: true`. This requires both `build.testDiscoveryPlan` accepts the common plan fields and optional `refresh` and `tool`, returning a request for the whole instance: `lithe/build/discoverTests` with its effective options. Project/output-file -selection is refused. This method also requires protocol version 3 and uses +selection is refused. This method also requires protocol version 3 or 4 and uses the existing bounded request/cancellation lifecycle, without execution progress. The provider returns `{ "format": "litheTestDiscovery", "version": 1, @@ -1009,6 +1009,72 @@ list before submitting its exact selectors to `lithe/build/test`; stale toolbar selections cannot run. The backend must reject a selector whose facts have become stale externally. Canonical example: `fixtures/build-test-discovery.json`. +### Provider run preparation (buildProviders 4) + +A run declaration may use `source: "provider"` with +`requires.buildProviders: 4`. This increment supports application targets only: +`fromTargets.tags` is required; `fromTasks`, service execution, nonempty +`arguments`, and extension templates are refused. The optional `name` is a +literal display name. `workingDirectory` is supplied by the prepared result; +the declaration's command-source default has no effect. These declarations +never generate empty command-based persisted Run entries. + +`build.prepareRunPlan` validates a selected `run` declaration ID, required +`project` and `target` selections, effective options, and bounded +`userArguments`. It returns a provider request for `lithe/build/prepareRun`, +with the common instance fields, options/option arguments/environment, +`run`, `project`, `target`, and `userArguments`. An output file is refused. +The host may add the resolved build tool hint. The provider owns upstream +synchronization, build, classpath and launch facts; Core does not reconstruct +those facts from source files or build configuration. + +Only protocol version 4 accepts this method. Version 4 retains task and test +support. Preparation shares the Core-assigned wire `operationID`, progress, +timeout, cancellation and stale-result handling of execution. It ends when +preparation finishes; the application is a separate host-owned process. +A JSON-RPC error aborts preparation and cannot launch an application. It retains +the common LSP `serverError` category; it is not an execution exit-code result +and does not forward a private-function `pluginErrorCode`. + +The successful result is a portable launch plan: + +```json +{ + "executable": { "kind": "toolchain", "value": "project-jdk" }, + "arguments": ["-cp", "classes", "example.Main"], + "workingDirectory": "app", + "environment": {} +} +``` + +The executable is either a host toolchain identifier (`toolchain`) or a bare +command resolved by the host (`command`); absolute paths and shell command +strings are refused. Arguments are separate strings, at most 1,024 entries +and 64 KiB UTF-8 each, without NUL. The environment has at most 64 entries; +keys match `[A-Za-z_][A-Za-z0-9_]*` with at most 128 bytes, values are strings +of at most 64 KiB without NUL. The complete JSON is bounded at 32 MiB. +`workingDirectory` must be an existing workspace-relative directory inside +the selected instance, including after resolving filesystem aliases. +Unknown fields are ignored. The host calls `build.decodeRunPreparation` +with its workspace root, instance root and raw result; any malformed plan +fails as `plugin.build.invalidOutput` before executable resolution. + +The native Build tree re-resolves model IDs before preparation. Source/model, +option, settings, package and workspace changes revoke a pending preparation. +After validation the existing Run host resolves the executable and owns output, +stop, exit status, and argument-resource cleanup. Workspace teardown and plugin +disable stop the application by its exact host operation ID; an old owner +cannot stop a replacement process. The Run host can explicitly save a selector to the existing local Run v2 layer +as `provider: "build.provider"` with `extensions.build`. The reference contains +`pluginID`, `buildSystemID`, instance `root`, `run`, `projectID`, `targetID`, and +raw `userArguments`, never an executable, environment, or cached classpath. +Every restart re-resolves the enabled instance/model and sends a fresh preparation. +Missing plugins/targets/models fail explicitly; there is no command fallback. +Saving and running work before detector-generated Run configurations exist. +Service coordination, debug target registration and Java/Maven migration remain +separate. See `fixtures/build-provider-run-preparation.plugin.json` +and `fixtures/build-run-preparation.json`. + ### Build providers Some tools expose their model only through their own API (the Gradle Tooling @@ -1061,10 +1127,11 @@ The manifest only says how to start the provider and which sources use it. Everything else is negotiated in the LSP `initialize` exchange, so a provider can add optional data within a negotiated protocol version: -- The host sends `capabilities.experimental.litheBuild: { "versions": [3, 2, 1] }`, +- The host sends `capabilities.experimental.litheBuild: { "versions": [4, 3, 2, 1] }`, the build-provider protocol versions it speaks. Version 1 is the two requests above with the params and result formats of this section; - version 2 adds typed task execution; version 3 adds typed test execution. + version 2 adds typed task execution; version 3 adds typed test execution + and discovery; version 4 adds application run preparation. - The provider answers `capabilities.experimental.litheBuild: { "version": 1 }` with the version it chose from that list. A provider that answers no version, or one the host did not offer, is not used for build requests; a diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-run-preparation.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-run-preparation.plugin.json new file mode 100644 index 0000000000..eb5422c65f --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-run-preparation.plugin.json @@ -0,0 +1,208 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-run-preparation", + "displayName": "Provider Run Preparation Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 4, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project", + "target" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "results": { + "decoder": "junitXml", + "reports": [ + "build/test-results/**/*.xml" + ] + }, + "discovery": true + }, + "run": [ + { + "id": "application", + "source": "provider", + "name": "Run Application", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [] + } + ] + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-run-preparation.json b/shared/contracts/plugins/v1/fixtures/build-run-preparation.json new file mode 100644 index 0000000000..bacabb6781 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-run-preparation.json @@ -0,0 +1,39 @@ +{ + "request": { + "root": ".", + "run": "application", + "project": { + "name": "app", + "path": "app" + }, + "target": { + "name": "Main", + "path": "app", + "tags": [ + "application" + ] + }, + "options": { + "offline": true + }, + "userArguments": [ + "a b" + ] + }, + "result": { + "executable": { + "kind": "toolchain", + "value": "project-jdk" + }, + "arguments": [ + "-cp", + "classes", + "example.Main", + "a b" + ], + "workingDirectory": "app", + "environment": { + "APP_ENV": "test" + } + } +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index e502f89b19..eb05041a4a 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -28,7 +28,7 @@ "additionalProperties": false, "properties": { "backend": { "description": "Required exactly when backend is present: the plugin's own process serving its web pages.", "enum": [1] }, - "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution and version 3 test execution.", "enum": [1, 2, 3] }, + "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution, version 3 test execution/discovery, and version 4 application run preparation.", "enum": [1, 2, 3, 4] }, "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1] }, "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, @@ -2345,6 +2345,10 @@ "id" ], "properties": { + "source": { + "description": "Provider-owned application preparation (buildProviders 4), fromTargets only, without host command templates.", + "enum": ["provider"] + }, "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", diff --git a/shared/contracts/run-configuration-v2.schema.json b/shared/contracts/run-configuration-v2.schema.json index 6d4d258f3e..0f32a39963 100644 --- a/shared/contracts/run-configuration-v2.schema.json +++ b/shared/contracts/run-configuration-v2.schema.json @@ -51,6 +51,56 @@ "type": "string", "pattern": "^(?!/)(?!.*(^|/)\\.\\.(?:/|$)).*$" }, + "buildProviderReference": { + "type": "object", + "required": [ + "pluginID", + "buildSystemID", + "root", + "run", + "projectID", + "targetID" + ], + "properties": { + "pluginID": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "buildSystemID": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "root": { + "$ref": "#/$defs/relativePath" + }, + "run": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "projectID": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "targetID": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "userArguments": { + "type": "array", + "maxItems": 64, + "items": { + "type": "string", + "maxLength": 4096 + } + } + }, + "additionalProperties": false + }, "configuration": { "type": "object", "required": ["id", "provider"], @@ -90,7 +140,25 @@ "disabled": { "type": "boolean" }, "source": { "type": "string" } }, - "additionalProperties": false + "additionalProperties": false, + "allOf": [{ + "if": { "properties": { "provider": { "const": "build.provider" } } }, + "then": { + "required": ["extensions"], + "properties": { + "execution": { "const": "application" }, + "command": false, + "args": { "maxItems": 0 }, + "env": { "maxProperties": 0 }, + "toolchains": { "maxProperties": 0 }, + "debug": false, + "extensions": { + "required": ["build"], + "properties": { "build": { "$ref": "#/$defs/buildProviderReference" } } + } + } + } + }] } } } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index e14e0bc8c7..779518de99 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1112,6 +1112,8 @@ package manager owns the download and Lithe does not infer bytes from logs. | `build.testPlan` | Plan a test run for a scope, suite, or case and say where its reports will be | | `build.testDiscoveryPlan` | Plan opt-in Provider test discovery for an instance | | `build.decodeTestDiscovery` | Validate, deduplicate and order Provider test selectors | +| `build.prepareRunPlan` | Plan Provider-owned application preparation from a model target | +| `build.decodeRunPreparation` | Validate portable launch data before the native Run host starts a process | | `build.dependencyPlan` | Expand the command that prints a project's dependency tree, or the request a build provider answers | | `build.decodeModel` | Decode model output or a provider result into projects, targets, tasks, content roots, option values, and run entries | | `build.decodeTestResults` | Decode a test run's reports and output into per-case results and a summary | @@ -2568,14 +2570,17 @@ and virtual-location representation as ordinary navigation. `lsp.buildRequest` accepts `{ sessionId, operationId?, method, params, timeoutMilliseconds? }` and returns `{ operationId }`. `method` and `params` are the `provider` field of a `build.modelPlan`, `build.dependencyPlan` or -provider-sourced `build.taskPlan`/`build.testPlan` result. Version 1 accepts +provider-sourced `build.taskPlan`/`build.testPlan`, `build.testDiscoveryPlan`, or +`build.prepareRunPlan` result. Version 1 accepts `lithe/build/model` and `lithe/build/dependencies`; version 2 adds `lithe/build/execute`, version 3 adds `lithe/build/test` with the same bounded execution lifecycle and `lithe/build/discoverTests` with the raw result lifecycle. +Version 4 adds `lithe/build/prepareRun`, with execution progress but a raw launch +plan passed to `build.decodeRunPreparation` before native process startup. Other methods are `invalid_request`; tasks on version 1 -and tests on versions 1/2 are `not_supported`. +and tests on versions 1/2 or run preparation on versions 1/2/3 are `not_supported`. Every session offers -`capabilities.experimental.litheBuild: { versions: [3, 2, 1] }` in `initialize`; a +`capabilities.experimental.litheBuild: { versions: [4, 3, 2, 1] }` in `initialize`; a session whose process did not answer one of those versions rejects the request with `not_supported`, so a shared provider that does not speak the protocol still serves its language. `timeoutMilliseconds` replaces the session's request @@ -2997,6 +3002,21 @@ object in the local layer (e.g. provides defaults for every configuration's `extensions.java.*`. A non-empty per-configuration toolchain path overrides the corresponding project default. +Provider applications can be explicitly saved with `runConfig.createUserConfiguration` +using `{root,scope:"local",name,type:"buildProvider",build:{pluginID,buildSystemID, +root,run,projectID,targetID,userArguments?}}`. Core upserts the existing local +reference by selector identity and retains its `user:` ID, unrelated configurations +and project toolchain defaults. `extensions.build` in Run v2 holds the typed +reference; process commands, top-level args/environment/toolchains/debug are +refused for `build.provider`. Current model membership is revalidated by the +native build workflow before every preparation. No generated document is needed: +`runConfig.inspect` recognizes explicit saved local references and `runConfig.resolve` +merges them against an empty generated layer. A workspace without generated data +or an explicit saved Provider reference retains the existing missing state. +The platform guards the source snapshot, atomically writes only the local layer, +then re-reads the result. `shared/fixtures/run-configuration/build-provider.json` +is the canonical persistence example. + `runConfig.updateOptions` and `runConfig.createUserConfiguration` are pure document transformations. They validate scope, paths, supported types, stable IDs, main classes, modules, and argument parsing, then return UTF-8 JSON in the @@ -3376,11 +3396,11 @@ each process, the host runs it with the discovered executable, and Core decodes what it printed or wrote. A build system with a `provider` requires `buildProviders: 1`, version 2 when -a task uses `source: "provider"`, or version 3 when tests do. -Core validates and plans all three versions; +a task uses `source: "provider"`, version 3 when tests do, or version 4 for +provider application run preparation. Core validates and plans all four versions; a host advertises each only after implementing its lifecycle and requests. -macOS advertises all three and routes provider tasks and tests through the existing workspace -session. Windows does not yet advertise Provider lifecycle support. +macOS advertises all four and routes provider tasks, tests and run preparation through +the existing workspace session. Prepared applications use the existing native Run host. Windows does not yet advertise Provider lifecycle support. Except `build.detect`, every command accepts `{ manifest, buildSystemID, request }`, revalidates the manifest, and answers @@ -3423,6 +3443,8 @@ and the directories are absolute host paths used only as substitution text. | `build.testPlan` | plan fields plus `project?`, `target?`, `suite?`, `case?`, `userArguments?`, `projects?` (the model's project directories) | command plan or `provider: {method: "lithe/build/test", params}` (buildProviders 3), plus `results`: `null`, `{ decoder, reports }` with workspace-relative globs (for the selected project, or for the root and every listed project when none is selected), or command-only `{ decoder: "hostIntegration" }`. Command filters that templates cannot place, such as a case without its suite when the tool only addresses `Suite#case`, are `plugin.build.invalidSelection`; Provider filters remain separate typed values and must be declared supported | | `build.testDiscoveryPlan` | common plan fields plus `refresh?`, `tool?`; no project/output file | `plan.provider` for `lithe/build/discoverTests`, only with `test.discovery: true` (Provider v3), using the same effective options and effect metadata | | `build.decodeTestDiscovery` | common plan fields plus canonical `result` | `discovery.tests` with validated `{id, suite, case, project?, target?}` selectors; bounded at 10,000 items/32 MiB, deterministic and exactly runnable; malformed replies reject in full | +| `build.prepareRunPlan` | common plan fields, `run`, required `project`/`target`, `userArguments?`; no output file | `plan.provider` for `lithe/build/prepareRun` (Provider v4), selected declaration/tags/paths/options validated | +| `build.decodeRunPreparation` | `workspaceRoot`, instance `root`, raw `result` | `launch` with portable `{executable:{kind,value},arguments,workingDirectory,environment}`; bounded strings and workspace directory containment, `plugin.build.invalidOutput` on invalid result | | `build.decodeModel` | `{ workspaceRoot, root, output?, outputFile?, result?, options? }`; `result` is the provider's JSON answer | `model`: `{ projects, targets, tasks, contentRoots, optionValues, run }`; `run` entries carry `buildSystem: { id, root, projectID, targetID?, optionFingerprint }` | | `build.decodeTestResults` | `{ workspaceRoot, root, project?, projects?, output, notBeforeMillis?, suite? }`; without `project` the reports of the root and every listed model project are read | `results`: `{ tests: [{ suite, case, status, message?, durationMillis?, path?, line?, invocations }], summary: { total, passed, failed, errors, skipped }, success }`; repeated invocations of one case merge into the most severe status | | `build.decodeDiagnostics` | `{ workspaceRoot, root, output }` | `diagnostics`: `[{ path, line, column?, severity, message }]`, deduplicated, inside the workspace only | diff --git a/shared/fixtures/run-configuration/build-provider.json b/shared/fixtures/run-configuration/build-provider.json new file mode 100644 index 0000000000..ab32e689cf --- /dev/null +++ b/shared/fixtures/run-configuration/build-provider.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "reference": { + "pluginID": "dev.example.gradle", + "buildSystemID": "gradle", + "root": "app", + "run": "application", + "projectID": "project-id", + "targetID": "target-id", + "userArguments": [ + "a b", + "--literal=one two" + ] + }, + "name": "Run Main", + "unrelated": { + "version": 2, + "toolchain": { + "java": { + "homePath": "/fixture/jdk" + } + }, + "configurations": [] + }, + "expected": { + "inspectionStatus": "ready", + "configurationCount": 1, + "provider": "build.provider" + } +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 64aa816439..5630a4305d 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9374,7 +9374,13 @@ "native.loadModelBeforeWorkspaceTests": "Load or reload the project model before running all tests, so subproject reports are collected.", "native.discoverTests": "Discover Tests", "native.discoveredTests": "Discovered Tests", - "native.noTestsDiscovered": "No tests discovered." + "native.noTestsDiscovered": "No tests discovered.", + "native.invalidBuildProviderLaunchPlan": "The build provider returned an invalid launch plan.", + "native.saveProviderRunConfiguration": "Save provider run configuration to .lithe/run/local.json", + "native.providerRunSavingUnavailable": "Saving build provider run configurations is unavailable.", + "native.loadProviderModelBeforeRun": "Load the current build model and enable its provider before running this configuration.", + "native.providerRunSaveConflict": "Run configuration changed while saving. Reload and try again.", + "native.savedProviderRunUnavailable": "The saved provider run configuration is unavailable." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index aa5c9baabd..6c01d2275a 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9374,7 +9374,13 @@ "native.loadModelBeforeWorkspaceTests": "运行全部测试前,请加载或重新加载项目模型,以便收集子项目的测试报告。", "native.discoverTests": "发现测试", "native.discoveredTests": "已发现的测试", - "native.noTestsDiscovered": "未发现测试。" + "native.noTestsDiscovered": "未发现测试。", + "native.invalidBuildProviderLaunchPlan": "构建提供方返回了无效的启动计划。", + "native.saveProviderRunConfiguration": "将提供方运行配置保存到 .lithe/run/local.json", + "native.providerRunSavingUnavailable": "当前无法保存构建提供方运行配置。", + "native.loadProviderModelBeforeRun": "请先加载当前构建模型并启用对应提供方,再运行此配置。", + "native.providerRunSaveConflict": "保存期间运行配置已更改。请重新加载后再试。", + "native.savedProviderRunUnavailable": "已保存的提供方运行配置不可用。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 268502e8a4..a360aed6f9 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -89210,6 +89210,72 @@ "parameters": { "arg1": "string" } + }, + { + "id": "native.invalidBuildProviderLaunchPlan", + "description": "Native UI: Invalid build provider launch plan", + "platforms": [ + "macos" + ], + "macos": { + "key": "The build provider returned an invalid launch plan.", + "arguments": [] + } + }, + { + "id": "native.saveProviderRunConfiguration", + "description": "Native UI: Save provider run configuration to .lithe/run/local.json", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save provider run configuration to .lithe/run/local.json", + "arguments": [] + } + }, + { + "id": "native.providerRunSavingUnavailable", + "description": "Native UI: Saving build provider run configurations is unavailable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saving build provider run configurations is unavailable.", + "arguments": [] + } + }, + { + "id": "native.loadProviderModelBeforeRun", + "description": "Native UI: Load the current build model and enable its provider before running this configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load the current build model and enable its provider before running this configuration.", + "arguments": [] + } + }, + { + "id": "native.providerRunSaveConflict", + "description": "Native UI: Provider run save conflict", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run configuration changed while saving. Reload and try again.", + "arguments": [] + } + }, + { + "id": "native.savedProviderRunUnavailable", + "description": "Native UI: Saved provider selection unavailable", + "platforms": [ + "macos" + ], + "macos": { + "key": "The saved provider run configuration is unavailable.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index a286c5400a..a38b728a56 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -2,7 +2,7 @@ "id": "build-system-plugins", "area": "扩展", "group": "插件与主题", - "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退;buildProviders 2/3 原生任务与测试执行复用工作区后端会话,带进度、取消、结果与配置失效隔离;协议 3 可按声明发现测试并在现有构建树按精确选择器运行,源码/设置变化撤销旧清单", + "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退;buildProviders 2/3 原生任务与测试执行复用工作区后端会话,带进度、取消、结果与配置失效隔离;协议 3 可按声明发现测试并在现有构建树按精确选择器运行,源码/设置变化撤销旧清单;协议 4 可为应用目标准备启动计划,Core 校验后交给原生 Run 管理进程;Provider 应用可保存到本地 Run v2 配置并重新准备后重启", "macos": { "evidence": [ "rust/lithe-core/src/build", @@ -36,7 +36,17 @@ "shared/contracts/plugins/v1/fixtures/build-provider-tests.plugin.json", "shared/contracts/plugins/v1/fixtures/build-test-protocol.json", "rust/lithe-core/src/build/test_discovery.rs", - "shared/contracts/plugins/v1/fixtures/build-test-discovery.json" + "shared/contracts/plugins/v1/fixtures/build-test-discovery.json", + "rust/lithe-core/src/build/run_preparation.rs", + "shared/contracts/plugins/v1/fixtures/build-provider-run-preparation.plugin.json", + "shared/contracts/plugins/v1/fixtures/build-run-preparation.json", + "macos/Sources/LitheExecutionModule/Services/RunService.swift", + "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift", + "shared/fixtures/run-configuration/build-provider.json", + "shared/contracts/run-configuration-v2.schema.json", + "macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift", + "macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift", + "macos/Tests/LitheTests/IdeCapabilitiesTests.swift" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -51,5 +61,5 @@ "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实工具及 Windows 工作流仍待验收。" + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。命令模型 run 配置尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实工具及 Windows 工作流仍待验收。;buildProviders 4 应用目标准备接入原生 Build 树,构建进度在 Build Output 展示,验证后转交 RunService 解析工具与启动/停止应用;准备期间源码、选项、工作区或停用变化拒绝迟到启动,旧操作 ID 不得停止替换进程;Core 拒绝绝对可执行路径、越界目录/符号链接及超限参数环境。服务编排、调试与 Java/Maven 消费者迁移尚未完成;真实插件/发行包和 Windows 工作流仍待验收。;Run v2 本地层显式保存 Provider 选择器,参数保持原始数组;无 generated.json 也能读回/显示,重复保存保留 ID/其他配置/项目环境;重启重新进入当前 Provider,停止与晚到结果按身份隔离。原生入口保存工作区文档后再准备,Run 窗口在未生成旧配置时仍显示 Provider 输出。;Agent 入口回归从真实本地配置启动,验证准备到进程退出保持公开操作 ID、准备中停止与重启重新准备,旧 ID 不能停止替换操作;Provider 与进程为可控替身,真实插件端到端验收仍待完成。;配套独立仓库 Node/Java SDK 源码补齐协议 2/3/4 的处理器、版本准入与进度;无页面示例经真实 Core 验证非零构建退出码、精确筛选、运行前构建及准备计划、并行工作区、取消工具清理和完整包不变,另保留锁定旧契约的兼容场景与 TypeScript 调用方编译检查。SDK 尚未发布,真实 Maven/JDT 与 Windows 验收仍待完成。" } From 215f67de718589c4abee6efc21dc6f0289b667cc Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Tue, 27 Oct 2026 17:17:44 +0800 Subject: [PATCH 04/76] feat(plugins): prepare native provider debug sessions (#1495) --- ...026-10-26-build-provider-task-execution.md | 31 +- macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../Core/Rust/RustBuildSystemOperations.swift | 23 +- .../RustDeclarativePluginOperations.swift | 5 +- .../Models/AppModel/AppModel+Debugging.swift | 41 ++- .../Build/MacBuildProviderSessions.swift | 5 + .../Debug/MacBuildDebugLaunchResolver.swift | 43 +++ .../Platform/MacOS/MacServiceContainer.swift | 29 +- .../MacOS/Plugins/MacLivePluginRegistry.swift | 29 ++ .../DebugLaunchConfigurationResolver.swift | 17 +- .../Views/Run/BuildSystemToolWindowView.swift | 9 + .../Debug/DebugAdapterContracts.swift | 13 +- .../Debug/DebugProviderDescriptor.swift | 14 +- .../Execution/BuildSystemContracts.swift | 26 +- .../Plugins/DeclarativePluginContracts.swift | 15 +- .../GenericDebugFeatureModel.swift | 25 +- .../Runtime/DebugAdapterSessionManager.swift | 105 ++++--- .../BuildSystemsFeatureModel.swift | 14 + .../Services/BuildSystemService.swift | 64 +++- .../LitheModuleAPI/Plugins/PluginTypes.swift | 4 +- .../DebugModuleTests.swift | 274 ++++++++++++++++++ .../BuildSystemServiceTests.swift | 115 ++++++++ .../LitheTests/PluginDebugAdapterTests.swift | 89 ++++++ .../PluginLiveActivationTests.swift | 40 +++ .../RustBuildSystemOperationsTests.swift | 26 ++ rust/lithe-core/src/build/commands.rs | 24 ++ .../lithe-core/src/build/debug_preparation.rs | 120 ++++++++ rust/lithe-core/src/build/manifest.rs | 28 ++ rust/lithe-core/src/build/mod.rs | 1 + rust/lithe-core/src/build/run_preparation.rs | 18 +- rust/lithe-core/src/debug/adapters.rs | 70 ++++- .../src/lsp/interface/build_provider.rs | 25 +- rust/lithe-core/src/lsp/interface/client.rs | 6 + rust/lithe-core/src/lsp/interface/engine.rs | 59 +++- rust/lithe-core/src/plugins/capabilities.rs | 4 +- rust/lithe-core/src/plugins/declarative.rs | 14 + rust/lithe-core/src/protocol/command.rs | 6 + rust/lithe-core/src/runtime/dispatcher.rs | 10 + rust/lithe-core/src/tests/build_systems.rs | 115 ++++++++ shared/contracts/application-boundary.md | 36 +++ shared/contracts/plugins/v1/README.md | 58 +++- .../v1/fixtures/build-debug-preparation.json | 28 ++ ...ild-provider-debug-preparation.plugin.json | 254 ++++++++++++++++ .../plugins/v1/plugin-manifest.schema.json | 13 +- shared/contracts/rust-core-api.md | 6 +- .../fixtures/debug/adapter-selection-v1.json | 118 ++++++++ shared/fixtures/debug/v1-baseline.json | 24 ++ shared/i18n/locales/en-US.json | 3 +- shared/i18n/locales/zh-CN.json | 3 +- shared/i18n/messages.json | 11 + .../features/build-system-plugins.json | 10 +- .../features/plugin-debug-adapters.json | 11 +- 53 files changed, 2028 insertions(+), 107 deletions(-) create mode 100644 macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift create mode 100644 rust/lithe-core/src/build/debug_preparation.rs create mode 100644 shared/contracts/plugins/v1/fixtures/build-debug-preparation.json create mode 100644 shared/contracts/plugins/v1/fixtures/build-provider-debug-preparation.plugin.json create mode 100644 shared/fixtures/debug/adapter-selection-v1.json diff --git a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md index eb0bcca254..eba7173758 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md +++ b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md @@ -4,7 +4,7 @@ ## 先说结论 -构建插件现在可以在自己的工作区 Provider(提供构建能力的后端进程)里执行任务、发现和运行测试,而不是把每个操作都展开为宿主命令。原生 Build 窗口复用现有工作区会话、输出、诊断、取消和进程清理,测试报告复用现有解析器,不要求插件增加网页或调用私有函数。这是 [#1495](https://github.com/1lck/Lithe-IDEA/issues/1495) 阶段 C 的构建、测试与应用启动准备基础能力;Maven/Java 业务迁移、多服务编排,以及运行准备与既有调试适配器注册表的衔接不在本决策已实现的范围内。 +构建插件现在可以在自己的工作区 Provider(提供构建能力的后端进程)里执行任务、发现和运行测试,而不是把每个操作都展开为宿主命令。原生 Build 窗口复用现有工作区会话、输出、诊断、取消和进程清理,测试报告复用现有解析器,不要求插件增加网页或调用私有函数。这是 [#1495](https://github.com/1lck/Lithe-IDEA/issues/1495) 阶段 C 的构建、测试与应用启动准备基础能力;Maven/Java 业务迁移、多服务编排,及 Java/JDT 调试准备不在本决策已实现的范围内。插件自有原生程序的调试准备已经接到既有调试适配器注册表。 ## 问题 @@ -70,7 +70,7 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 准备中的源码、模型、配置、插件或工作区变化使旧结果失效,解码完成后也要再次检查。应用启动后不因普通源码编辑就终止;停用所属插件或关闭工作区才按对应操作 ID 停止,旧操作 ID 不能停止新进程。运行目录只能是工作区内、属于当前实例的已有目录;符号链接解析后的目录也必须满足这一限制。没有新增运行资源目录、下载或跨工作树缓存,既有平台进程与临时参数 adapter 继续负责可写位置,安装包保持只读。 -首次从 Build 树运行仍在 Build 窗口管理准备;从 Run 重启时,Run 的停止动作取消对应准备 ID,成功启动后改由进程 ID 管理。应用输出不再要求旧式检测器先生成 Run 配置,也不会因缺少 generated.json 被配置引导页遮住。多服务编排与调试目标注册仍未接入,不能把此增量表述为阶段 C 或 Java/Maven 迁移全部完成。 +首次从 Build 树运行仍在 Build 窗口管理准备;从 Run 重启时,Run 的停止动作取消对应准备 ID,成功启动后改由进程 ID 管理。应用输出不再要求旧式检测器先生成 Run 配置,也不会因缺少 generated.json 被配置引导页遮住。多服务编排与 Java/JDT 调试准备仍未接入,不能把此增量表述为阶段 C 或 Java/Maven 迁移全部完成。 ### 保存选择器,重启时重新准备 @@ -88,6 +88,20 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 两套无页面示例使用虚构引擎展示协议,端到端回归通过真实 Core 检查构建退出码、精确筛选、运行前构建与准备计划、并行工作区隔离、取消后的工具清理,以及包含 SDK/JAR 的完整包字节不变。SDK 处理器属于开发中的源码,不代表已发布 SDK 或现有 Maven/Java 插件完成迁移;发布契约副本仍必须等主仓库合并后运行同步工具。 +### 调试启动保留已选适配器的身份 + +现有调试适配器注册表(DebugAdapter Registry)已经定义插件 ID 与适配器 ID 组成的身份,但 macOS 会话过去只按源文件选择语言默认适配器。工程目标可能没有打开的源文件,同一语言也可能有多个调试器,所以明确选中的适配器现在通过 `DebugLaunchConfiguration.adapterProviderID` 交给原有会话管理器。原有插件文件启动入口也携带这份身份,重试与配置变换继续保留它。 + +开发者要从已安装、已启用的注册中取得 `插件 ID/适配器 ID`,并在启动前完成现有 Core 参数及权限校验。正确例子是把已校验的 LLDB 启动参数交给 `dev.example.native/lldb`;不要只传一个 `.c` 文件,再期待宿主碰巧选中同一个调试器。明确身份已经消失、被停用或不支持该请求时,启动在创建进程前失败。不能因此改用同语言的另一插件,也不能把身份当成可调用私有函数、任意进程或端口的权限。 + +宿主保留所有适配器的完整身份,包括只能附加到进程的适配器;它们不会挤占文件启动的默认项。源码断点传给匹配语言的已注册适配器(未限制语言的原生适配器接收全部源码),并在之后启动时重放;函数、异常与数据断点按当前会话的适配器身份更新。新增会话启动失败只关闭自己的资源,原会话和选择继续保留。这里不增加资源目录、下载或缓存;插件进程仍由既有平台 adapter 放在原有可写状态位置,安装包保持只读。 + +`buildProviders: 5` 增加独立的原生程序调试准备请求。运行声明只引用同插件适配器;后端先构建,再返回程序路径、参数、工作目录和环境,不得夹带适配器身份、进程、端口或任意调试协议参数。Core 检查程序属于当前工程实例且为已有文件;平台交接前再次检查可执行权限和符号链接,随后复用注册表的目标绑定生成参数。`debugAdapters: 2` 只增加参数与环境的类型化目标绑定,原有 DAP 引擎、适配器资源与权限规则继续生效。 + +Build 树和已保存的 Provider Run 配置都有 Debug 入口,启动前先保存文档。异步准备、Core 解码和宿主交接共享已有运行准备的配置、源码与模型失效判断;调试停止只取消对应准备,准备成功后由 Debug 会话负责资源。正确例子是插件自己的构建引擎生成当前程序,再交给自己声明的 LLDB 入口;不要把 `prepareRun` 返回的命令改名成调试计划,更不能从 Provider 返回另一个插件的私有函数或开放端口。两个 SDK 同步增加类型化入口并保留版本准入;正式契约副本仍在合并后同步。 + +这个纵向增量只覆盖插件自有原生可执行程序。Java/JDT、跨插件准备与长期服务仍未迁移,不能据此声称整个阶段 C 或 Java Support 拆分已完成。 + ## 考虑过的备选方案 - **扩展独立页面 backend。** 它是全应用范围的私有服务;改为工作区服务需要重复已有 Provider 的作用域和清理。执行首先复用现有工作区机制。 @@ -96,6 +110,7 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 - **让测试复用任务方法并让宿主推导筛选命令。** 无法证明旧后端支持测试,也容易扩大筛选范围;单独协商测试方法,保留选择器为类型化数据。报告解析复用现有实现,不增加另一套测试报告系统。 - **只在宿主源码里找测试,或返回不可运行的标签。** 宿主会拥有第二份框架事实,测试清单和实际运行容易不一致。插件返回上游选择器,Core 只复用现有测试计划校验;当前不支持精确筛选的工具暂不声明发现能力。 - **一次补齐全部 Run/Test/Debug。** 各领域有不同目标和状态要求;先交付可独立验证的执行路径,后续基于真实需求补类型化能力,复用 #1210 的调试契约。 +- **让 Provider 准备后伪造一个带语言后缀的文件来选调试器。** 会把工程目标误当成文件,遇到同语言多个适配器或插件停用时选错。直接传递现有注册身份,不增加另一套调试器路由与进程机制。 ## 后果 @@ -120,13 +135,19 @@ Agent 入口回归从真实本地 Run 配置读取选择器,用可控 Provider ./scripts/verify-rust-core-comments.sh ./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --jobs 6 --filter 'BuildSystemServiceTests|ExecutionModuleTests|MacBuildProviderSessionsTests|RustBuildSystemOperationsTests|BuildToolProviderResolutionTests|PluginWebBridgeTests|BuildSystemToolWindowTests|RunConfigurationIntegrationTests|RunWorkflowCoordinatorTests|IdeCapabilitiesTests' +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'BuildSystemServiceTests|RustBuildSystemOperationsTests|MacBuildProviderSessionsTests|LitheDebugModuleTests|PluginDebugAdapterTests|PluginLiveActivationTests|RunConfigurationIntegrationTests|RunWorkflowCoordinatorTests' cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib build_systems cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib run_configuration +cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib provider_debug cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib lsp ``` 当前本地逐项计时证据为 `saved-provider-complete-macos.json`、`saved-provider-core.json`、`provider-sdk-unit.json` 与 `provider-sdk-native.json`,对应 HTML/JUnit 保存在 `.artifacts/test-stability/`。SDK 兼容场景另外使用契约锁定的 Core,原生工作流场景使用显式工作区源码快照,不能把日志里的基线 SHA 当作完整已提交实现。Java 产物检查 Java 8 字节码;本地运行时和 Windows 实机证据分别记录,不能用编译替代运行。 +调试身份交接的本地回归记录在 `provider-debug-routing-final.json` 及对应 HTML/JUnit:182 个测试通过,包含真实 Rust Core 桥接后 DAP 参数不混入宿主身份、无源文件目标、明确身份失效、请求类型不匹配、多适配器断点、失败后恢复原会话及同步回调替换会话后的精确清理。测试使用受控会话,不代表真实插件适配器、Provider 调试准备或 Windows 已验收。 + +原生调试准备的本地增量报告是 `provider-debug-preparation-macos.json`(375 项)、`provider-debug-preparation-core.json`(1,304 项)、`provider-debug-sdk-unit.json`(114 项)和 `provider-debug-sdk-native.json`(6 项),均有对应 HTML/JUnit。报告覆盖当前 Swift/Node/JDK 运行时的受控宿主与真实 SDK 进程;原生调试准备取消后构建子进程结束、另一工作区仍可用,完整插件包保持只读。平台交接回归另验程序不可执行、程序/实例别名变化以及插件撤回和替换;没有启动真实 LLDB、JDT 或 Windows 产品,所以功能矩阵继续保持 `pending`。 + 在 `lithe-plugin-docs` 运行以下入口可以复验 SDK 部分: ```bash @@ -161,3 +182,9 @@ node scripts/sdk-e2e.mjs --source /path/to/Lithe-IDEA --working-tree --build-wor - `shared/fixtures/run-configuration/build-provider.json` - `macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift` - `macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift` +- `macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift` +- `macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift` +- `macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacLivePluginRegistry.swift` +- `macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift` +- `shared/fixtures/debug/adapter-selection-v1.json` diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 28fe6011ce..302315599f 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -3710,6 +3710,7 @@ "The build provider failed to start." = "The build provider failed to start."; "The build provider returned an invalid launch plan." = "The build provider returned an invalid launch plan."; "The build provider stopped before it was ready." = "The build provider stopped before it was ready."; +"The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled." = "The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled."; "The build tool exited with code %lld." = "The build tool exited with code %lld."; "The build tool output is too large or not UTF-8." = "The build tool output is too large or not UTF-8."; "The build tool timed out." = "The build tool timed out."; @@ -7215,6 +7216,7 @@ "native.providedByLitheForThisLanguage" = "Provided by Lithe for this language"; "native.provider" = "Provider"; "native.providerConfigurationParsingIsUnavailable" = "Provider configuration parsing is unavailable."; +"native.providerDebugUnavailable" = "The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled."; "native.providerDetails" = "Provider details"; "native.providerName" = "Provider name"; "native.providerRequiresAnAPIKey" = "Provider requires an API key"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index f2355995c2..ec248a4772 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3710,6 +3710,7 @@ "The build provider failed to start." = "构建 Provider 启动失败。"; "The build provider returned an invalid launch plan." = "构建提供方返回了无效的启动计划。"; "The build provider stopped before it was ready." = "构建 Provider 在就绪前已停止。"; +"The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled." = "构建插件的调试程序或适配器不可用。请重新构建,并检查插件是否已启用。"; "The build tool exited with code %lld." = "构建工具已退出,退出码 %lld。"; "The build tool output is too large or not UTF-8." = "构建工具的输出过大或不是 UTF-8 编码。"; "The build tool timed out." = "构建工具超时。"; @@ -7215,6 +7216,7 @@ "native.providedByLitheForThisLanguage" = "由 Lithe 为该语言提供"; "native.provider" = "识别器"; "native.providerConfigurationParsingIsUnavailable" = "供应商配置解析暂不可用。"; +"native.providerDebugUnavailable" = "构建插件的调试程序或适配器不可用。请重新构建,并检查插件是否已启用。"; "native.providerDetails" = "服务商详情"; "native.providerName" = "服务商名称"; "native.providerRequiresAnAPIKey" = "服务商需要 API 密钥"; diff --git a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift index 089cb93e27..6681197cf8 100644 --- a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift @@ -123,6 +123,18 @@ struct RustBuildSystemOperations: BuildSystemPlanning { func prepareRunPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { + try preparationPlan(target, context: context, run: run, project: project, selectedTarget: selectedTarget, + userArguments: userArguments, command: "build.prepareRunPlan") + } + + func prepareDebugPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { + try preparationPlan(target, context: context, run: run, project: project, selectedTarget: selectedTarget, + userArguments: userArguments, command: "build.prepareDebugPlan") + } + + private func preparationPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String], command: String) throws -> BuildProviderRequest { struct Request: Encodable { let root: String let pluginDirectory: String? @@ -133,7 +145,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let target: BuildSelection let userArguments: [String] } - let outcome: Outcome = try execute("build.prepareRunPlan", target: target, + let outcome: Outcome = try execute(command, target: target, request: Request(root: context.root, pluginDirectory: context.pluginDirectory?.path, workspaceStateDirectory: context.workspaceStateDirectory?.path, options: context.options, run: run, project: project, target: selectedTarget, userArguments: userArguments)) @@ -152,6 +164,15 @@ struct RustBuildSystemOperations: BuildSystemPlanning { return outcome.value.launch } + func decodeDebugPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, run: String, + result: ToolingJSONValue) throws -> BuildDebugPreparation { + struct Request: Encodable { let workspaceRoot: String; let root: String; let run: String; let result: ToolingJSONValue } + struct Reply: Decodable { let preparation: BuildDebugPreparation } + let outcome: Outcome = try execute("build.decodeDebugPreparation", target: target, + request: Request(workspaceRoot: workspaceURL.path, root: root, run: run, result: result)) + return outcome.value.preparation + } + func decodeTestDiscovery( _ target: BuildSystemTarget, context: BuildPlanContext, result: ToolingJSONValue ) throws -> BuildTestDiscovery { diff --git a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift index 3ff4e0d2a4..e6e8053b29 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift @@ -191,7 +191,8 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec path: request.path, processID: request.processID, workingDirectory: request.workingDirectory, - executable: request.executable + executable: request.executable, + arguments: request.arguments, environment: request.environment ) ) ) @@ -601,6 +602,8 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec let processID: Int? let workingDirectory: String? let executable: String? + let arguments: [String]? + let environment: [String: String]? } let registration: Registration diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift index ca2adcb3f2..ac571ea767 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift @@ -75,7 +75,7 @@ extension AppModel { scope: target.scope) return } - Task { [weak self] in await self?.startDebuggingAfterActivation() } + runWorkflowCoordinator.startModuleOperation { [weak self] in await self?.startDebuggingAfterActivation() } } func startOrRestartDebugging() { @@ -173,6 +173,18 @@ extension AppModel { } return } + if selectedConfiguration.buildProvider != nil { + guard await saveAllDocuments(), isCurrentWorkspace(identity), !Task.isCancelled, + let feature = buildSystemsFeatureIfActive else { return } + let started = feature.debugProviderConfiguration(selectedConfiguration, workspace: identity.url) { [weak self] source, preparation in + guard let self, self.isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } + try self.launchPreparedProviderDebug(source: source, preparation: preparation, + workspaceURL: identity.url, title: selectedConfiguration.name) + } + if started != nil { showToolWindow(.buildOutput) } + else { showNotification("The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled.") } + return + } let activeDocumentIsLaunchable = selectedConfiguration.usesCurrentEditorFile ? await isLaunchableJavaEntrypoint(activeDocument, in: workspaceURL) : false @@ -223,7 +235,34 @@ extension AppModel { await startGenericDebuggingAfterActivation(fileURL: sourceURL, document: document) } + func debugBuildProviderApplication(entryID: String, request: BuildRunRequest, title: String) { + runWorkflowCoordinator.startModuleOperation { [weak self] in + guard let self, let identity = currentWorkspaceIdentity, + await saveAllDocuments(), isCurrentWorkspace(identity), + await activateDebugModule() != nil, isCurrentWorkspace(identity), + !Task.isCancelled, let feature = buildSystemsFeatureIfActive else { return } + if feature.debugApplication(entryID, request: request, title: title, launch: { [weak self] source, preparation in + guard let self, self.isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } + try self.launchPreparedProviderDebug(source: source, preparation: preparation, workspaceURL: identity.url, title: title) + }) != nil { showToolWindow(.buildOutput) } + } + } + + private func launchPreparedProviderDebug(source: BuildSystemPluginSource, preparation: BuildDebugPreparation, + workspaceURL: URL, title: String) throws { + guard let feature = genericDebugFeatureIfActive else { throw CancellationError() } + let configuration = try debugLaunchConfigurationResolver.resolveProvider(source: source, preparation: preparation, + workspaceURL: workspaceURL, title: title) + guard feature.startAdditional(fileURL: workspaceURL.appendingPathComponent(preparation.program), + rootURL: workspaceURL, configuration: configuration) else { + throw NSError(domain: "BuildProviderDebug", code: 1, userInfo: [NSLocalizedDescriptionKey: feature.errorMessage + ?? L10n.string("The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled.")]) + } + showToolWindow(.debug) + } + func stopDebugging() { + buildSystemsFeatureIfActive?.cancelDebugPreparation() runWorkflowCoordinator.cancelModuleOperation() cancelJavaTestDebugLaunch() guard let feature = genericDebugFeatureIfActive else { diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift index 7999c5b03e..05d4d7666a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift @@ -130,6 +130,11 @@ final class MacBuildProviderSessions: BuildProviderRequesting { return try JSONDecoder().decode(BuildProviderExecutionResult.self, from: JSONEncoder().encode(value)) } + func prepareDebug(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + try await prepareRun(request, on: target, timeout: timeout, progress: progress) + } + func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { let session = try await ownedSession(for: target) diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift new file mode 100644 index 0000000000..d8faaea4da --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift @@ -0,0 +1,43 @@ +import Foundation +import LitheCoreContracts + +/// Rechecks native filesystem and current package ownership at the debug handoff. +/// Provider facts never replace the adapter's own registered process or permissions. +@MainActor +struct MacBuildDebugLaunchResolver { + let planner: any DeclarativeLanguageServerLaunchPlanning + let entry: (String) -> PluginDebugAdapterEntry? + let generation: () -> Int + + func resolve(source: BuildSystemPluginSource, preparation: BuildDebugPreparation, + workspaceURL: URL, title: String) throws -> DebugLaunchConfiguration { + let identity = "\(source.pluginID)/\(preparation.adapter)" + guard let entry = entry(identity), + entry.plugin.descriptor.normalizedManifest == source.manifest, + entry.plugin.packageURL.standardizedFileURL == source.packageURL.standardizedFileURL else { + throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable + } + // Recheck canonical containment at the platform handoff, since + // a workspace symlink may change after Core decoded the result. + let workspace = workspaceURL.resolvingSymlinksInPath().standardizedFileURL + let program = workspace.appendingPathComponent(preparation.program).resolvingSymlinksInPath().standardizedFileURL + let cwd = workspace.appendingPathComponent(preparation.workingDirectory).resolvingSymlinksInPath().standardizedFileURL + let instance = workspace.appendingPathComponent(preparation.root).resolvingSymlinksInPath().standardizedFileURL + let prefix = instance.path.hasSuffix("/") ? instance.path : instance.path + "/" + let workspacePrefix = workspace.path.hasSuffix("/") ? workspace.path : workspace.path + "/" + var isDirectory: ObjCBool = false + guard instance == workspace || instance.path.hasPrefix(workspacePrefix), + program.path.hasPrefix(prefix), cwd == instance || cwd.path.hasPrefix(prefix), + MacPluginDebugExecutableResolver.isRunnableFile(program.path), + FileManager.default.fileExists(atPath: cwd.path, isDirectory: &isDirectory) && isDirectory.boolValue else { + throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable + } + let generation = generation() + let arguments = try planner.debugStartArguments( + manifest: source.manifest, adapterID: preparation.adapter, + request: DeclarativeDebugStartRequest(request: .launch, targetKind: "executable", path: program.path, + workingDirectory: cwd.path, registrationGeneration: generation, currentRegistrationGeneration: generation, + arguments: preparation.arguments, environment: preparation.environment)) + return DebugLaunchConfiguration(name: title, request: .launch, arguments: arguments, adapterProviderID: identity) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 6bf9316dde..0c03c39d43 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -187,6 +187,7 @@ final class MacServiceContainer { // debug. A language the host debugs itself keeps its own adapter. // Plugins that apply without a restart are in `livePlugins` instead. let registeredDebugAdapters = PluginDebugAdapterEntry.index(of: pluginStartup.registeredPlugins) + let registeredQualifiedDebugAdapters = PluginDebugAdapterEntry.qualifiedIndex(of: pluginStartup.registeredPlugins) let debugAdapterSessionsBox = DebugAdapterSessionsBox() let moduleEnablement = moduleStore let launchMode = moduleLaunchMode @@ -220,12 +221,18 @@ final class MacServiceContainer { } return snapshot.state != .disabled && !snapshot.isQuarantined && !snapshot.isSuppressedBySafeMode } - let activeDebugAdapter: @MainActor (String) -> PluginDebugAdapterEntry? = { languageID in - if let entry = registeredDebugAdapters[languageID], + let qualifiedDebugAdapters: @MainActor () -> [String: PluginDebugAdapterEntry] = { + registeredQualifiedDebugAdapters.filter { + isLaunchRegisteredAdapterUsable($0.value.plugin.descriptor.id.rawValue) + }.merging(PluginDebugAdapterEntry.qualifiedIndex(of: liveSnapshotBox.snapshot.activePackages)) { _, live in live } + } + let activeDebugAdapter: @MainActor (String) -> PluginDebugAdapterEntry? = { providerID in + if providerID.contains("/") { return qualifiedDebugAdapters()[providerID] } + if let entry = registeredDebugAdapters[providerID], isLaunchRegisteredAdapterUsable(entry.plugin.descriptor.id.rawValue) { return entry } - return liveSnapshotBox.snapshot.debugAdapters[languageID] + return liveSnapshotBox.snapshot.debugAdapters[providerID] } // A launch-registered plugin is not in the live registry, so turning its // debug module off reaches the running sessions through the module's @@ -632,7 +639,13 @@ final class MacServiceContainer { } ] let adapterSessions = DebugAdapterSessionManager( - providersSource: { debugProviderCache.providers() }, + providersSource: { + let languages = debugProviderCache.providers() + let qualified = qualifiedDebugAdapters().sorted { $0.key < $1.key }.map { + $0.value.provider(languages: languages) + } + return languages + qualified + }, makeSession: { descriptor, _ in if let factory = debugFactories[descriptor.id] { return factory() } guard let entry = activeDebugAdapter(descriptor.id) else { return nil } @@ -862,6 +875,11 @@ final class MacServiceContainer { languageProviderCatalogSnapshot: languageProviderCatalogSnapshot, debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver( javaTestLaunchResolver: rustCore, + providerLaunch: { source, preparation, workspaceURL, title in + try MacBuildDebugLaunchResolver(planner: declarativePluginOperations, + entry: { qualifiedDebugAdapters()[$0] }, generation: { liveSnapshotBox.snapshot.generation }) + .resolve(source: source, preparation: preparation, workspaceURL: workspaceURL, title: title) + }, javaExecutablePath: { [runtimeService] runtime in runtimeService.javaExecutableURL(forDebugging: runtime)?.path }, @@ -895,7 +913,8 @@ final class MacServiceContainer { return DebugLaunchConfiguration( name: documentURL.lastPathComponent, request: .launch, - arguments: arguments + arguments: arguments, + adapterProviderID: entry.providerID ) } ), diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacLivePluginRegistry.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacLivePluginRegistry.swift index 89aafab0e8..81c3e64fc7 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacLivePluginRegistry.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacLivePluginRegistry.swift @@ -15,6 +15,35 @@ struct PluginDebugAdapterEntry { let plugin: InstalledPluginPackage let adapter: DeclarativeDebugAdapter + var providerID: String { "\(plugin.descriptor.id.rawValue)/\(adapter.id)" } + + /// Every declared adapter has an explicit identity, independently of the + /// default debugger of its language or the presence of a source file. + static func qualifiedIndex(of packages: [InstalledPluginPackage]) -> [String: PluginDebugAdapterEntry] { + var entries: [String: PluginDebugAdapterEntry] = [:] + for plugin in packages.sorted(by: { $0.manifest.id < $1.manifest.id }) { + for adapter in plugin.descriptor.debugAdapters { + let entry = PluginDebugAdapterEntry(plugin: plugin, adapter: adapter) + entries[entry.providerID] = entry + } + } + return entries + } + + func provider(languages: [DebugProviderDescriptor]) -> DebugProviderDescriptor { + let supported = languages.filter { adapter.languageIDs.contains($0.id) } + return DebugProviderDescriptor( + id: providerID, + displayName: adapter.displayName, + fileExtensions: Set(supported.flatMap(\.fileExtensions)), + fileNames: Set(supported.flatMap(\.fileNames)), + fileNamePrefixes: Set(supported.flatMap(\.fileNamePrefixes)), + selectsByFile: false, + matchesAllSources: adapter.languageIDs.isEmpty, + requests: adapter.requests + ) + } + /// The adapter that launches a debug session for a language's open file, /// first package in ID order first. An adapter that only attaches, or only /// debugs executables and processes, is not chosen for a file. diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift index 7013754ba2..13425d31a4 100644 --- a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift +++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift @@ -4,6 +4,7 @@ import LitheCoreContracts enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable { case unsupportedProvider(String) + case providerDebugUnavailable case javaLaunchTargetUnavailable case invalidJavaAttachHost case invalidJavaAttachPort @@ -18,6 +19,8 @@ enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable { switch self { case .unsupportedProvider(let provider): return L10n.string("The \(provider) Debug Adapter is not installed yet.") + case .providerDebugUnavailable: + return L10n.string("The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled.") case .javaLaunchTargetUnavailable: return L10n.string("The Java language service could not resolve a main class for this file.") case .invalidJavaAttachHost: @@ -71,11 +74,22 @@ struct DebugLaunchConfigurationResolver { /// when no adapter serves the language. private let pluginLaunch: (@MainActor (_ languageID: String, _ documentURL: URL, _ workspaceURL: URL) throws -> DebugLaunchConfiguration?)? + private let providerLaunch: (@MainActor (BuildSystemPluginSource, BuildDebugPreparation, URL, String) throws -> DebugLaunchConfiguration)? + + @MainActor + func resolveProvider(source: BuildSystemPluginSource, preparation: BuildDebugPreparation, + workspaceURL: URL, title: String) throws -> DebugLaunchConfiguration { + guard let providerLaunch else { throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable } + return try providerLaunch(source, preparation, workspaceURL, title) + } + init( javaTestLaunchResolver: (any JavaTestDebugLaunchResolving)? = nil, + providerLaunch: (@MainActor (BuildSystemPluginSource, BuildDebugPreparation, URL, String) throws -> DebugLaunchConfiguration)? = nil, javaExecutablePath: (@MainActor (DebugJavaRuntime) -> String?)? = nil, pluginLaunch: (@MainActor (String, URL, URL) throws -> DebugLaunchConfiguration?)? = nil ) { + self.providerLaunch = providerLaunch self.pluginLaunch = pluginLaunch self.javaTestLaunchResolver = javaTestLaunchResolver self.javaExecutablePath = javaExecutablePath @@ -116,7 +130,8 @@ struct DebugLaunchConfigurationResolver { name: configuration.name, request: configuration.request, arguments: arguments, - steppingFilters: configuration.steppingFilters + steppingFilters: configuration.steppingFilters, + adapterProviderID: configuration.adapterProviderID ) } diff --git a/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift b/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift index 8753b179e3..83b7b491b2 100644 --- a/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift +++ b/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift @@ -534,6 +534,12 @@ struct BuildSystemToolWindowView: View { runnable: .application(BuildRunRequest(run: run.id, projectID: project.id, targetID: target.id), title: Self.title(run.displayName, scope: target.name)), target: target) } + for run in runs where run.debugAdapter != nil { + leafRow(id: nodeID + ":debug:" + run.id, title: Self.title(L10n.string("Debug"), scope: run.displayName), help: nil, + iconPath: "expui/run/debug.svg", entryID: entry.id, project: project, + runnable: .debugApplication(BuildRunRequest(run: run.id, projectID: project.id, targetID: target.id), + title: Self.title(run.displayName, scope: target.name)), target: target) + } if testable { leafRow( id: nodeID + ":tests", title: L10n.string("Run Tests"), help: nil, @@ -940,6 +946,7 @@ struct BuildSystemToolWindowView: View { case tests(BuildTestRequest, title: String) case discoveredTest(String, title: String) case application(BuildRunRequest, title: String) + case debugApplication(BuildRunRequest, title: String) } private struct Selection: Equatable { @@ -968,6 +975,8 @@ struct BuildSystemToolWindowView: View { feature.runTask(entryID, request: request, title: title) case .tests(let request, let title): feature.runTests(entryID, request: request, title: title) + case .debugApplication(let request, let title): + model.debugBuildProviderApplication(entryID: entryID, request: request, title: title) case .application(let request, let title): model.runBuildProviderApplication(entryID: entryID, request: request, title: title) case .discoveredTest(let testID, let title): diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index 3cbfa82632..7b894dedcd 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -41,7 +41,7 @@ public enum DebugAdapterState: String, Equatable, Sendable { case idle, initializing, ready, launching, running, paused, terminated, failed } -public enum DebugRequestKind: String, Codable, Equatable, Sendable { +public enum DebugRequestKind: String, Codable, Hashable, Sendable { case launch, attach } @@ -50,17 +50,23 @@ public struct DebugLaunchConfiguration: Codable, Equatable, Sendable { public let request: DebugRequestKind public let arguments: [String: ToolingJSONValue] public let steppingFilters: DebugSteppingFilters? + /// Host-selected Registry identity (`pluginID/adapterID` for plugins). + /// A missing identity selects by source file; an unavailable explicit + /// identity must fail rather than select another adapter for that file. + public let adapterProviderID: String? public init( name: String, request: DebugRequestKind, arguments: [String: ToolingJSONValue], - steppingFilters: DebugSteppingFilters? = nil + steppingFilters: DebugSteppingFilters? = nil, + adapterProviderID: String? = nil ) { self.name = name self.request = request self.arguments = arguments self.steppingFilters = steppingFilters + self.adapterProviderID = adapterProviderID } public func applying(steppingFilters: DebugSteppingFilters) -> Self { @@ -68,7 +74,8 @@ public struct DebugLaunchConfiguration: Codable, Equatable, Sendable { name: name, request: request, arguments: arguments, - steppingFilters: steppingFilters + steppingFilters: steppingFilters, + adapterProviderID: adapterProviderID ) } } diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift b/macos/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift index b23134fb4f..493fe03ddf 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift @@ -10,22 +10,34 @@ public struct DebugProviderDescriptor: Identifiable, Hashable, Sendable { public let fileExtensions: Set public let fileNames: Set public let fileNamePrefixes: Set + /// Qualified plugin entries are explicit selections, not competing file + /// defaults. Their file patterns still identify supported breakpoints. + public let matchesAllSources: Bool + public let selectsByFile: Bool + public let requests: Set public init( id: String, displayName: String, fileExtensions: Set, fileNames: Set = [], - fileNamePrefixes: Set = [] + fileNamePrefixes: Set = [], + selectsByFile: Bool = true, + matchesAllSources: Bool = false, + requests: Set = [.launch, .attach] ) { self.id = id self.displayName = displayName self.fileExtensions = fileExtensions self.fileNames = fileNames self.fileNamePrefixes = fileNamePrefixes + self.matchesAllSources = matchesAllSources + self.selectsByFile = selectsByFile + self.requests = requests } public func matches(_ fileURL: URL) -> Bool { + if matchesAllSources { return true } let name = fileURL.lastPathComponent.lowercased() if fileNames.contains(name) { return true } if fileNamePrefixes.contains(where: name.hasPrefix) { return true } diff --git a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift index a390c285cb..ba9bab2c3e 100644 --- a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift @@ -106,8 +106,9 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { public let id: String public let displayName: String public let targetTags: [String] - public init(id: String, displayName: String, targetTags: [String]) { - self.id = id; self.displayName = displayName; self.targetTags = targetTags + public let debugAdapter: String? + public init(id: String, displayName: String, targetTags: [String], debugAdapter: String? = nil) { + self.id = id; self.displayName = displayName; self.targetTags = targetTags; self.debugAdapter = debugAdapter } } public let providerRuns: [ProviderRun] @@ -718,6 +719,17 @@ package struct BuildRunPreparation: Codable, Equatable, Sendable { } } +/// Core-validated native facts. The adapter comes from the selected declaration, +/// never from the backend's reply; the platform checks executable permissions. +package struct BuildDebugPreparation: Decodable, Equatable, Sendable { + package let root: String + package let adapter: String + package let program: String + package let arguments: [String] + package let workingDirectory: String + package let environment: [String: String] +} + @MainActor package protocol BuildRunLaunching: AnyObject { func launchPreparedRun(_ plan: BuildRunPreparation, workspaceURL: URL, title: String, configuration: RunConfiguration?) throws -> String @@ -1039,6 +1051,10 @@ package protocol BuildSystemPlanning: Sendable { project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest func decodeRunPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, result: ToolingJSONValue) throws -> BuildRunPreparation + func prepareDebugPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest + func decodeDebugPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, run: String, + result: ToolingJSONValue) throws -> BuildDebugPreparation func testDiscoveryPlan(_ target: BuildSystemTarget, context: BuildPlanContext) throws -> BuildProviderRequest func decodeTestDiscovery( _ target: BuildSystemTarget, context: BuildPlanContext, result: ToolingJSONValue @@ -1131,6 +1147,8 @@ package protocol BuildProviderRequesting: AnyObject { /// Prepares portable launch data, retaining execution progress and cancellation. func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue + func prepareDebug(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue /// Runs a typed provider task in the same workspace session. func execute( _ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, @@ -1154,6 +1172,10 @@ package protocol BuildProviderRequesting: AnyObject { } package extension BuildProviderRequesting { + func prepareDebug(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + throw PluginFunctionFailure(code: "plugin.build.unavailable", message: L10n.string("The build provider returned an invalid launch plan.")) + } func pluginFunctions(of _: BuildProviderTarget) async throws -> [String] { throw PluginFunctionFailure( code: "plugin.function.unavailable", diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index d731b2a1aa..bb0400f318 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -679,6 +679,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { id: $0.id, displayName: $0.displayName, adapterID: $0.adapterID ?? $0.id, + requests: Set($0.requests.compactMap(DebugRequestKind.init(rawValue:))), languageIDs: $0.languageIDs, fileLaunchLanguageIDs: $0.fileLaunchLanguageIDs, consoleCommands: $0.consoleCommands, @@ -1225,6 +1226,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let icon: PluginUIIcon? } struct ProviderRun: Decodable { + struct Debug: Decodable { let adapter: String } + let debug: Debug? struct Targets: Decodable { let tags: [String] } let id: String let source: String? @@ -1295,7 +1298,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { declaresProvider: declaresProvider, testDiscovery: test?.discovery ?? false, providerRuns: run.filter { $0.source == "provider" }.map { - .init(id: $0.id, displayName: $0.name ?? $0.id, targetTags: $0.fromTargets?.tags ?? []) + .init(id: $0.id, displayName: $0.name ?? $0.id, targetTags: $0.fromTargets?.tags ?? [], debugAdapter: $0.debug?.adapter) } ) } @@ -1366,6 +1369,7 @@ public struct DeclarativeDebugAdapter: Equatable, Sendable { public let displayName: String /// The `adapterID` of the DAP `initialize` request. public let adapterID: String + public let requests: Set /// Every language a selector of the adapter names, whatever the request or /// target kind. It says which languages the plugin has a debugger for, so a /// debug-only declaration is recognised; it does not choose the adapter for @@ -1393,6 +1397,7 @@ public struct DeclarativeDebugAdapter: Equatable, Sendable { id: String, displayName: String, adapterID: String, + requests: Set = [.launch, .attach], languageIDs: [String], fileLaunchLanguageIDs: [String]? = nil, consoleCommands: [DebugConsoleCommand] = [], @@ -1403,6 +1408,7 @@ public struct DeclarativeDebugAdapter: Equatable, Sendable { self.id = id self.displayName = displayName self.adapterID = adapterID + self.requests = requests self.languageIDs = languageIDs self.fileLaunchLanguageIDs = fileLaunchLanguageIDs ?? languageIDs self.consoleCommands = consoleCommands @@ -1505,13 +1511,16 @@ public struct DeclarativeDebugStartRequest: Equatable, Sendable { /// The program the host found for a launch whose adapter binds `program` to /// `target.executable`; an absolute path the host has checked is runnable. public let executable: String? + public let arguments: [String]? + public let environment: [String: String]? public let registrationGeneration: Int public let currentRegistrationGeneration: Int public init( request: Request, targetKind: String, path: String? = nil, processID: Int? = nil, workingDirectory: String? = nil, executable: String? = nil, - registrationGeneration: Int, currentRegistrationGeneration: Int + registrationGeneration: Int, currentRegistrationGeneration: Int, + arguments: [String]? = nil, environment: [String: String]? = nil ) { self.request = request self.targetKind = targetKind @@ -1519,6 +1528,8 @@ public struct DeclarativeDebugStartRequest: Equatable, Sendable { self.processID = processID self.workingDirectory = workingDirectory self.executable = executable + self.arguments = arguments + self.environment = environment self.registrationGeneration = registrationGeneration self.currentRegistrationGeneration = currentRegistrationGeneration } diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 9192cf7f5d..856675a18c 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -296,7 +296,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu if startAdditional(fileURL: request.fileURL, rootURL: request.rootURL, configuration: DebugLaunchConfiguration( name: request.configuration.name, request: request.configuration.request, - arguments: arguments, steppingFilters: request.configuration.steppingFilters)) { + arguments: arguments, steppingFilters: request.configuration.steppingFilters, + adapterProviderID: request.configuration.adapterProviderID)) { registerJavaUpdateTarget(target) } } catch { @@ -727,7 +728,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu serviceUpdateFailed = false lastStartRequest = request activeFileURL = request.fileURL - providerID = sessionsProviderID(for: fileURL) + providerID = configuration.adapterProviderID ?? sessionsProviderID(for: fileURL) targetTitle = configuration.name replaceOutput(with: "") debuggeeOutputNormalizer.reset() @@ -748,7 +749,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedFrameID = nil stoppedFrame = nil selectedFrame = nil - for index in breakpoints.indices where sessionsProviderID(for: breakpoints[index].fileURL) == providerID { + for index in breakpoints.indices where providerHandles(breakpoints[index].fileURL, providerID: providerID) { breakpoints[index].verified = false breakpoints[index].message = nil breakpoints[index].resolvedLine = nil @@ -756,7 +757,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu do { try synchronizeRequestedBreakpoints(for: providerID) if !dataBreakpoints.isEmpty { - try sessions.setDataBreakpoints(coreDataBreakpoints, for: request.fileURL) + try sessions.setDataBreakpoints(coreDataBreakpoints, for: request.fileURL, adapterProviderID: providerID) } let effectiveConfiguration: DebugLaunchConfiguration if providerID == "java", let javaSteppingFilters { @@ -803,7 +804,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu outputPresentation.flush() if let activeFileURL { dataBreakpoints.removeAll { !$0.canPersist } - try? sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL) + try? sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL, adapterProviderID: providerID) } if let activeSessionID { let children = sessions.childSessionIDs(of: activeSessionID) @@ -2072,6 +2073,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu sessions.provider(for: fileURL)?.id } + private func providerHandles(_ fileURL: URL, providerID: String?) -> Bool { + providerID.flatMap { sessions.provider(id: $0) }?.matches(fileURL) == true + } + private func consume(_ event: DebugAdapterEvent) { switch event { case .initialized: @@ -2404,7 +2409,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private func synchronizeRequestedBreakpoints(for providerID: String?) throws { guard let providerID else { return } for fileURL in requestedBreakpointsByFile.keys.sorted(by: { $0.path < $1.path }) - where sessionsProviderID(for: fileURL) == providerID { + where providerHandles(fileURL, providerID: providerID) { try sessions.setBreakpoints(effectiveBreakpoints(for: fileURL), in: fileURL) } } @@ -2446,7 +2451,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu condition: $0.condition ) }, - for: activeFileURL + for: activeFileURL, + adapterProviderID: providerID ) } catch { record(error) @@ -2465,7 +2471,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu hitCondition: $0.hitCondition ) }, - for: activeFileURL + for: activeFileURL, + adapterProviderID: providerID ) } catch { record(error) @@ -2488,7 +2495,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private func synchronizeDataBreakpoints() { guard let activeFileURL else { return } do { - try sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL) + try sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL, adapterProviderID: providerID) } catch { record(error) } diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift index 635521a4d7..8e7bf46a72 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift @@ -165,12 +165,22 @@ public final class DebugAdapterSessionManager: ObservableObject { public var activeSessionIDs: Set { Set(sessions.keys) } public func provider(for fileURL: URL) -> DebugProviderDescriptor? { - providersSource().first { $0.matches(fileURL) } + providersSource().first { $0.selectsByFile && $0.matches(fileURL) } + } + + /// Reads current registrations even when an older session is still alive. + public func provider(id: String) -> DebugProviderDescriptor? { + providersSource().first { $0.id == id } } @discardableResult - public func activate(for fileURL: URL, rootURL: URL) throws -> any DebugAdapterSession { - let providerID = try descriptor(for: fileURL).id + public func activate(for fileURL: URL, rootURL: URL, adapterProviderID: String? = nil) throws -> any DebugAdapterSession { + let descriptor = try descriptor(for: fileURL, adapterProviderID: adapterProviderID) + return try activate(descriptor: descriptor, rootURL: rootURL) + } + + private func activate(descriptor: DebugProviderDescriptor, rootURL: URL) throws -> any DebugAdapterSession { + let providerID = descriptor.id if let sessionID = activeSessionIDsByProvider[providerID], let managed = sessions[sessionID], managed.session.isRunning, @@ -180,15 +190,18 @@ public final class DebugAdapterSessionManager: ObservableObject { if activeSessionIDsByProvider[providerID] != nil { stop(providerID: providerID) } - return try activateNew(for: fileURL, rootURL: rootURL).session + return try activateNew(descriptor: descriptor, rootURL: rootURL).session } /// Creates a new independent session without replacing another session for /// the same provider. Existing provider-level APIs continue to use the /// latest session as their compatibility target. @discardableResult - public func activateNew(for fileURL: URL, rootURL: URL) throws -> DebugSessionHandle { - let descriptor = try descriptor(for: fileURL) + public func activateNew(for fileURL: URL, rootURL: URL, adapterProviderID: String? = nil) throws -> DebugSessionHandle { + try activateNew(descriptor: descriptor(for: fileURL, adapterProviderID: adapterProviderID), rootURL: rootURL) + } + + private func activateNew(descriptor: DebugProviderDescriptor, rootURL: URL) throws -> DebugSessionHandle { let normalizedRoot = rootURL.standardizedFileURL guard let session = makeSession(descriptor, normalizedRoot) else { throw DebugProviderError.adapterUnavailable(descriptor.displayName) @@ -228,14 +241,22 @@ public final class DebugAdapterSessionManager: ObservableObject { rootURL: URL, configuration: DebugLaunchConfiguration ) throws -> any DebugAdapterControllingSession { - let session = try activate(for: fileURL, rootURL: rootURL) + let descriptor = try launchDescriptor(for: fileURL, configuration: configuration) + let session = try activate(descriptor: descriptor, rootURL: rootURL) + let sessionID = activeSessionIDsByProvider[descriptor.id] guard let controlling = session as? any DebugAdapterControllingSession else { + if let sessionID { stop(sessionID: sessionID) } throw DebugProviderError.capabilityUnavailable( - provider: provider(for: fileURL)?.displayName ?? fileURL.pathExtension, + provider: descriptor.displayName, capability: "DAP launch control" ) } - try controlling.launch(configuration) + do { + try controlling.launch(configuration) + } catch { + if let sessionID { stop(sessionID: sessionID) } + throw error + } return controlling } @@ -246,11 +267,12 @@ public final class DebugAdapterSessionManager: ObservableObject { rootURL: URL, configuration: DebugLaunchConfiguration ) throws -> (id: DebugSessionID, session: any DebugAdapterControllingSession) { - let handle = try activateNew(for: fileURL, rootURL: rootURL) + let descriptor = try launchDescriptor(for: fileURL, configuration: configuration) + let handle = try activateNew(descriptor: descriptor, rootURL: rootURL) guard let controlling = handle.session as? any DebugAdapterControllingSession else { stop(sessionID: handle.id) throw DebugProviderError.capabilityUnavailable( - provider: provider(for: fileURL)?.displayName ?? fileURL.pathExtension, + provider: descriptor.displayName, capability: "DAP launch control" ) } @@ -265,29 +287,32 @@ public final class DebugAdapterSessionManager: ObservableObject { } public func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) throws { - guard let descriptor = provider(for: fileURL) else { + // Two explicit adapters can debug the same language concurrently. Each + // receives source breakpoints, including those recorded before launch. + let matching = providersSource().filter { $0.matches(fileURL) } + guard !matching.isEmpty else { throw DebugProviderError.noProvider( fileExtension: fileURL.pathExtension.lowercased() ) } - var values = requestedBreakpoints[descriptor.id] ?? [:] - values[fileURL.standardizedFileURL] = breakpoints - requestedBreakpoints[descriptor.id] = values + for descriptor in matching { + var values = requestedBreakpoints[descriptor.id] ?? [:] + values[fileURL.standardizedFileURL] = breakpoints + requestedBreakpoints[descriptor.id] = values + } + let providerIDs = Set(matching.map(\.id)) sessions.values - .filter { $0.descriptor.id == descriptor.id } + .filter { providerIDs.contains($0.descriptor.id) } .compactMap { $0.session as? any DebugAdapterControllingSession } .forEach { $0.setBreakpoints(breakpoints, in: fileURL) } } public func setExceptionBreakpoints( _ breakpoints: [DebugExceptionBreakpoint], - for fileURL: URL + for fileURL: URL, + adapterProviderID: String? = nil ) throws { - guard let descriptor = provider(for: fileURL) else { - throw DebugProviderError.noProvider( - fileExtension: fileURL.pathExtension.lowercased() - ) - } + let descriptor = try descriptor(for: fileURL, adapterProviderID: adapterProviderID) requestedExceptionBreakpoints[descriptor.id] = breakpoints sessions.values .filter { $0.descriptor.id == descriptor.id } @@ -297,13 +322,10 @@ public final class DebugAdapterSessionManager: ObservableObject { public func setFunctionBreakpoints( _ breakpoints: [DebugFunctionBreakpoint], - for fileURL: URL + for fileURL: URL, + adapterProviderID: String? = nil ) throws { - guard let descriptor = provider(for: fileURL) else { - throw DebugProviderError.noProvider( - fileExtension: fileURL.pathExtension.lowercased() - ) - } + let descriptor = try descriptor(for: fileURL, adapterProviderID: adapterProviderID) requestedFunctionBreakpoints[descriptor.id] = breakpoints sessions.values .filter { $0.descriptor.id == descriptor.id } @@ -313,13 +335,10 @@ public final class DebugAdapterSessionManager: ObservableObject { public func setDataBreakpoints( _ breakpoints: [DebugDataBreakpoint], - for fileURL: URL + for fileURL: URL, + adapterProviderID: String? = nil ) throws { - guard let descriptor = provider(for: fileURL) else { - throw DebugProviderError.noProvider( - fileExtension: fileURL.pathExtension.lowercased() - ) - } + let descriptor = try descriptor(for: fileURL, adapterProviderID: adapterProviderID) requestedDataBreakpoints[descriptor.id] = breakpoints sessions.values .filter { $0.descriptor.id == descriptor.id } @@ -467,7 +486,13 @@ public final class DebugAdapterSessionManager: ObservableObject { } } - private func descriptor(for fileURL: URL) throws -> DebugProviderDescriptor { + private func descriptor(for fileURL: URL, adapterProviderID: String? = nil) throws -> DebugProviderDescriptor { + if let adapterProviderID { + guard let descriptor = provider(id: adapterProviderID) else { + throw DebugProviderError.adapterUnavailable(adapterProviderID) + } + return descriptor + } guard let descriptor = provider(for: fileURL) else { throw DebugProviderError.noProvider( fileExtension: fileURL.pathExtension.lowercased() @@ -476,6 +501,16 @@ public final class DebugAdapterSessionManager: ObservableObject { return descriptor } + private func launchDescriptor(for fileURL: URL, configuration: DebugLaunchConfiguration) throws -> DebugProviderDescriptor { + let descriptor = try descriptor(for: fileURL, adapterProviderID: configuration.adapterProviderID) + guard descriptor.requests.contains(configuration.request) else { + throw DebugProviderError.capabilityUnavailable( + provider: descriptor.displayName, capability: configuration.request.rawValue + ) + } + return descriptor + } + private func updateSessionState(_ sessionID: DebugSessionID, state: DebugAdapterState) { guard let index = sessionSummaries.firstIndex(where: { $0.id == sessionID }), let existing = sessions[sessionID] else { return } diff --git a/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift b/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift index ca16b1471a..7ffb0561f0 100644 --- a/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift +++ b/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift @@ -69,6 +69,20 @@ package final class BuildSystemsFeatureModel: ObservableObject { package func runTests(_ entryID: String, request: BuildTestRequest, title: String) -> UUID? { service.runTests(entryID, request: request, title: title) } + package func cancelDebugPreparation() { service.cancelDebugPreparation() } + + @discardableResult + package func debugApplication(_ entryID: String, request: BuildRunRequest, title: String, + launch: @escaping @MainActor (BuildSystemPluginSource, BuildDebugPreparation) throws -> Void) -> UUID? { + service.debugApplication(entryID, request: request, title: title, launch: launch) + } + + @discardableResult + package func debugProviderConfiguration(_ configuration: RunConfiguration, workspace: URL, + launch: @escaping @MainActor (BuildSystemPluginSource, BuildDebugPreparation) throws -> Void) -> UUID? { + service.debugProviderConfiguration(configuration, workspace: workspace, launch: launch) + } + @discardableResult package func runApplication(_ entryID: String, request: BuildRunRequest, title: String) -> UUID? { service.runApplication(entryID, request: request, title: title) diff --git a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift index d4978dce9f..ff2b9d56d6 100644 --- a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift +++ b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift @@ -135,6 +135,7 @@ package final class BuildSystemService: ObservableObject { private let runner: any BuildCommandRunning private let providers: (any BuildProviderRequesting)? private let runLauncher: (any BuildRunLaunching)? + private var preparingDebugID: UUID? private var preparedApplication: (source: BuildSystemPluginSource, operationID: String)? private let settings: @MainActor (String) -> [String: ToolingJSONValue] private let waitForRedetection: @Sendable () async throws -> Void @@ -692,29 +693,58 @@ package final class BuildSystemService: ObservableObject { package func cancelRun(_ id: UUID) { if runID == id { stopRun() } } + package func cancelDebugPreparation() { if let id = preparingDebugID { cancelRun(id) } } + + /// Debug owns the launched session; this service owns only preparation. + @discardableResult + package func debugApplication(_ entryID: String, request: BuildRunRequest, title: String, + launch: @escaping @MainActor (BuildSystemPluginSource, BuildDebugPreparation) throws -> Void) -> UUID? { + prepareApplication(entryID, request: request, title: title, debugLaunch: launch) + } + + @discardableResult + package func debugProviderConfiguration(_ configuration: RunConfiguration, workspace: URL, + launch: @escaping @MainActor (BuildSystemPluginSource, BuildDebugPreparation) throws -> Void) -> UUID? { + guard workspace.standardizedFileURL == workspaceURL, let reference = configuration.buildProvider, + let entry = entries.first(where: { $0.instance.pluginID == reference.pluginID + && $0.instance.buildSystemID == reference.buildSystemID && $0.instance.root == reference.root }) else { return nil } + return debugApplication(entry.id, request: reference.request, title: configuration.name, launch: launch) + } + /// The provider owns build/launch facts; the Run host owns the application. @discardableResult package func runApplication(_ entryID: String, request: BuildRunRequest, title: String, configuration: RunConfiguration? = nil) -> UUID? { + prepareApplication(entryID, request: request, title: title, configuration: configuration) + } + + private func prepareApplication(_ entryID: String, request: BuildRunRequest, title: String, + configuration: RunConfiguration? = nil, + debugLaunch: (@MainActor (BuildSystemPluginSource, BuildDebugPreparation) throws -> Void)? = nil) -> UUID? { guard let workspaceURL, let entry = entry(entryID), let model = entry.model, !entry.isReloadRequired, entry.modelState == .loaded, let project = model.projects.first(where: { $0.id == request.projectID }), let target = model.targets.first(where: { $0.id == request.targetID && $0.projectID == project.id }), - entry.declaration.providerRuns.contains(where: { $0.id == request.run }) else { return nil } + entry.declaration.providerRuns.contains(where: { $0.id == request.run && (debugLaunch == nil || $0.debugAdapter != nil) }) else { return nil } let origin = configuration ?? RunConfiguration(id: "build:" + entryID + ":" + request.run + ":" + request.targetID, name: title, kind: .process(provider: "build.provider"), execution: .application, modulePath: nil, mainClass: nil, buildProvider: BuildProviderRunReference(pluginID: entry.instance.pluginID, buildSystemID: entry.instance.buildSystemID, root: entry.instance.root, run: request.run, projectID: request.projectID, targetID: request.targetID, userArguments: request.userArguments)) - stopPreparedApplication() + if debugLaunch == nil { stopPreparedApplication() } let sourceRevision = fileRevision return startRun(entryID, title: title) { service, run in - guard let providers = service.providers, let launcher = service.runLauncher else { + guard let providers = service.providers, debugLaunch != nil || service.runLauncher != nil else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } let context = try service.planContext(entry, workspaceURL: workspaceURL) - let planned = try service.planner.prepareRunPlan(entry.target, context: context, run: request.run, - project: BuildSelection(project: project), selectedTarget: BuildSelection(target: target), - userArguments: request.userArguments) + let planned: BuildProviderRequest + if debugLaunch != nil { + planned = try service.planner.prepareDebugPlan(entry.target, context: context, run: request.run, + project: BuildSelection(project: project), selectedTarget: BuildSelection(target: target), userArguments: request.userArguments) + } else { + planned = try service.planner.prepareRunPlan(entry.target, context: context, run: request.run, + project: BuildSelection(project: project), selectedTarget: BuildSelection(target: target), userArguments: request.userArguments) + } let validConfiguration = service.runValidity(.provider(planned), entry: entry, workspaceURL: workspaceURL, run: run) let isValid: @MainActor @Sendable () -> Bool = { [weak service] in guard let service else { return false } @@ -722,24 +752,42 @@ package final class BuildSystemService: ObservableObject { && service.entry(entryID)?.model == model && service.entry(entryID)?.modelState == .loaded && service.entry(entryID)?.isReloadRequired == false } + if debugLaunch != nil { service.preparingDebugID = run.id } + defer { if service.preparingDebugID == run.id { service.preparingDebugID = nil } } service.providerRunID = run.id service.preparingApplicationID = run.id defer { if service.preparingApplicationID == run.id { service.preparingApplicationID = nil } } defer { if service.providerRunID == run.id { service.providerRunID = nil } } let resolved = try await service.providerRequestWithTool(planned, entry: entry, workspaceURL: workspaceURL, run: run) guard isValid() else { throw CancellationError() } - let result = try await providers.prepareRun(resolved, on: Self.providerTarget(entry, workspaceURL: workspaceURL), - timeout: .seconds(3600)) { [weak service] progress in + let progress: @MainActor @Sendable (BuildProviderProgress) -> Void = { [weak service] progress in guard let service, isValid() else { return } if let output = progress.output { service.appendOutput(output, in: service.outputDirectory(entry)) } else if let message = progress.message { service.appendOutput(message + "\n", in: service.outputDirectory(entry)) } } + let providerTarget = Self.providerTarget(entry, workspaceURL: workspaceURL) + let result: ToolingJSONValue + if debugLaunch != nil { + result = try await providers.prepareDebug(resolved, on: providerTarget, timeout: .seconds(3600), progress: progress) + } else { + result = try await providers.prepareRun(resolved, on: providerTarget, timeout: .seconds(3600), progress: progress) + } guard isValid() else { throw CancellationError() } let planner = service.planner + if let debugLaunch { + let preparation = try await Task.detached(priority: .userInitiated) { + try planner.decodeDebugPreparation(entry.target, workspaceURL: workspaceURL, root: context.root, + run: request.run, result: result) + }.value + guard isValid() else { throw CancellationError() } + try debugLaunch(entry.source, preparation) + return 0 + } let preparation = try await Task.detached(priority: .userInitiated) { try planner.decodeRunPreparation(entry.target, workspaceURL: workspaceURL, root: context.root, result: result) }.value guard isValid() else { throw CancellationError() } + guard let launcher = service.runLauncher else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } let operationID = try launcher.launchPreparedRun(preparation, workspaceURL: workspaceURL, title: title, configuration: origin) service.preparedApplication = (entry.source, operationID) service.applicationLaunched.send(entryID) diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index b64bfe86d4..b843e9a225 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -504,8 +504,8 @@ public struct PluginManifest: Equatable, Codable, Sendable { /// not running it, so an entry is added only with its host support. public static let hostCapabilities: [String: [Int]] = [ "backend": [1], - "buildProviders": [1, 2, 3, 4], - "buildSystems": [1, 2], "debugAdapters": [1], "executableResolution": [1], + "buildProviders": [1, 2, 3, 4, 5], + "buildSystems": [1, 2], "debugAdapters": [1, 2], "executableResolution": [1], "executionProviders": [1], "languageServers": [1, 2], "languages": [1], diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index d67657774e..4d25f5d04b 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -23,6 +23,266 @@ struct DebugModuleTests { #expect(feature.canUpdateJavaService) } + @Test + func adapterSelectionMatchesTheSharedHostFixture() throws { + struct Fixture: Decodable { + struct Provider: Decodable { + let id: String + let displayName: String + let fileExtensions: Set + let selectsByFile: Bool + let requests: Set + } + struct Case: Decodable { + let id: String + let target: String + let configuration: DebugLaunchConfiguration + let expectedProviderID: String? + let error: String? + } + let providers: [Provider] + let cases: [Case] + } + let repository = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: repository + .appendingPathComponent("shared/fixtures/debug/adapter-selection-v1.json"))) + let providers = fixture.providers.map { + DebugProviderDescriptor(id: $0.id, displayName: $0.displayName, fileExtensions: $0.fileExtensions, + selectsByFile: $0.selectsByFile, requests: $0.requests) + } + let manager = DebugAdapterSessionManager(providers: providers) { _, _ in DeferredInspectionDebugSession() } + defer { manager.stopAll() } + let root = URL(fileURLWithPath: "/workspace/fixture", isDirectory: true) + for example in fixture.cases { + defer { manager.stopAll() } + do { + let launch = try manager.launchNew(for: root.appendingPathComponent(example.target), + rootURL: root, configuration: example.configuration) + #expect(example.error == nil, "Unexpected launch: \(example.id)") + #expect(manager.sessionSummaries.first { $0.id == launch.id }?.providerID == example.expectedProviderID) + } catch let error as DebugProviderError { + switch error { + case .adapterUnavailable: + #expect(example.error == "unavailable", "Unexpected refusal: \(example.id)") + case .capabilityUnavailable: + #expect(example.error == "unsupported", "Unexpected request refusal: \(example.id)") + default: Issue.record("Unexpected selection error: \(error)") + } + #expect(manager.activeSessionIDs.isEmpty) + } + } + } + + @Test + func explicitAdapterIdentitySurvivesCodingAndSteppingFilterChanges() throws { + let configuration = DebugLaunchConfiguration( + name: "engine target", request: .launch, arguments: ["program": .string("bin/example")], + adapterProviderID: "dev.example.native/lldb" + ) + let filters = DebugSteppingFilters(classNameFilters: [], skipSynthetics: true, + skipStaticInitializers: false, skipConstructors: false, hideFilteredStackFrames: true) + let updated = configuration.applying(steppingFilters: filters) + let decoded = try JSONDecoder().decode(DebugLaunchConfiguration.self, from: JSONEncoder().encode(updated)) + #expect(decoded == updated) + #expect(decoded.adapterProviderID == "dev.example.native/lldb") + let oldJSON = Data(#"{"name":"file","request":"launch","arguments":{}}"#.utf8) + #expect(try JSONDecoder().decode(DebugLaunchConfiguration.self, from: oldJSON).adapterProviderID == nil) + } + + @Test + func explicitAdapterCanLaunchAProjectWithoutAMatchingSourceFile() throws { + let automatic = DebugProviderDescriptor(id: "c", displayName: "Default", fileExtensions: ["c"]) + let explicit = DebugProviderDescriptor(id: "dev.example.native/lldb", displayName: "LLDB", + fileExtensions: ["c"], selectsByFile: false, requests: [.launch]) + var selected: [String] = [] + let session = DeferredInspectionDebugSession() + let manager = DebugAdapterSessionManager(providers: [automatic, explicit]) { descriptor, _ in + selected.append(descriptor.id) + return session + } + defer { manager.stopAll() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + let configuration = DebugLaunchConfiguration(name: "example", request: .launch, + arguments: [:], adapterProviderID: explicit.id) + let launched = try manager.launchNew(for: root, rootURL: root, configuration: configuration) + #expect(selected == [explicit.id]) + #expect(manager.sessionSummaries.first?.providerID == explicit.id) + #expect(session.launchConfigurations == [configuration]) + #expect(manager.provider(for: root.appendingPathComponent("main.c")) == automatic) + #expect(manager.session(id: launched.id) === session) + } + + @Test + func missingOrWithdrawnExplicitAdapterNeverFallsBackToAFileDefault() throws { + let automatic = DebugProviderDescriptor(id: "c", displayName: "Default", fileExtensions: ["c"]) + let explicit = DebugProviderDescriptor(id: "dev.example.native/lldb", displayName: "LLDB", + fileExtensions: ["c"], selectsByFile: false) + var providers = [automatic, explicit] + var created = 0 + let manager = DebugAdapterSessionManager(providersSource: { providers }) { _, _ in + created += 1 + return DeferredInspectionDebugSession() + } + defer { manager.stopAll() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + let source = root.appendingPathComponent("main.c") + let configuration = DebugLaunchConfiguration(name: "example", request: .launch, + arguments: [:], adapterProviderID: explicit.id) + _ = try manager.launch(for: source, rootURL: root, configuration: configuration) + providers = [automatic] + #expect(throws: DebugProviderError.adapterUnavailable(explicit.id)) { + try manager.launch(for: source, rootURL: root, configuration: configuration) + } + #expect(throws: DebugProviderError.adapterUnavailable("")) { + try manager.activateNew(for: source, rootURL: root, adapterProviderID: "") + } + #expect(created == 1) + } + + @Test + func unsupportedExplicitRequestIsRejectedBeforeCreatingATransport() throws { + let attach = DebugProviderDescriptor(id: "dev.example.native/attach", displayName: "Attach", + fileExtensions: [], selectsByFile: false, requests: [.attach]) + var created = 0 + let manager = DebugAdapterSessionManager(providers: [attach]) { _, _ in + created += 1 + return DeferredInspectionDebugSession() + } + defer { manager.stopAll() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + #expect(throws: DebugProviderError.capabilityUnavailable(provider: "Attach", capability: "launch")) { + try manager.launchNew(for: root, rootURL: root, configuration: DebugLaunchConfiguration( + name: "example", request: .launch, arguments: [:], adapterProviderID: attach.id)) + } + #expect(created == 0) + _ = try manager.launchNew(for: root, rootURL: root, configuration: DebugLaunchConfiguration( + name: "example", request: .attach, arguments: [:], adapterProviderID: attach.id)) + #expect(created == 1) + } + + @Test + func sourceBreakpointsReachBothExplicitAdaptersBeforeAndAfterLaunch() throws { + let first = DebugProviderDescriptor(id: "dev.example.first/lldb", displayName: "First", + fileExtensions: ["c"], selectsByFile: false) + let second = DebugProviderDescriptor(id: "dev.example.second/lldb", displayName: "Second", + fileExtensions: ["c"], selectsByFile: false) + var sessions: [String: DeferredInspectionDebugSession] = [:] + let manager = DebugAdapterSessionManager(providers: [first, second]) { descriptor, _ in + let session = DeferredInspectionDebugSession() + sessions[descriptor.id] = session + return session + } + defer { manager.stopAll() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + let source = root.appendingPathComponent("main.c") + try manager.setBreakpoints([DebugSourceBreakpoint(line: 8, enabled: true)], in: source) + for descriptor in [first, second] { + _ = try manager.launchNew(for: root, rootURL: root, configuration: DebugLaunchConfiguration( + name: "example", request: .launch, arguments: [:], adapterProviderID: descriptor.id)) + #expect(sessions[descriptor.id]?.breakpointUpdates.last?.first?.line == 8) + } + try manager.setBreakpoints([DebugSourceBreakpoint(line: 12, enabled: true)], in: source) + #expect(sessions[first.id]?.breakpointUpdates.last?.first?.line == 12) + #expect(sessions[second.id]?.breakpointUpdates.last?.first?.line == 12) + manager.stop(providerID: first.id) + #expect(sessions[first.id]?.isRunning == false) + #expect(sessions[second.id]?.isRunning == true) + } + + @Test + func explicitFeatureRetryKeepsItsAdapterAndFailureCleansUpOnlyItsNewSession() throws { + let automatic = DebugProviderDescriptor(id: "c", displayName: "Default", fileExtensions: ["c"]) + let explicit = DebugProviderDescriptor(id: "dev.example.native/lldb", displayName: "LLDB", + fileExtensions: ["c"], selectsByFile: false) + var created: [(String, DeferredInspectionDebugSession)] = [] + let manager = DebugAdapterSessionManager(providers: [automatic, explicit]) { descriptor, _ in + let session = DeferredInspectionDebugSession() + if created.count == 2 { session.failNextLaunch = true } + created.append((descriptor.id, session)) + return session + } + let feature = GenericDebugFeatureModel(sessions: manager) + defer { feature.reset() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + let configuration = DebugLaunchConfiguration(name: "example", request: .launch, + arguments: [:], adapterProviderID: explicit.id) + #expect(feature.start(fileURL: root, rootURL: root, configuration: configuration)) + let original = try #require(feature.activeSessionID) + #expect(feature.providerID == explicit.id) + #expect(feature.retry()) + #expect(created.map { $0.0 } == [explicit.id, explicit.id]) + #expect(!created[0].1.isRunning) + #expect(feature.activeSessionID != original) + let current = try #require(feature.activeSessionID) + // Failed additional launch uses the same registry and must restore + // the current selection without stopping the earlier session. + let other = DebugLaunchConfiguration(name: "other", request: .launch, + arguments: [:], adapterProviderID: automatic.id) + #expect(!feature.startAdditional(fileURL: root, rootURL: root, configuration: other)) + #expect(created.count == 3) + #expect(created[2].0 == automatic.id) + #expect(!created[2].1.isRunning) + #expect(feature.activeSessionID == current) + #expect(feature.providerID == explicit.id) + #expect(created[1].1.isRunning) + #expect(manager.activeSessionIDs == [current]) + #expect(feature.retry()) + #expect(created.last?.0 == explicit.id) + } + + @Test + func failedLaunchCannotStopAReentrantReplacementOfTheSameAdapter() throws { + let descriptor = DebugProviderDescriptor(id: "dev.example.native/lldb", displayName: "LLDB", + fileExtensions: [], selectsByFile: false) + let first = DeferredInspectionDebugSession() + let replacement = DeferredInspectionDebugSession() + var created = 0 + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + created += 1 + return created == 1 ? first : replacement + } + defer { manager.stopAll() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + let configuration = DebugLaunchConfiguration(name: "example", request: .launch, + arguments: [:], adapterProviderID: descriptor.id) + // A synchronous session callback replaces the launch before the older + // launch throws. Cleanup must retain the replacement's exact identity. + first.onLaunch = { + _ = try manager.launchNew(for: root, rootURL: root, configuration: configuration) + throw DeferredDebugSessionError.launchFailed + } + defer { first.onLaunch = nil } + #expect(throws: DeferredDebugSessionError.launchFailed) { + try manager.launch(for: root, rootURL: root, configuration: configuration) + } + #expect(!first.isRunning) + #expect(replacement.isRunning) + #expect(manager.sessionSummaries.count == 1) + #expect(manager.session(providerID: descriptor.id) === replacement) + } + + @Test + func filelessFeatureSendsNonSourceBreakpointsToItsExplicitAdapter() throws { + let automatic = DebugProviderDescriptor(id: "c", displayName: "Default", fileExtensions: ["c"]) + let explicit = DebugProviderDescriptor(id: "dev.example.native/lldb", displayName: "LLDB", + fileExtensions: ["c"], selectsByFile: false) + let session = DeferredInspectionDebugSession() + let manager = DebugAdapterSessionManager(providers: [automatic, explicit]) { _, _ in session } + let feature = GenericDebugFeatureModel(sessions: manager) + defer { feature.reset() } + let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) + #expect(feature.start(fileURL: root, rootURL: root, configuration: DebugLaunchConfiguration( + name: "example", request: .launch, arguments: [:], adapterProviderID: explicit.id))) + feature.addFunctionBreakpoint(name: "main", condition: nil, hitCondition: nil) + #expect(session.functionBreakpointUpdates.last?.first?.name == "main") + try manager.setExceptionBreakpoints([], for: root, adapterProviderID: explicit.id) + try manager.setDataBreakpoints([], for: root, adapterProviderID: explicit.id) + #expect(!session.exceptionBreakpointUpdates.isEmpty) + #expect(!session.dataBreakpointUpdates.isEmpty) + #expect(feature.errorMessage == nil) + } + /// A host that registers debug adapters while it runs hands back its current /// set on each lookup, so a debugger installed later is found without /// rebuilding the manager. @@ -3449,8 +3709,12 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi private(set) var startCount = 0 private(set) var launchConfigurations: [DebugLaunchConfiguration] = [] private(set) var breakpointUpdates: [[DebugSourceBreakpoint]] = [] + private(set) var functionBreakpointUpdates: [[DebugFunctionBreakpoint]] = [] + private(set) var exceptionBreakpointUpdates: [[DebugExceptionBreakpoint]] = [] + private(set) var dataBreakpointUpdates: [[DebugDataBreakpoint]] = [] private(set) var executionCommands: [DebugExecutionCommand] = [] var failNextLaunch = false + var onLaunch: (() throws -> Void)? var consoleCommands: [DebugConsoleCommand] = [] var consoleCommandPrefix: String? private(set) var completionRequests: [( @@ -3521,6 +3785,7 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi func launch(_ configuration: DebugLaunchConfiguration) throws { launchConfigurations.append(configuration) + try onLaunch?() if failNextLaunch { failNextLaunch = false throw DeferredDebugSessionError.launchFailed @@ -3543,6 +3808,15 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in _: URL) { breakpointUpdates.append(breakpoints) } + func setFunctionBreakpoints(_ breakpoints: [DebugFunctionBreakpoint]) { + functionBreakpointUpdates.append(breakpoints) + } + func setExceptionBreakpoints(_ breakpoints: [DebugExceptionBreakpoint]) { + exceptionBreakpointUpdates.append(breakpoints) + } + func setDataBreakpoints(_ breakpoints: [DebugDataBreakpoint]) { + dataBreakpointUpdates.append(breakpoints) + } func execute(_ command: DebugExecutionCommand, threadID _: Int?) { executionCommands.append(command) } diff --git a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift index 9f4fd47c03..40afc0341d 100644 --- a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift @@ -1444,6 +1444,100 @@ struct BuildSystemServiceTests { #expect(harness.service.output.contains("build failed")) } + @Test + func providerDebugPreparationHandsOffOnlyValidatedCurrentFacts() async throws { + let (planner, providers, launcher, harness) = try Self.debugPreparationHarness() + defer { harness.service.stop() } + var launched: [BuildDebugPreparation] = [] + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + try #require(harness.service.debugApplication(Self.instanceID, request: Self.runRequest, title: "Debug") { source, plan in + #expect(source.pluginID == "dev.example") + launched.append(plan) + } != nil) + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(providers.sent.map(\.request.method) == ["lithe/build/prepareDebug"]) + #expect(launched.first?.adapter == "native") + #expect(launched.first?.arguments == ["a b", "x#y"]) + #expect(launcher.launched.isEmpty) + #expect(planner.diagnosticOutputs.isEmpty) + } + + @Test(arguments: ["stop", "debug-stop", "workspace", "options", "disable", "source", "reload"]) + func providerDebugLateReplyCannotLaunch(change: String) async throws { + let (_, providers, _, harness) = try Self.debugPreparationHarness() + let release = BuildTestGate() + let launched = BuildTestGate() + providers.replyGate = release + defer { release.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.debugApplication(Self.instanceID, request: Self.runRequest, title: "Debug") { _, _ in launched.open() } + try #require(await providers.requestStarted.waitUntilOpen()) + switch change { + case "workspace": harness.service.reset(workspaceURL: URL(fileURLWithPath: "/other")) + case "options": harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + case "disable": harness.sources = []; harness.service.pluginsChanged() + case "source": harness.service.workspaceFilesChanged([Self.workspace.appendingPathComponent("app/main.c")]) + case "reload": harness.service.loadModel(Self.instanceID, reason: .refresh) + case "debug-stop": harness.service.cancelDebugPreparation() + default: harness.service.stopRun() + } + providers.executionProgress?(BuildProviderProgress(output: "stale debug output")) + release.open() + try #require(await providers.requestFinished.waitUntilOpen()) + #expect(await launched.waitUntilOpen(timeout: .milliseconds(200)) == false) + #expect(!harness.service.output.contains("stale debug output")) + } + + @Test + func providerDebugCancellationDuringDecodePreventsAdapterStart() async throws { + let (planner, _, _, harness) = try Self.debugPreparationHarness() + let gate = GatedBuildDecode() + let launched = BuildTestGate() + planner.runPreparationGate = gate + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.debugApplication(Self.instanceID, request: Self.runRequest, title: "Debug") { _, _ in launched.open() } + try #require(await gate.entered.waitUntilOpen()) + harness.service.cancelDebugPreparation() + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + #expect(await launched.waitUntilOpen(timeout: .milliseconds(200)) == false) + } + + @Test + func providerDebugRejectsMissingCapabilitiesAndReportsLaunchFailure() async throws { + let (_, providers, _, noDebug) = try Self.runPreparationHarness() + defer { noDebug.service.stop() } + noDebug.service.reset(workspaceURL: Self.workspace) + try #require(await noDebug.waitForEntries { !$0.isEmpty }) + #expect(noDebug.service.debugApplication(Self.instanceID, request: Self.runRequest, title: "Debug") { _, _ in + Issue.record("No debug adapter was declared") + } == nil) + #expect(providers.sent.isEmpty) + let (_, _, _, harness) = try Self.debugPreparationHarness() + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + harness.service.debugApplication(Self.instanceID, request: Self.runRequest, title: "Debug") { _, _ in + throw BuildSystemServiceTestsError(message: "adapter unavailable") + } + try #require(await harness.waitForRunState { if case .failed = $0 { true } else { false } }) + #expect(harness.service.output.contains("adapter unavailable")) + } + + private static func debugPreparationHarness() throws -> (FakeBuildPlanner, FakeBuildProviders, FakeBuildRunLauncher, Harness) { + let (planner, providers, launcher, harness) = try runPreparationHarness() + planner.providerRequest = providerRequest(method: "lithe/build/prepareDebug") + providers.reply = try decode(ToolingJSONValue.self, + #"{"root":"app","adapter":"native","program":"app/bin/main","arguments":["a b","x#y"],"workingDirectory":"app","environment":{}}"#) + harness.sources = [fixtureSource(providerRuns: [.init(id: "application", displayName: "Run", + targetTags: ["application"], debugAdapter: "native")])] + return (planner, providers, launcher, harness) + } + private static let runRequest = BuildRunRequest(run: "application", projectID: "p", targetID: "t") private static func runPreparationHarness() throws -> (FakeBuildPlanner, FakeBuildProviders, FakeBuildRunLauncher, Harness) { let planner = FakeBuildPlanner() @@ -2555,6 +2649,22 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { return providerRequest } + func prepareDebugPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { + guard let providerRequest else { throw CancellationError() } + return providerRequest + } + + func decodeDebugPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, run: String, + result: ToolingJSONValue) throws -> BuildDebugPreparation { + if let gate = access({ let gate = $0.runPreparationGate; $0.runPreparationGate = nil; return gate }) { + gate.entered.open() + guard gate.waitForRelease() else { throw GatedDecodeError.releaseTimedOut } + gate.finished.open() + } + return try JSONDecoder().decode(BuildDebugPreparation.self, from: JSONEncoder().encode(result)) + } + func decodeRunPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, result: ToolingJSONValue) throws -> BuildRunPreparation { if let gate = access({ let gate = $0.runPreparationGate; $0.runPreparationGate = nil; return gate }) { @@ -2924,6 +3034,11 @@ private final class FakeBuildProviders: BuildProviderRequesting { return executionResult } + func prepareDebug(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + try await prepareRun(request, on: target, timeout: timeout, progress: progress) + } + func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { executionProgress = progress diff --git a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift index f0bf849a76..928c91fccb 100644 --- a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift +++ b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift @@ -1,5 +1,6 @@ import Foundation import LitheCoreContracts +import LitheModuleAPI import LitheDebugModule import Testing @testable import Lithe @@ -9,6 +10,94 @@ import Testing struct PluginDebugAdapterTests { private struct ResolveFailure: Error, Equatable {} + @Test(arguments: ["valid", "not-executable", "withdrawn", "replaced", "program-alias", "instance-alias"]) + func providerDebugHandoffRechecksFilesAndCurrentPackage(change: String) throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-debug-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("workspace", isDirectory: true) + let instance = workspace.appendingPathComponent("app", isDirectory: true) + try FileManager.default.createDirectory(at: instance, withIntermediateDirectories: true) + let program = instance.appendingPathComponent("main") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: program) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: program.path) + let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-provider-debug-preparation.plugin.json") + let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: Data(contentsOf: fixture)) + let package = InstalledPluginPackage(descriptor: descriptor, installation: PluginInstallationRecord( + pluginID: descriptor.id, activeVersion: descriptor.pluginManifest.version, origin: .marketplace, status: .installed), + packageURL: root.appendingPathComponent("package", isDirectory: true)) + let entry = try #require(PluginDebugAdapterEntry.qualifiedIndex(of: [package]).values.first) + let source = BuildSystemPluginSource(pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, packageURL: package.packageURL, buildSystems: descriptor.buildSystems) + let plan = try JSONDecoder().decode(BuildDebugPreparation.self, from: Data( + #"{"root":"app","adapter":"native","program":"app/main","arguments":["a b","x#y"],"workingDirectory":"app","environment":{"MODE":"debug"}}"#.utf8)) + let lookup = entry.provider(languages: []) + #expect(lookup.matches(URL(fileURLWithPath: "/workspace/main.c"))) + #expect(!lookup.selectsByFile) + var current: PluginDebugAdapterEntry? = entry + switch change { + case "not-executable": try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: program.path) + case "withdrawn": current = nil + case "replaced": + let other = InstalledPluginPackage(descriptor: descriptor, installation: package.installation, + packageURL: root.appendingPathComponent("replacement", isDirectory: true)) + current = PluginDebugAdapterEntry(plugin: other, adapter: entry.adapter) + case "program-alias", "instance-alias": + let other = workspace.appendingPathComponent("other", isDirectory: true) + try FileManager.default.createDirectory(at: other, withIntermediateDirectories: true) + let outside = other.appendingPathComponent("main") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: outside) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: outside.path) + if change == "program-alias" { + try FileManager.default.removeItem(at: program) + try FileManager.default.createSymbolicLink(at: program, withDestinationURL: outside) + } else { + let external = root.appendingPathComponent("external", isDirectory: true) + try FileManager.default.moveItem(at: instance, to: external) + try FileManager.default.createSymbolicLink(at: instance, withDestinationURL: external) + } + default: break + } + let resolver = MacBuildDebugLaunchResolver(planner: DeclarativePluginTestSupport.operations, + entry: { _ in current }, generation: { 7 }) + if change == "valid" { + let launch = try resolver.resolve(source: source, preparation: plan, workspaceURL: workspace, title: "Main") + #expect(launch.adapterProviderID == entry.providerID) + #expect(launch.arguments["args"] == .array([.string("a b"), .string("x#y")])) + #expect(launch.arguments["env"] == .object(["MODE": .string("debug")])) + #expect(launch.arguments["program"] == .string(program.resolvingSymlinksInPath().path)) + } else { + #expect(throws: DebugLaunchConfigurationResolutionError.providerDebugUnavailable) { + try resolver.resolve(source: source, preparation: plan, workspaceURL: workspace, title: "Main") + } + } + } + + + @Test + func explicitHostIdentityCrossesTheRealCoreBridgeWithoutBecomingADAPArgument() throws { + let core = RustCoreBridge() + let id = "explicit-adapter-\(UUID().uuidString)" + _ = try core.createDebugSession(sessionID: id, adapterID: "example-dap", rootPath: "/workspace/example", + supportsRunInTerminalRequest: false, supportsStartDebuggingRequest: false, + disabledCapabilities: [], parentSessionID: nil) + defer { core.destroyDebugSession(sessionID: id) } + let body = Data(#"{"seq":1,"type":"response","request_seq":1,"command":"initialize","success":true,"body":{}}"#.utf8) + var reply = Data("Content-Length: \(body.count)\r\n\r\n".utf8) + reply.append(body) + _ = try core.receiveDebugData(sessionID: id, data: reply) + let launch = try core.launchDebugSession(sessionID: id, operationID: "launch", configuration: + DebugLaunchConfiguration(name: "example", request: .launch, + arguments: ["program": .string("bin/example")], adapterProviderID: "dev.example.native/lldb")) + let frame = try #require(launch.outboundFrames.first) + let bytes = try #require(Data(base64Encoded: frame)) + let separator = try #require(bytes.range(of: Data("\r\n\r\n".utf8))) + let message = try #require(JSONSerialization.jsonObject(with: bytes[separator.upperBound...]) as? [String: Any]) + let arguments = try #require(message["arguments"] as? [String: Any]) + #expect(message["command"] as? String == "launch") + #expect(arguments["program"] as? String == "bin/example") + #expect(arguments["adapterProviderID"] == nil) + #expect(message["adapterProviderID"] == nil) + } + private static func process(_ executable: MacPluginLanguageServerLaunchResolver.BuildProviderProcess.Executable) -> MacPluginLanguageServerLaunchResolver.DebugAdapterProcess { diff --git a/macos/Tests/LitheTests/PluginLiveActivationTests.swift b/macos/Tests/LitheTests/PluginLiveActivationTests.swift index 3e253c2606..406818cbbf 100644 --- a/macos/Tests/LitheTests/PluginLiveActivationTests.swift +++ b/macos/Tests/LitheTests/PluginLiveActivationTests.swift @@ -79,6 +79,46 @@ struct PluginLiveActivationTests { // MARK: - Registry content + @Test + func qualifiedAdaptersKeepEveryPackageAndNeverBecomeFileDefaults() throws { + let fixture = try LiveFixture() + defer { fixture.cleanUp() } + let first = try fixture.package(Self.debugManifest(id: "dev.example.first")) + let second = try fixture.package(Self.debugManifest(id: "dev.example.second")) + let attach = try fixture.package(Self.debugManifest(id: "dev.example.attach", + languageIDs: ["java"], requests: ["attach"], targetKinds: ["process"])) + let packages = [second, attach, first] + let qualified = PluginDebugAdapterEntry.qualifiedIndex(of: packages) + #expect(Set(qualified.keys) == ["dev.example.first/lldb", "dev.example.second/lldb", "dev.example.attach/lldb"]) + let automatic = PluginDebugAdapterEntry.index(of: packages) + #expect(automatic["rust"]?.plugin.descriptor.id == first.descriptor.id) + #expect(automatic["java"] == nil) + let languages = [DebugProviderDescriptor(id: "java", displayName: "Java", fileExtensions: ["java"])] + let descriptor = try #require(qualified["dev.example.attach/lldb"]?.provider(languages: languages)) + #expect(descriptor.requests == [.attach]) + #expect(!descriptor.selectsByFile) + #expect(descriptor.matches(URL(fileURLWithPath: "/workspace/Main.java"))) + } + + @Test + func qualifiedAdapterLookupFollowsLiveEnablementAndReplacement() throws { + let fixture = try LiveFixture() + defer { fixture.cleanUp() } + let original = try fixture.package(Self.debugManifest()) + fixture.registry.apply([original]) + let key = "\(Self.pluginID.rawValue)/lldb" + #expect(PluginDebugAdapterEntry.qualifiedIndex(of: fixture.registry.snapshot.activePackages)[key] != nil) + fixture.isEnabled = false + fixture.registry.apply([original]) + #expect(PluginDebugAdapterEntry.qualifiedIndex(of: fixture.registry.snapshot.activePackages)[key] == nil) + fixture.isEnabled = true + let replacement = try fixture.package(Self.debugManifest(adapterID: "replacement")) + fixture.registry.apply([replacement]) + let entries = PluginDebugAdapterEntry.qualifiedIndex(of: fixture.registry.snapshot.activePackages) + #expect(entries[key] == nil) + #expect(entries["\(Self.pluginID.rawValue)/replacement"] != nil) + } + @Test func onlyInstalledPackagesThatQualifyAreAdopted() throws { let fixture = try LiveFixture() diff --git a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift index afabb878ae..088b49c14f 100644 --- a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift +++ b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift @@ -143,6 +143,32 @@ struct RustBuildSystemOperationsTests { #expect(launch.workingDirectory == "app") } + @Test + func providerDebugPreparationRoundTripsThroughRealCore() throws { + let descriptor = try Self.descriptor("build-provider-debug-preparation") + #expect(descriptor.buildSystems.first?.providerRuns.first?.debugAdapter == "native") + let target = try Self.target("build-provider-debug-preparation", "gradle") + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: workspace) } + let directory = workspace.appendingPathComponent("app", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("native program".utf8).write(to: directory.appendingPathComponent("main")) + let plan = try operations.prepareDebugPlan(target, context: BuildPlanContext(root: ".", options: ["offline": .bool(true)]), + run: "application", project: BuildSelection(name: "app", path: "app"), + selectedTarget: BuildSelection(name: "Main", path: "app", tags: ["application"]), userArguments: ["a b"]) + #expect(plan.method == "lithe/build/prepareDebug") + guard case .object(let params) = plan.params else { Issue.record("Missing provider params"); return } + #expect(params["userArguments"] == .array([.string("a b")])) + let result: ToolingJSONValue = .object(["program": .string("app/main"), "arguments": .array([.string("a b")]), + "workingDirectory": .string("app"), "environment": .object(["MODE": .string("debug")])]) + let prepared = try operations.decodeDebugPreparation(target, workspaceURL: workspace, root: "app", run: "application", result: result) + #expect(prepared.adapter == "native") + #expect(prepared.root == "app") + #expect(prepared.program == "app/main") + #expect(prepared.arguments == ["a b"]) + #expect(prepared.environment == ["MODE": "debug"]) + } + @Test func providerTestPlanKeepsTypedFiltersAndReportPatterns() throws { let target = try Self.target("build-provider-tests", "gradle") diff --git a/rust/lithe-core/src/build/commands.rs b/rust/lithe-core/src/build/commands.rs index f611043140..2d2ebfeeba 100644 --- a/rust/lithe-core/src/build/commands.rs +++ b/rust/lithe-core/src/build/commands.rs @@ -299,3 +299,27 @@ pub(crate) fn build_language_import_command(payload: Value) -> Result Result { + with_build_system( + payload, + "build.prepareDebugPlan", + "plan", + |build, request: &super::run_preparation::PrepareRunRequest| { + super::debug_preparation::plan(build, request) + }, + ) +} + +/// `build.decodeDebugPreparation`: validates portable native debug target facts. +pub(crate) fn build_decode_debug_preparation_command(payload: Value) -> Result { + with_build_system( + payload, + "build.decodeDebugPreparation", + "preparation", + |build, request: &super::debug_preparation::DecodeDebugPreparationRequest| { + super::debug_preparation::decode(build, request) + }, + ) +} diff --git a/rust/lithe-core/src/build/debug_preparation.rs b/rust/lithe-core/src/build/debug_preparation.rs new file mode 100644 index 0000000000..1fa36873cc --- /dev/null +++ b/rust/lithe-core/src/build/debug_preparation.rs @@ -0,0 +1,120 @@ +//! Native executable preparation uses a same-package DebugAdapter Registry entry. +//! Providers own build facts; Core validates portable data and declared bindings. + +use super::decode::{self, Workspace}; +use super::manifest::{BuildSystemDeclaration, RunDeclaration, TaskSource}; +use super::plans; +use super::run_preparation::{self, DecodeRunPreparationRequest, PrepareRunRequest}; +use crate::debug::adapters::{DebugAdapterDeclaration, DebugRequest}; +use crate::plugins::declarative::Validation; +use serde::Deserialize; +use serde_json::{json, Value}; + +/// No backend-selected adapter, process, endpoint, or arbitrary DAP arguments. +pub(crate) fn adapter<'a>( + run: &RunDeclaration, + adapters: &'a [DebugAdapterDeclaration], +) -> Validation<&'a DebugAdapterDeclaration> { + let invalid = || { + plans::invalid_selection("Native debug preparation needs a same-plugin launch adapter with program, cwd, args and env target bindings, without overriding defaults.") + }; + let id = &run.debug.as_ref().ok_or_else(invalid)?.adapter; + let adapter = adapters + .iter() + .find(|adapter| &adapter.id == id) + .ok_or_else(invalid)?; + let configuration = adapter + .configurations + .as_ref() + .and_then(|configs| configs.launch.as_ref()) + .ok_or_else(invalid)?; + let bindings = configuration.target_bindings.as_ref().ok_or_else(invalid)?; + if run.source != TaskSource::Provider + || !adapter.requests.contains(&DebugRequest::Launch) + || bindings.program.as_deref() != Some("target.path") + || bindings.cwd.as_deref() != Some("target.workingDirectory") + || bindings.args.as_deref() != Some("target.arguments") + || bindings.env.as_deref() != Some("target.environment") + || bindings.process_id.is_some() + || configuration.arguments.as_ref().is_some_and(|args| { + ["program", "cwd", "args", "env"] + .iter() + .any(|key| args.contains_key(*key)) + }) + || !adapter + .selectors + .iter() + .any(|selector| selector.target_kinds.contains(&"executable".to_string())) + { + return Err(invalid()); + } + Ok(adapter) +} + +/// Validates the ordinary run selector before changing its method and admission. +pub(crate) fn plan( + build: &BuildSystemDeclaration, + request: &PrepareRunRequest, +) -> Validation { + let mut value = run_preparation::plan(build, request)?; + if !build + .run + .iter() + .any(|run| run.id == request.run && run.debug.is_some()) + { + return Err(plans::unavailable(build, "debug preparation")); + } + value["provider"]["method"] = json!("lithe/build/prepareDebug"); + value["decoder"] = json!("litheDebugPreparation"); + Ok(value) +} + +/// The host keeps the selected run separate from the untrusted provider result. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DecodeDebugPreparationRequest { + #[serde(flatten)] + pub(crate) base: DecodeRunPreparationRequest, + pub(crate) run: String, +} + +/// Reuses bounded argv/environment/cwd validation without accepting an executable command. +pub(crate) fn decode( + build: &BuildSystemDeclaration, + request: &DecodeDebugPreparationRequest, +) -> Validation { + let run = build + .run + .iter() + .find(|run| run.id == request.run && run.debug.is_some()) + .ok_or_else(|| plans::unavailable(build, "debug preparation"))?; + let invalid = || decode::invalid_output("Invalid provider native debug preparation result."); + let object = request.base.result.as_object().ok_or_else(invalid)?; + if object.len() != 4 + || !["program", "arguments", "workingDirectory", "environment"] + .iter() + .all(|key| object.contains_key(*key)) + { + return Err(invalid()); + } + let program = decode::request_path(object["program"].as_str().ok_or_else(invalid)?, "program") + .map_err(|_| invalid())?; + let root = decode::request_path(&request.base.root, "root")?; + let workspace = Workspace::new(&request.base.workspace_root)?; + let instance = workspace + .read_path(&root) + .filter(|path| path.is_dir()) + .ok_or_else(invalid)?; + if !decode::contains(&root, &program) + || !workspace + .read_path(&program) + .is_some_and(|path| path.is_file() && path.starts_with(&instance)) + { + return Err(invalid()); + } + let validated = run_preparation::process_fields(&request.base)?; + Ok( + json!({"adapter":run.debug.as_ref().expect("selected debug declaration").adapter, + "root":root,"program":program,"arguments":validated["arguments"],"environment":validated["environment"],"workingDirectory":validated["workingDirectory"]}), + ) +} diff --git a/rust/lithe-core/src/build/manifest.rs b/rust/lithe-core/src/build/manifest.rs index 23cced1d32..c00db39231 100644 --- a/rust/lithe-core/src/build/manifest.rs +++ b/rust/lithe-core/src/build/manifest.rs @@ -631,6 +631,9 @@ fn task_execution() -> BuildExecution { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct RunDeclaration { + /// A same-package adapter for native executable debug preparation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) debug: Option, pub(crate) id: String, /// Provider runs prepare a launch plan; they never become empty argv entries. #[serde(default, skip_serializing_if = "TaskSource::is_command")] @@ -654,6 +657,13 @@ pub(crate) struct RunDeclaration { pub(crate) extension: BTreeMap, } +/// Explicit adapter reference; it never grants cross-package access. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RunDebug { + pub(crate) adapter: String, +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct FromTargets { @@ -928,6 +938,7 @@ fn check_target_tags(tags: &mut Vec, scopes: &[Scope], field: &str) -> V /// Facts of the rest of the manifest a build-system declaration is checked /// against. pub(crate) struct DeclarationContext<'a> { + pub(crate) debug_adapters: &'a [crate::debug::adapters::DebugAdapterDeclaration], pub(crate) plugin_api_version: u32, pub(crate) build_systems_version: u32, /// Admission version for provider execution; independent of wire negotiation. @@ -1342,6 +1353,17 @@ impl BuildSystemDeclaration { "No model, dependencies, task or test source uses the provider.", )); } + for run in &self.run { + if run.debug.is_some() { + if context.build_providers_version < 5 { + return Err(invalid( + &field, + "Provider debug preparation requires buildProviders 5.", + )); + } + super::debug_preparation::adapter(run, context.debug_adapters)?; + } + } if provider_tests && context.build_providers_version < 3 { return Err(invalid(&field, "Provider tests require buildProviders 3.")); } @@ -1790,6 +1812,12 @@ impl BuildSystemDeclaration { "Run IDs are unique identifiers that start with a letter.", )); } + if run.debug.is_some() && run.source != TaskSource::Provider { + return Err(invalid( + &field, + "Debug preparation requires a provider run.", + )); + } if run.source == TaskSource::Provider { let valid_targets = run.from_targets.as_ref().is_some_and(|from| { !from.tags.is_empty() diff --git a/rust/lithe-core/src/build/mod.rs b/rust/lithe-core/src/build/mod.rs index f2b1e0ac73..2bcc4cbebe 100644 --- a/rust/lithe-core/src/build/mod.rs +++ b/rust/lithe-core/src/build/mod.rs @@ -16,6 +16,7 @@ //! Note: the contract is recorded in .agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md mod commands; +mod debug_preparation; mod decode; mod detect; mod integrations; diff --git a/rust/lithe-core/src/build/run_preparation.rs b/rust/lithe-core/src/build/run_preparation.rs index fafdb26d65..c51158f3b4 100644 --- a/rust/lithe-core/src/build/run_preparation.rs +++ b/rust/lithe-core/src/build/run_preparation.rs @@ -121,6 +121,19 @@ pub(crate) fn decode( { return Err(invalid()); } + let mut fields = process_fields(request)?; + fields["executable"] = json!({"kind":kind,"value":name}); + Ok(fields) +} + +/// Common bounded arguments, environment and workspace-contained directory. +pub(super) fn process_fields(request: &DecodeRunPreparationRequest) -> Validation { + let invalid = || decode::invalid_output("Invalid provider process preparation result."); + let value = &request.result; + if serde_json::to_vec(value).map_or(usize::MAX, |bytes| bytes.len()) > decode::MAX_OUTPUT_BYTES + { + return Err(invalid()); + } let args = value["arguments"] .as_array() .filter(|args| args.len() <= MAX_ARGUMENTS) @@ -164,8 +177,5 @@ pub(crate) fn decode( return Err(invalid()); } } - Ok( - json!({"executable":{"kind":kind,"value":name},"arguments":args, - "workingDirectory":cwd,"environment":environment}), - ) + Ok(json!({"arguments":args,"workingDirectory":cwd,"environment":environment})) } diff --git a/rust/lithe-core/src/debug/adapters.rs b/rust/lithe-core/src/debug/adapters.rs index 573555b1e1..2a4be8a3c5 100644 --- a/rust/lithe-core/src/debug/adapters.rs +++ b/rust/lithe-core/src/debug/adapters.rs @@ -242,6 +242,10 @@ pub(crate) struct DebugExecutable { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct DebugTargetBindings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) args: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) env: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) program: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -619,7 +623,12 @@ impl DebugConfiguration { return Ok(()); }; let binding_field = format!("{field}.targetBindings"); - if bindings.program.is_none() && bindings.process_id.is_none() && bindings.cwd.is_none() { + if bindings.program.is_none() + && bindings.process_id.is_none() + && bindings.cwd.is_none() + && bindings.args.is_none() + && bindings.env.is_none() + { return Err(invalid( &binding_field, "A binding list declares a binding.", @@ -633,6 +642,8 @@ impl DebugConfiguration { ), ("processId", &bindings.process_id, &["target.processID"][..]), ("cwd", &bindings.cwd, &["target.workingDirectory"][..]), + ("args", &bindings.args, &["target.arguments"][..]), + ("env", &bindings.env, &["target.environment"][..]), ] { if source .as_deref() @@ -1115,6 +1126,11 @@ impl Generation { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct TargetFacts { + /// Host-validated process arguments and environment; absent differs from empty. + #[serde(default)] + pub(crate) arguments: Option>, + #[serde(default)] + pub(crate) environment: Option>, // Selection by kind happens before a start; the host still states it so a // start names what it was prepared for. #[allow(dead_code)] @@ -1183,6 +1199,16 @@ pub(crate) fn start_arguments( &bindings.cwd, target.working_directory.clone().map(Value::from), ); + bind( + "args", + &bindings.args, + target.arguments.as_ref().map(|args| json!(args)), + ); + bind( + "env", + &bindings.env, + target.environment.as_ref().map(|env| json!(env)), + ); Ok(arguments) } @@ -1271,6 +1297,34 @@ pub(crate) fn plan( } _ => {} } + if bindings.args.is_some() + && target.arguments.as_ref().map_or(true, |args| { + args.len() > 1024 + || args + .iter() + .any(|arg| arg.len() > 65536 || arg.contains('\0')) + }) + { + return Err(missing("target.arguments", "bounded arguments")); + } + if bindings.env.is_some() + && target.environment.as_ref().map_or(true, |env| { + env.len() > 64 + || env.iter().any(|(key, value)| { + key.is_empty() + || key.len() > 128 + || !key.bytes().enumerate().all(|(index, byte)| { + byte == b'_' + || byte.is_ascii_alphabetic() + || index > 0 && byte.is_ascii_digit() + }) + || value.len() > 65536 + || value.contains('\0') + }) + }) + { + return Err(missing("target.environment", "bounded environment")); + } if bindings.process_id.is_some() && target.process_id.is_none() { return Err(missing("target.processID", "process ID")); } @@ -1331,3 +1385,17 @@ fn sort_unique(values: &mut Vec, field: &str) -> Validation<()> { Err(invalid(field, "An entry is listed more than once.")) } } + +/// New bindings have their own manifest admission version. +pub(crate) fn uses_target_arguments(adapters: &[DebugAdapterDeclaration]) -> bool { + adapters + .iter() + .filter_map(|adapter| adapter.configurations.as_ref()) + .any(|configs| { + [configs.launch.as_ref(), configs.attach.as_ref()] + .into_iter() + .flatten() + .filter_map(|config| config.target_bindings.as_ref()) + .any(|bindings| bindings.args.is_some() || bindings.env.is_some()) + }) +} diff --git a/rust/lithe-core/src/lsp/interface/build_provider.rs b/rust/lithe-core/src/lsp/interface/build_provider.rs index a02bc5ef5f..b6eb9494a6 100644 --- a/rust/lithe-core/src/lsp/interface/build_provider.rs +++ b/rust/lithe-core/src/lsp/interface/build_provider.rs @@ -15,7 +15,7 @@ use serde_json::{json, Value}; const EXPERIMENTAL_KEY: &str = "litheBuild"; /// Build-provider protocol versions this client speaks, in preference order. -const SUPPORTED_VERSIONS: [u64; 4] = [4, 3, 2, 1]; +const SUPPORTED_VERSIONS: [u64; 5] = [5, 4, 3, 2, 1]; /// Feature name recorded in the session's server capabilities once a /// provider answered a supported version. @@ -25,6 +25,18 @@ pub(crate) const BUILD_PROVIDER_FEATURE: &str = "litheBuild"; pub(crate) const EXECUTE_METHOD: &str = "lithe/build/execute"; /// Version 4 prepares a bounded launch, while the host owns the application. pub(crate) const PREPARE_RUN_METHOD: &str = "lithe/build/prepareRun"; +/// Version 5 prepares a native debug target, never an adapter process. +pub(crate) const PREPARE_DEBUG_METHOD: &str = "lithe/build/prepareDebug"; +/// Admission feature for native debug preparation. +pub(crate) const DEBUG_PREPARATION_FEATURE: &str = "litheDebugPreparation"; +/// Debug preparation requires the explicit version 5 agreement. +pub(crate) fn debug_preparation_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + == Some(5) +} + /// Admission feature for the version 4 method. pub(crate) const RUN_PREPARATION_FEATURE: &str = "litheRunPreparation"; /// Version 3 adds tests while retaining the bounded execution lifecycle. @@ -62,6 +74,7 @@ pub(crate) fn is_build_method(method: &str) -> bool { || is_execution_method(method) || method == DISCOVER_TESTS_METHOD || method == PREPARE_RUN_METHOD + || method == PREPARE_DEBUG_METHOD } /// Whether the method shares bounded output, progress, and final result handling. @@ -74,7 +87,7 @@ pub(crate) fn execution_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 2 | 3 | 4)) + .is_some_and(|version| matches!(version, 2 | 3 | 4 | 5)) } /// Tests require an explicit version 3 agreement, independent of task support. @@ -82,12 +95,12 @@ pub(crate) fn tests_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 3 | 4)) + .is_some_and(|version| matches!(version, 3 | 4 | 5)) } /// A preparation has progress and cancellation, but its reply is a launch plan. pub(crate) fn is_progress_method(method: &str) -> bool { - is_execution_method(method) || method == PREPARE_RUN_METHOD + is_execution_method(method) || method == PREPARE_RUN_METHOD || method == PREPARE_DEBUG_METHOD } /// Run preparation requires an explicit version 4 agreement. @@ -95,7 +108,7 @@ pub(crate) fn run_preparation_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - == Some(4) + .is_some_and(|version| matches!(version, 4 | 5)) } /// Normalizes bounded progress; the engine checks the pending request identity. @@ -167,7 +180,7 @@ mod tests { &json!({ "experimental": { "litheBuild": {} } }) )); assert!(!negotiated( - &json!({ "experimental": { "litheBuild": { "version": 5 } } }) + &json!({ "experimental": { "litheBuild": { "version": 6 } } }) )); assert!(!negotiated( &json!({ "experimental": { "litheBuild": { "version": "1" } } }) diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 2c70807400..980c5ac3ad 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -1756,6 +1756,12 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { if super::build_provider::tests_negotiated(capabilities) { insert_unique(&mut values, super::build_provider::TEST_EXECUTION_FEATURE); } + if super::build_provider::debug_preparation_negotiated(capabilities) { + insert_unique( + &mut values, + super::build_provider::DEBUG_PREPARATION_FEATURE, + ); + } if super::build_provider::run_preparation_negotiated(capabilities) { insert_unique(&mut values, super::build_provider::RUN_PREPARATION_FEATURE); } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 955c5c15d9..51e3d472ea 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -2129,6 +2129,19 @@ impl RuntimeSession { ) .with_details("plugin.build.unavailable")); } + if request.method == super::build_provider::PREPARE_DEBUG_METHOD + && !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::DEBUG_PREPARATION_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate debug preparation.", + ) + .with_details("plugin.build.unavailable")); + } if request.method == super::build_provider::PREPARE_RUN_METHOD && !state .client @@ -11272,6 +11285,48 @@ public class Main { } } + #[test] + fn provider_debug_preparation_requires_version_five_and_retains_progress() { + for version in [1, 2, 3, 4] { + let harness = plugin_backend(json!({"litheBuild":{"version":version}})); + let error = + build_request(&harness, "lithe/build/prepareDebug", json!({}), None).unwrap_err(); + assert_eq!(error.details.as_deref(), Some("plugin.build.unavailable")); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/prepareDebug")); + } + let mut harness = plugin_backend(json!({"litheBuild":{"version":5}})); + let operation = + build_request(&harness, "lithe/build/prepareDebug", json!({}), None).unwrap(); + let request = harness + .server + .await_request("lithe/build/prepareDebug") + .unwrap(); + let wire_id = request.clone(); + assert_eq!( + harness.written_request(&request)["params"]["operationID"], + wire_id + ); + harness.server.send(json!({"jsonrpc":"2.0","method":"lithe/build/progress","params":{"operationID":wire_id,"output":"built"}})); + let progress = harness.await_event(|event| { + event.kind == "requestProgress" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(progress.result.as_ref().unwrap()["output"], "built"); + harness.server.send(json!({"jsonrpc":"2.0","id":request,"result":{"program":"app/bin/main","arguments":[],"environment":{},"workingDirectory":"app"}})); + let done = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!( + done.result.as_ref().unwrap()["value"]["program"], + "app/bin/main" + ); + } + #[test] fn provider_run_preparation_requires_version_four_and_returns_raw_plan() { for version in [1, 2, 3] { @@ -11565,7 +11620,7 @@ public class Main { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"]["litheBuild"], - json!({ "versions": [4, 3, 2, 1] }) + json!({ "versions": [5, 4, 3, 2, 1] }) ); let error = build_request(&harness, "lithe/build/model", json!({}), None) @@ -12072,7 +12127,7 @@ public class Main { assert_eq!( initialize["params"]["capabilities"]["experimental"], json!({ - "litheBuild": { "versions": [4, 3, 2, 1] }, + "litheBuild": { "versions": [5, 4, 3, 2, 1] }, "litheFunctions": { "versions": [1, 2] }, "litheVcs": { "versions": [1] } }) diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index c7c39cb94e..232d706d7e 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -66,9 +66,9 @@ pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; /// capability keeps the meaning its section had at API level 2. pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (BACKEND, &[1]), - (BUILD_PROVIDERS, &[1, 2, 3, 4]), + (BUILD_PROVIDERS, &[1, 2, 3, 4, 5]), (BUILD_SYSTEMS, &[1, 2]), - (DEBUG_ADAPTERS, &[1]), + (DEBUG_ADAPTERS, &[1, 2]), (EXECUTION_PROVIDERS, &[1]), (EXECUTABLE_RESOLUTION, &[1]), (LANGUAGE_SERVERS, &[1, 2]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index c477efbd17..5398eeef43 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -973,6 +973,7 @@ impl DeclarativePluginManifest { crate::build::validate_declarations( &mut self.build_systems, &crate::build::DeclarationContext { + debug_adapters: &self.debug_adapters, plugin_api_version: self.plugin_api_version, build_systems_version: self.requires.get("buildSystems").copied().unwrap_or(1), build_providers_version: self.requires.get("buildProviders").copied().unwrap_or(1), @@ -1094,6 +1095,19 @@ impl DeclarativePluginManifest { declared_settings, }, )?; + if crate::debug::adapters::uses_target_arguments(&self.debug_adapters) + && self + .requires + .get(capabilities::DEBUG_ADAPTERS) + .copied() + .unwrap_or(1) + < 2 + { + return Err(invalid( + "requires.debugAdapters", + "Argument and environment bindings require debugAdapters 2.", + )); + } if self.plugin_api_version >= CAPABILITIES_API_VERSION { // serverProcess 2 exists for the selected-runtime launch alone, so // a host without it never accepts an adapter it cannot start. diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index b423c116ec..9fd0785521 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -108,6 +108,10 @@ pub enum CoreCommand { BuildTestPlan, /// Plans typed provider run preparation (`build.prepareRunPlan`). BuildPrepareRunPlan, + /// Plans native debug preparation (`build.prepareDebugPlan`). + BuildPrepareDebugPlan, + /// Validates native debug target facts (`build.decodeDebugPreparation`). + BuildDecodeDebugPreparation, /// Validates prepared launch data (`build.decodeRunPreparation`). BuildDecodeRunPreparation, /// Plans provider-owned test discovery (`build.testDiscoveryPlan`). @@ -532,6 +536,8 @@ impl CoreCommand { "build.modelPlan" => Some(Self::BuildModelPlan), "build.taskPlan" => Some(Self::BuildTaskPlan), "build.testPlan" => Some(Self::BuildTestPlan), + "build.prepareDebugPlan" => Some(Self::BuildPrepareDebugPlan), + "build.decodeDebugPreparation" => Some(Self::BuildDecodeDebugPreparation), "build.prepareRunPlan" => Some(Self::BuildPrepareRunPlan), "build.decodeRunPreparation" => Some(Self::BuildDecodeRunPreparation), "build.testDiscoveryPlan" => Some(Self::BuildTestDiscoveryPlan), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 83dfbbe455..007d8ea445 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -262,6 +262,16 @@ fn execute(request: &str) -> CoreResponse { CoreCommand::BuildTaskPlan => { plugin_response(id, parsed.payload, crate::build::build_task_plan_command) } + CoreCommand::BuildPrepareDebugPlan => plugin_response( + id, + parsed.payload, + crate::build::build_prepare_debug_plan_command, + ), + CoreCommand::BuildDecodeDebugPreparation => plugin_response( + id, + parsed.payload, + crate::build::build_decode_debug_preparation_command, + ), CoreCommand::BuildPrepareRunPlan => plugin_response( id, parsed.payload, diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index e6b01d768b..a469ca6667 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -2123,3 +2123,118 @@ fn provider_run_working_directory_rejects_external_symlinks() { "arguments":[],"workingDirectory":"escape","environment":{}}}})).unwrap(); assert_eq!(result["error"]["code"], "plugin.build.invalidOutput"); } + +#[test] +fn provider_debug_preparation_validates_native_target_and_same_plugin_adapter() { + use crate::build::{build_decode_debug_preparation_command, build_prepare_debug_plan_command}; + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-debug-preparation.plugin.json" + )) + .unwrap(); + let fixture: Value = + serde_json::from_str(contract_file!("fixtures/build-debug-preparation.json")).unwrap(); + let run: Value = + serde_json::from_str(contract_file!("fixtures/build-run-preparation.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + let planned = build_prepare_debug_plan_command( + json!({"manifest":manifest,"buildSystemID":"gradle","request":run["request"]}), + ) + .unwrap(); + assert_eq!( + planned["plan"]["provider"]["method"], "lithe/build/prepareDebug", + "{planned}" + ); + assert_eq!(planned["plan"]["decoder"], "litheDebugPreparation"); + for (field, value) in [("buildProviders", json!(4)), ("debugAdapters", json!(1))] { + let mut old = manifest.clone(); + old["requires"][field] = value; + assert_eq!(validate(old)["status"], "rejected"); + } + for adapter in ["other/native", "missing", ""] { + let mut invalid = manifest.clone(); + invalid["buildSystems"][0]["run"][0]["debug"]["adapter"] = json!(adapter); + assert_eq!(validate(invalid)["status"], "rejected"); + } + let mut overridden = manifest.clone(); + overridden["debugAdapters"][0]["configurations"]["launch"]["arguments"] = + json!({"program":"other"}); + assert_eq!(validate(overridden)["status"], "rejected"); + let workspace = TemporaryWorkspace::new("provider-native-debug"); + workspace.write("app/bin/sample", "sample"); + let decode = |result: Value| { + build_decode_debug_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"workspaceRoot":workspace.root(),"root":"app","run":run["request"]["run"],"result":result}})).unwrap() + }; + let valid = decode(fixture["result"].clone()); + assert_eq!(valid["status"], "ok", "{valid}"); + assert_eq!(valid["preparation"]["adapter"], "native"); + assert_eq!(valid["preparation"]["arguments"], json!(["a b", "x#y"])); + for invalid in fixture["invalidResults"].as_array().unwrap() { + assert_eq!(decode(invalid.clone())["status"], "rejected"); + } + for (field, value) in [ + ("program", json!("app/missing")), + ("program", json!("app")), + ("program", json!("/tmp/sample")), + ("arguments", json!(["bad\u{0}"])), + ("environment", json!({"1BAD":"x"})), + ("workingDirectory", json!(".")), + ] { + let mut invalid = fixture["result"].clone(); + invalid[field] = value; + assert_eq!(decode(invalid)["status"], "rejected"); + } +} + +#[cfg(unix)] +#[test] +fn provider_debug_preparation_rejects_a_program_symlink_outside_the_instance() { + use crate::build::build_decode_debug_preparation_command; + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-debug-preparation.plugin.json" + )) + .unwrap(); + let workspace = TemporaryWorkspace::new("debug-native-symlink"); + workspace.write("app/keep", ""); + workspace.write("other/program", ""); + std::os::unix::fs::symlink( + workspace.0.join("other/program"), + workspace.0.join("app/program"), + ) + .unwrap(); + let result = build_decode_debug_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle","request":{ + "workspaceRoot":workspace.root(),"root":"app","run":"application","result":{"program":"app/program","arguments":[],"workingDirectory":"app","environment":{}}}})).unwrap(); + assert_eq!(result["status"], "rejected"); +} + +#[test] +fn provider_debug_target_bindings_keep_exact_arguments_and_require_bounded_facts() { + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-debug-preparation.plugin.json" + )) + .unwrap(); + let target = json!({"kind":"executable","path":"/workspace/app/main","workingDirectory":"/workspace/app", + "arguments":["a b","x#y"],"environment":{"MODE":"debug"}}); + let arguments = |target: Value| { + crate::plugins::declarative::debug_start_arguments_command(json!({"manifest":manifest, + "adapterID":"native","request":"launch","facts":{"registration":{"generation":7,"currentGeneration":7},"target":target}})).unwrap() + }; + let result = arguments(target.clone()); + assert_eq!( + result["arguments"]["args"], + json!(["a b", "x#y"]), + "{result}" + ); + assert_eq!(result["arguments"]["env"], json!({"MODE":"debug"})); + for (field, value) in [ + ("arguments", Value::Null), + ("arguments", json!(["bad\u{0}"])), + ("arguments", json!(vec!["x"; 1025])), + ("environment", Value::Null), + ("environment", json!({"1BAD":"x"})), + ] { + let mut invalid = target.clone(); + invalid[field] = value; + assert_eq!(arguments(invalid)["status"], "rejected"); + } +} diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 470413b0ce..743c7c9341 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -532,6 +532,42 @@ must not impose a shorter readiness deadline. The terminal timeout code is `serviceReadyTimeout`, and its details preserve the last import, download, and cache snapshot for both products. +### Explicit debug adapter selection + +`DebugLaunchConfiguration.adapterProviderID` is optional host routing metadata. +Plugin registrations use the existing Registry identity `pluginID/adapterID`; +the host Java adapter retains `java`. The field is not a DAP argument, a process +command, or a plugin-private function call. An omitted identity uses the existing +file selection. An explicit identity, including an empty or withdrawn one, must +never fall back to another adapter matching the file. + +Qualified registrations include every declared adapter, including attach-only +and executable/process adapters. They do not become automatic file defaults. +Before creating a transport, the session manager resolves the current registration +and verifies its declared launch/attach request. An explicit target need not be +an open source file. Retry and configuration transformations retain the identity; +failed additional starts clean up their own session and preserve the previous one. + +Source breakpoints are shared by matching registered adapters, including replay +on a later launch. Function, exception and data breakpoint changes use the selected +adapter identity, even when the target has no source extension. Plugin ownership +continues to control withdrawal and process cleanup. The platform resolver still +owns manifest validation, permissions, executable resolution and supported transports; +this routing field grants no new permission and carries no adapter process or socket. + +The host fixture is [adapter-selection-v1.json](../fixtures/debug/adapter-selection-v1.json). +macOS consumes this contract. Windows has no plugin adapter launch consumer yet. +Build Provider version 5 adds a separate native debug preparation request; +version 4 run plans are never reinterpreted as debug plans. The selected run +names a same-plugin launch adapter. Core validates an existing instance-contained +program plus bounded arguments/environment/directory, and the platform rechecks +executable permissions before binding target facts through `debug.adapters.startArguments`. +Build-tree Debug and saved-provider Run Debug save documents first and revalidate +workspace, source, configuration, model and plugin identity before handoff. Debug +owns the resulting session; stopping preparation or accepting late replies cannot +start a session after invalidation. Java/JDT and cross-plugin preparation are not +covered by this native increment. + ## Error Codes Use stable categories rather than platform error strings: diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index f87c95f7cc..e2320ea0cd 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -406,9 +406,9 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | | `buildSystems` | Declarative build detection, models, and command plans | `1` | -| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; version 2 adds task execution, version 3 test execution | `1`, `2`, `3` | +| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; v2 tasks, v3 tests, v4 run, v5 native debug preparation | `1`, `2`, `3`, `4`, `5` | | `vcsProviders` | `vcsProviders[]` | `1` | -| `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters) | `1` | +| `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters); v2 adds target arguments/environment | `1`, `2` | | `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | | `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process for its pages | `1` | @@ -456,7 +456,7 @@ in `plugins.declarative.validate`, `plugins.marketplace.validateIndex`, and The macOS host sends its capability table with validation, marketplace filtering and install review. It advertises `buildSystems: [1, 2]` and -`buildProviders: [1, 2, 3]`; native Provider models, dependencies, tasks and tests +`buildProviders: [1, 2, 3, 4, 5]`; native Provider models, dependencies, tasks, tests and launch preparation share workspace-owned sessions. Backend and VCS support use their own entries in the same table. Core validates supported versions without a host table, but admission must use the actual host table. Windows does not yet advertise @@ -1028,7 +1028,7 @@ The host may add the resolved build tool hint. The provider owns upstream synchronization, build, classpath and launch facts; Core does not reconstruct those facts from source files or build configuration. -Only protocol version 4 accepts this method. Version 4 retains task and test +Protocol versions 4 and 5 accept this method. Version 4 retains task and test support. Preparation shares the Core-assigned wire `operationID`, progress, timeout, cancellation and stale-result handling of execution. It ends when preparation finishes; the application is a separate host-owned process. @@ -1059,6 +1059,50 @@ Unknown fields are ignored. The host calls `build.decodeRunPreparation` with its workspace root, instance root and raw result; any malformed plan fails as `plugin.build.invalidOutput` before executable resolution. +#### Native debug preparation (version 5) + +A provider run may additionally declare `debug: {"adapter":"native"}` with +`requires.buildProviders: 5` and `requires.debugAdapters: 2`. `adapter` is a +local ID declared by this plugin; qualified cross-package references are refused. +The adapter must serve launch of executable targets, with bindings `program` ← +`target.path`, `cwd` ← `target.workingDirectory`, `args` ← `target.arguments`, +and `env` ← `target.environment`. These fields cannot be overridden by adapter +default arguments. This first increment covers native executable launch only; +it does not accept attach endpoints, backend-selected adapters or arbitrary DAP JSON. + +`build.prepareDebugPlan` validates the same selected run/project/target and +arguments as run preparation and returns `lithe/build/prepareDebug`. Only wire +version 5 accepts it. Progress, cancellation, timeouts, tool hints, workspace +ownership and JSON-RPC failure handling follow run preparation. A successful result +contains exactly these four fields: + +```json +{"program":"app/bin/sample","arguments":["a b","x#y"],"workingDirectory":"app","environment":{}} +``` + +The provider owns synchronization/build before returning. `program` is an existing +workspace-relative regular file contained in the selected instance, including +canonical symlink resolution. Arguments, environment and directory retain run +preparation's size and containment limits. Unknown fields are refused here; there +is no adapter process, socket, shell, private function or DAP argument channel. +`build.decodeDebugPreparation` takes the selected `run` separately and returns +`preparation` with its validated `root`, declaration-owned `adapter`, and these +four fields. Missing, malformed and escaping targets fail before adapter startup. + +The native host saves documents before preparation, retains model/configuration/ +source/plugin identity through decode, and drops stale results. It checks executable +permissions and canonical containment again at handoff. `debug.adapters.startArguments` +binds the validated target facts; the existing Registry launches the adapter using +its own permissions and package process declaration. The launch configuration retains +`pluginID/adapterID` for routing and retry. Stopping preparation cancels only its +operation; the Debug host owns launched sessions and plugin withdrawal cleanup. +Saved Run configurations keep their existing selectors and prepare again for Debug. + +Fixtures: [native declaration](fixtures/build-provider-debug-preparation.plugin.json) +and [native result](fixtures/build-debug-preparation.json). This does not provide +Java/JDT preparation, long-running services or cross-plugin collaboration. + + The native Build tree re-resolves model IDs before preparation. Source/model, option, settings, package and workspace changes revoke a pending preparation. After validation the existing Run host resolves the executable and owns output, @@ -1127,11 +1171,11 @@ The manifest only says how to start the provider and which sources use it. Everything else is negotiated in the LSP `initialize` exchange, so a provider can add optional data within a negotiated protocol version: -- The host sends `capabilities.experimental.litheBuild: { "versions": [4, 3, 2, 1] }`, +- The host sends `capabilities.experimental.litheBuild: { "versions": [5, 4, 3, 2, 1] }`, the build-provider protocol versions it speaks. Version 1 is the two requests above with the params and result formats of this section; version 2 adds typed task execution; version 3 adds typed test execution - and discovery; version 4 adds application run preparation. + and discovery; version 4 adds application run preparation; version 5 adds native debug preparation. - The provider answers `capabilities.experimental.litheBuild: { "version": 1 }` with the version it chose from that list. A provider that answers no version, or one the host did not offer, is not used for build requests; a @@ -1220,7 +1264,7 @@ A manifest below level 4 may not declare `debugAdapters`, and a level 3 manifest | `requests` | `launch` (default) and/or `attach`. | | `selectors[]` | Optional automatic matching by `targetKinds` and `languageIDs`; absent means explicit selection only. | | `configurationTypes` | Imported `launch.json` `type` values the adapter answers to. | -| `configurations.launch/attach` | Default DAP `arguments`, a packaged `parametersSchema`, and `targetBindings` (`program` ← `target.path`, `processId` ← `target.processID`, `cwd` ← `target.workingDirectory`). A configuration for an undeclared request is refused. | +| `configurations.launch/attach` | Default DAP `arguments`, a packaged `parametersSchema`, and `targetBindings` (`program` ← `target.path`, `processId` ← `target.processID`, `cwd` ← `target.workingDirectory`; version 2 also supports bounded `args` ← `target.arguments` and `env` ← `target.environment`). A configuration for an undeclared request is refused. | | `configurations.launch.executable` | For a target that is a source file the adapter cannot debug directly (C, C++, Swift): `setting` names a `file` setting of the plugin, `candidates[]` (up to 8) are the paths a host looks for when the user chose nothing. Each candidate starts with `${workspaceFolder}/` or `${fileDirectory}/`, may use `${fileBasenameNoExtension}`, and has no `..`. Declare it together with `targetBindings.program: "target.executable"` and neither without the other; a launch fills `program` with the path the host settled on. The user's setting always wins: a chosen path that is not a runnable file is an error, not a reason to try the candidates. The host shows the path it found in that setting, and the setting's `description` is where the plugin teaches how to produce the program. | | `capabilities.disabled` | Standard DAP `supports*` names or `exceptionBreakpointFilters` the host treats as absent. | | `console.commandPrefix` | Text (1–8 characters, no spaces; for lldb-dap a backtick) the adapter's console reads as "what follows is a debugger command". The host puts it before the typed line only when it asks the adapter to complete, and moves the adapter's answer back, so the user never sees it. An adapter that treats a bare word as an expression (lldb-dap) otherwise mixes language keywords such as `long` into the first word's completions; the prefix limits them to commands. Declare at least this or `console.commands[]` when `console` is present. | diff --git a/shared/contracts/plugins/v1/fixtures/build-debug-preparation.json b/shared/contracts/plugins/v1/fixtures/build-debug-preparation.json new file mode 100644 index 0000000000..e7cf3e8e40 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-debug-preparation.json @@ -0,0 +1,28 @@ +{ + "result": { + "program": "app/bin/sample", + "arguments": [ + "a b", + "x#y" + ], + "workingDirectory": "app", + "environment": { + "SAMPLE": "debug" + } + }, + "invalidResults": [ + { + "program": "../sample", + "arguments": [], + "workingDirectory": "app", + "environment": {} + }, + { + "program": "app/bin/sample", + "arguments": [], + "workingDirectory": "app", + "environment": {}, + "adapter": "other/native" + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-debug-preparation.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-debug-preparation.plugin.json new file mode 100644 index 0000000000..e6d09985e3 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-debug-preparation.plugin.json @@ -0,0 +1,254 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-debug-preparation", + "displayName": "Native Debug Preparation Fixture", + "version": "0.1.0", + "pluginAPIVersion": 4, + "requires": { + "buildProviders": 5, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1, + "debugAdapters": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "debug.launch" + ], + "activationEvents": [ + "onBuildSystem:gradle", + "onDebug:native" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project", + "target" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "results": { + "decoder": "junitXml", + "reports": [ + "build/test-results/**/*.xml" + ] + }, + "discovery": true + }, + "run": [ + { + "id": "application", + "source": "provider", + "name": "Run Application", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [], + "debug": { + "adapter": "native" + } + } + ] + } + ], + "debugAdapters": [ + { + "id": "native", + "displayName": "Native DAP", + "requests": [ + "launch" + ], + "selectors": [ + { + "targetKinds": [ + "executable" + ] + } + ], + "configurationTypes": [ + "native" + ], + "configurations": { + "launch": { + "targetBindings": { + "program": "target.path", + "cwd": "target.workingDirectory", + "args": "target.arguments", + "env": "target.environment" + } + } + }, + "launch": { + "process": { + "launch": { + "kind": "systemCommand", + "commands": [ + "example-dap" + ] + } + }, + "transport": "stdio" + } + } + ] +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index eb05041a4a..c2ef2e3a98 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -28,9 +28,9 @@ "additionalProperties": false, "properties": { "backend": { "description": "Required exactly when backend is present: the plugin's own process serving its web pages.", "enum": [1] }, - "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution, version 3 test execution/discovery, and version 4 application run preparation.", "enum": [1, 2, 3, 4] }, + "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution, version 3 test execution/discovery, version 4 application run preparation, and version 5 native debug preparation.", "enum": [1, 2, 3, 4, 5] }, "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, - "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1] }, + "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. Version 2 adds bounded target argument and environment bindings. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1, 2] }, "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, @@ -440,7 +440,9 @@ }, "cwd": { "const": "target.workingDirectory" - } + }, + "args": { "const": "target.arguments" }, + "env": { "const": "target.environment" } } }, "executable": { @@ -2345,6 +2347,11 @@ "id" ], "properties": { + "debug": { + "description": "Native executable debug preparation (buildProviders 5); same-package debugAdapters 2 launch adapter with typed program/cwd/args/env bindings.", + "type": "object", "additionalProperties": false, "required": ["adapter"], + "properties": { "adapter": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "maxLength": 64 } } + }, "source": { "description": "Provider-owned application preparation (buildProviders 4), fromTargets only, without host command templates.", "enum": ["provider"] diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 779518de99..81c9ddab5c 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1114,6 +1114,8 @@ package manager owns the download and Lithe does not infer bytes from logs. | `build.decodeTestDiscovery` | Validate, deduplicate and order Provider test selectors | | `build.prepareRunPlan` | Plan Provider-owned application preparation from a model target | | `build.decodeRunPreparation` | Validate portable launch data before the native Run host starts a process | +| `build.prepareDebugPlan` | Plan same-plugin native debug preparation (Provider v5) | +| `build.decodeDebugPreparation` | Validate an existing instance-contained native debug program and bounded process facts | | `build.dependencyPlan` | Expand the command that prints a project's dependency tree, or the request a build provider answers | | `build.decodeModel` | Decode model output or a provider result into projects, targets, tasks, content roots, option values, and run entries | | `build.decodeTestResults` | Decode a test run's reports and output into per-case results and a summary | @@ -2576,7 +2578,7 @@ provider-sourced `build.taskPlan`/`build.testPlan`, `build.testDiscoveryPlan`, o `lithe/build/execute`, version 3 adds `lithe/build/test` with the same bounded execution lifecycle and `lithe/build/discoverTests` with the raw result lifecycle. Version 4 adds `lithe/build/prepareRun`, with execution progress but a raw launch -plan passed to `build.decodeRunPreparation` before native process startup. +plan passed to `build.decodeRunPreparation` before native process startup. Version 5 adds `lithe/build/prepareDebug`: raw native executable facts pass through `build.decodeDebugPreparation` and the existing DebugAdapter Registry, not private functions or a second DAP engine. Other methods are `invalid_request`; tasks on version 1 and tests on versions 1/2 or run preparation on versions 1/2/3 are `not_supported`. Every session offers @@ -3445,6 +3447,8 @@ and the directories are absolute host paths used only as substitution text. | `build.decodeTestDiscovery` | common plan fields plus canonical `result` | `discovery.tests` with validated `{id, suite, case, project?, target?}` selectors; bounded at 10,000 items/32 MiB, deterministic and exactly runnable; malformed replies reject in full | | `build.prepareRunPlan` | common plan fields, `run`, required `project`/`target`, `userArguments?`; no output file | `plan.provider` for `lithe/build/prepareRun` (Provider v4), selected declaration/tags/paths/options validated | | `build.decodeRunPreparation` | `workspaceRoot`, instance `root`, raw `result` | `launch` with portable `{executable:{kind,value},arguments,workingDirectory,environment}`; bounded strings and workspace directory containment, `plugin.build.invalidOutput` on invalid result | +| `build.prepareDebugPlan` | same selected fields as `build.prepareRunPlan`; run declares same-plugin `debug.adapter` | `plan.provider` for `lithe/build/prepareDebug` (v5); no cross-package reference | +| `build.decodeDebugPreparation` | `workspaceRoot`, instance `root`, selected `run`, raw `result` | `preparation` with declaration-owned `adapter`, normalized `root`, `program`, `arguments`, `workingDirectory`, `environment`; program is an existing regular file contained in the instance after canonicalization | | `build.decodeModel` | `{ workspaceRoot, root, output?, outputFile?, result?, options? }`; `result` is the provider's JSON answer | `model`: `{ projects, targets, tasks, contentRoots, optionValues, run }`; `run` entries carry `buildSystem: { id, root, projectID, targetID?, optionFingerprint }` | | `build.decodeTestResults` | `{ workspaceRoot, root, project?, projects?, output, notBeforeMillis?, suite? }`; without `project` the reports of the root and every listed model project are read | `results`: `{ tests: [{ suite, case, status, message?, durationMillis?, path?, line?, invocations }], summary: { total, passed, failed, errors, skipped }, success }`; repeated invocations of one case merge into the most severe status | | `build.decodeDiagnostics` | `{ workspaceRoot, root, output }` | `diagnostics`: `[{ path, line, column?, severity, message }]`, deduplicated, inside the workspace only | diff --git a/shared/fixtures/debug/adapter-selection-v1.json b/shared/fixtures/debug/adapter-selection-v1.json new file mode 100644 index 0000000000..b265a65b53 --- /dev/null +++ b/shared/fixtures/debug/adapter-selection-v1.json @@ -0,0 +1,118 @@ +{ + "format": "litheDebugAdapterSelection", + "version": 1, + "providers": [ + { + "id": "c", + "displayName": "Default C", + "fileExtensions": [ + "c" + ], + "selectsByFile": true, + "requests": [ + "launch", + "attach" + ] + }, + { + "id": "dev.example.native/lldb", + "displayName": "Native", + "fileExtensions": [ + "c" + ], + "selectsByFile": false, + "requests": [ + "launch" + ] + }, + { + "id": "dev.example.attach/native", + "displayName": "Attach", + "fileExtensions": [ + "c" + ], + "selectsByFile": false, + "requests": [ + "attach" + ] + } + ], + "cases": [ + { + "id": "file-default", + "target": "main.c", + "configuration": { + "name": "file-default", + "request": "launch", + "arguments": {} + }, + "expectedProviderID": "c" + }, + { + "id": "qualified-file", + "target": "main.c", + "configuration": { + "name": "qualified-file", + "request": "launch", + "arguments": {}, + "adapterProviderID": "dev.example.native/lldb" + }, + "expectedProviderID": "dev.example.native/lldb" + }, + { + "id": "qualified-project", + "target": ".", + "configuration": { + "name": "qualified-project", + "request": "launch", + "arguments": {}, + "adapterProviderID": "dev.example.native/lldb" + }, + "expectedProviderID": "dev.example.native/lldb" + }, + { + "id": "missing-qualified", + "target": "main.c", + "configuration": { + "name": "missing-qualified", + "request": "launch", + "arguments": {}, + "adapterProviderID": "dev.example.missing/native" + }, + "error": "unavailable" + }, + { + "id": "empty-explicit", + "target": "main.c", + "configuration": { + "name": "empty-explicit", + "request": "launch", + "arguments": {}, + "adapterProviderID": "" + }, + "error": "unavailable" + }, + { + "id": "unsupported-request", + "target": "main.c", + "configuration": { + "name": "unsupported-request", + "request": "launch", + "arguments": {}, + "adapterProviderID": "dev.example.attach/native" + }, + "error": "unsupported" + }, + { + "id": "explicit-attach", + "target": ".", + "configuration": { + "name": "explicit-attach", + "request": "attach", + "arguments": {}, + "adapterProviderID": "dev.example.attach/native" + }, + "expectedProviderID": "dev.example.attach/native" + } + ] +} diff --git a/shared/fixtures/debug/v1-baseline.json b/shared/fixtures/debug/v1-baseline.json index e3640aa51f..6f259f1611 100644 --- a/shared/fixtures/debug/v1-baseline.json +++ b/shared/fixtures/debug/v1-baseline.json @@ -456,6 +456,30 @@ } ], "gaps": [] + }, + { + "id": "explicit-adapter-routing", + "behavior": "host-selected adapter identities survive configuration transforms and retry; missing registrations and unsupported requests never fall back to a matching source-file adapter; fileless targets and concurrent matching adapters retain session ownership", + "status": "covered", + "evidence": [ + { + "platform": "macos", + "path": "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift", + "test": "adapterSelectionMatchesTheSharedHostFixture", + "fixture": "shared/fixtures/debug/adapter-selection-v1.json" + }, + { + "platform": "macos", + "path": "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift", + "test": "explicitFeatureRetryKeepsItsAdapterAndFailureCleansUpOnlyItsNewSession" + }, + { + "platform": "macos", + "path": "macos/Tests/LitheTests/PluginLiveActivationTests.swift", + "test": "qualifiedAdaptersKeepEveryPackageAndNeverBecomeFileDefaults" + } + ], + "gaps": [] } ] } diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 5630a4305d..abdfa8c94c 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9380,7 +9380,8 @@ "native.providerRunSavingUnavailable": "Saving build provider run configurations is unavailable.", "native.loadProviderModelBeforeRun": "Load the current build model and enable its provider before running this configuration.", "native.providerRunSaveConflict": "Run configuration changed while saving. Reload and try again.", - "native.savedProviderRunUnavailable": "The saved provider run configuration is unavailable." + "native.savedProviderRunUnavailable": "The saved provider run configuration is unavailable.", + "native.providerDebugUnavailable": "The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 6c01d2275a..d1b8849e37 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9380,7 +9380,8 @@ "native.providerRunSavingUnavailable": "当前无法保存构建提供方运行配置。", "native.loadProviderModelBeforeRun": "请先加载当前构建模型并启用对应提供方,再运行此配置。", "native.providerRunSaveConflict": "保存期间运行配置已更改。请重新加载后再试。", - "native.savedProviderRunUnavailable": "已保存的提供方运行配置不可用。" + "native.savedProviderRunUnavailable": "已保存的提供方运行配置不可用。", + "native.providerDebugUnavailable": "构建插件的调试程序或适配器不可用。请重新构建,并检查插件是否已启用。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index a360aed6f9..e9c079014e 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -89276,6 +89276,17 @@ "key": "The saved provider run configuration is unavailable.", "arguments": [] } + }, + { + "id": "native.providerDebugUnavailable", + "description": "Native UI: Provider debug target unavailable", + "platforms": [ + "macos" + ], + "macos": { + "key": "The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index a38b728a56..266483b6e6 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -2,7 +2,7 @@ "id": "build-system-plugins", "area": "扩展", "group": "插件与主题", - "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退;buildProviders 2/3 原生任务与测试执行复用工作区后端会话,带进度、取消、结果与配置失效隔离;协议 3 可按声明发现测试并在现有构建树按精确选择器运行,源码/设置变化撤销旧清单;协议 4 可为应用目标准备启动计划,Core 校验后交给原生 Run 管理进程;Provider 应用可保存到本地 Run v2 配置并重新准备后重启", + "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退;buildProviders 2/3 原生任务与测试执行复用工作区后端会话,带进度、取消、结果与配置失效隔离;协议 3 可按声明发现测试并在现有构建树按精确选择器运行,源码/设置变化撤销旧清单;协议 4 可为应用目标准备启动计划,Core 校验后交给原生 Run 管理进程;Provider 应用可保存到本地 Run v2 配置并重新准备后重启;插件自有原生程序经 Provider v5 构建并准备 Debug,Build 树和保存的 Run 配置复用原生调试会话", "macos": { "evidence": [ "rust/lithe-core/src/build", @@ -46,7 +46,11 @@ "shared/contracts/run-configuration-v2.schema.json", "macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift", "macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift", - "macos/Tests/LitheTests/IdeCapabilitiesTests.swift" + "macos/Tests/LitheTests/IdeCapabilitiesTests.swift", + "rust/lithe-core/src/build/debug_preparation.rs", + "shared/contracts/plugins/v1/fixtures/build-provider-debug-preparation.plugin.json", + "macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -61,5 +65,5 @@ "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。命令模型 run 配置尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实工具及 Windows 工作流仍待验收。;buildProviders 4 应用目标准备接入原生 Build 树,构建进度在 Build Output 展示,验证后转交 RunService 解析工具与启动/停止应用;准备期间源码、选项、工作区或停用变化拒绝迟到启动,旧操作 ID 不得停止替换进程;Core 拒绝绝对可执行路径、越界目录/符号链接及超限参数环境。服务编排、调试与 Java/Maven 消费者迁移尚未完成;真实插件/发行包和 Windows 工作流仍待验收。;Run v2 本地层显式保存 Provider 选择器,参数保持原始数组;无 generated.json 也能读回/显示,重复保存保留 ID/其他配置/项目环境;重启重新进入当前 Provider,停止与晚到结果按身份隔离。原生入口保存工作区文档后再准备,Run 窗口在未生成旧配置时仍显示 Provider 输出。;Agent 入口回归从真实本地配置启动,验证准备到进程退出保持公开操作 ID、准备中停止与重启重新准备,旧 ID 不能停止替换操作;Provider 与进程为可控替身,真实插件端到端验收仍待完成。;配套独立仓库 Node/Java SDK 源码补齐协议 2/3/4 的处理器、版本准入与进度;无页面示例经真实 Core 验证非零构建退出码、精确筛选、运行前构建及准备计划、并行工作区、取消工具清理和完整包不变,另保留锁定旧契约的兼容场景与 TypeScript 调用方编译检查。SDK 尚未发布,真实 Maven/JDT 与 Windows 验收仍待完成。" + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。命令模型 run 配置尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实工具及 Windows 工作流仍待验收。;buildProviders 4 应用目标准备接入原生 Build 树,构建进度在 Build Output 展示,验证后转交 RunService 解析工具与启动/停止应用;准备期间源码、选项、工作区或停用变化拒绝迟到启动,旧操作 ID 不得停止替换进程;Core 拒绝绝对可执行路径、越界目录/符号链接及超限参数环境。服务编排、调试与 Java/Maven 消费者迁移尚未完成;真实插件/发行包和 Windows 工作流仍待验收。;Run v2 本地层显式保存 Provider 选择器,参数保持原始数组;无 generated.json 也能读回/显示,重复保存保留 ID/其他配置/项目环境;重启重新进入当前 Provider,停止与晚到结果按身份隔离。原生入口保存工作区文档后再准备,Run 窗口在未生成旧配置时仍显示 Provider 输出。;Agent 入口回归从真实本地配置启动,验证准备到进程退出保持公开操作 ID、准备中停止与重启重新准备,旧 ID 不能停止替换操作;Provider 与进程为可控替身,真实插件端到端验收仍待完成。;配套独立仓库 Node/Java SDK 源码补齐协议 2/3/4 的处理器、版本准入与进度;无页面示例经真实 Core 验证非零构建退出码、精确筛选、运行前构建及准备计划、并行工作区、取消工具清理和完整包不变,另保留锁定旧契约的兼容场景与 TypeScript 调用方编译检查。SDK 尚未发布,真实 Maven/JDT 与 Windows 验收仍待完成。 原生 Debug:运行声明引用同包适配器,点击 Build 树 Debug 或保存配置的 Debug 后先保存并构建,验证程序路径/参数/环境交给注册表;停止、源码或配置变化、模型重载、插件停用与工作区切换均丢弃过期结果,解码后的文件别名变化和不可执行文件在平台交接前拒绝。Node/Java SDK 进程的准备协议与 Core 解码有端到端覆盖;真实适配器、Java/JDT、跨插件协作和 Windows 尚未验收。" } diff --git a/shared/platform-feature-matrix/features/plugin-debug-adapters.json b/shared/platform-feature-matrix/features/plugin-debug-adapters.json index 4c192d6bee..7567e9b77b 100644 --- a/shared/platform-feature-matrix/features/plugin-debug-adapters.json +++ b/shared/platform-feature-matrix/features/plugin-debug-adapters.json @@ -2,7 +2,7 @@ "id": "plugin-debug-adapters", "area": "运行与调试", "group": "调试器", - "capability": "插件提供调试适配器:声明式 debugAdapters 经共享 Core 校验,宿主按已安装插件拉起适配器、启动与附加调试、调试控制台提示符与补全,安装、更新、启停、卸载免重启生效", + "capability": "插件提供调试适配器:声明式 debugAdapters 经共享 Core 校验,宿主按已安装插件拉起适配器、启动与附加调试、调试控制台提示符与补全,安装、更新、启停、卸载免重启生效;原生启动配置保留明确适配器身份,支持无源文件目标及同语言多适配器的会话路由;debugAdapters v2 绑定受限的目标参数与环境,并接收同包 Provider 原生调试准备", "macos": { "evidence": [ "shared/contracts/plugins/v1/fixtures/debug-adapter.plugin.json", @@ -20,7 +20,12 @@ "macos/Tests/LitheTests/PluginLiveActivationTests.swift", "macos/Tests/LitheTests/PluginDebugAdapterTests.swift", "macos/Tests/LitheTests/DebugExecutableResolverTests.swift", - "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift" + "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift", + "shared/fixtures/debug/adapter-selection-v1.json", + "macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift", + "macos/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift", + "rust/lithe-core/src/build/debug_preparation.rs", + "macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -34,5 +39,5 @@ "verificationStatus": "not-applicable" }, "owner": "Debug", - "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 仍使用宿主自带适配器。capabilities.disabled 中的能力在调试会话里不可用,并随 debug.createSession 传给 Core。会话进行中禁用、更新或卸载该插件,或在模块设置里停用它,只停止该插件拥有的会话,同语言其他插件的会话继续运行。启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。" + "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 仍使用宿主自带适配器。capabilities.disabled 中的能力在调试会话里不可用,并随 debug.createSession 传给 Core。会话进行中禁用、更新或卸载该插件,或在模块设置里停用它,只停止该插件拥有的会话,同语言其他插件的会话继续运行。启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。 原生宿主路由回归:使用 shared/fixtures/debug/adapter-selection-v1.json 验证自动文件选择、明确身份、无源文件目标、空身份、已撤回身份和 launch/attach 不匹配;同语言多个适配器分别收到断点,重试保持身份,附加会话启动失败保留原会话。这里只验证宿主交接与受控会话,Provider 原生程序准备 RPC 有受控流程与真实 SDK 进程覆盖;真实适配器的完整工作流与 Windows 接入仍未验收。" } From 58ca81cf66c5c6e281c7df73ac67adb3526174f3 Mon Sep 17 00:00:00 2001 From: Mucheen <145200703+Mucheen@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:23:27 +0800 Subject: [PATCH 05/76] feat(plugins): share read-only provider model inputs (#1495) --- ...026-10-26-build-provider-task-execution.md | 78 +++- ...ue-1495-plugin-migration-remaining-plan.md | 72 ++++ .../Core/Rust/RustBuildSystemOperations.swift | 21 +- .../Execution/BuildSystemContracts.swift | 46 +- .../Plugins/DeclarativePluginContracts.swift | 8 +- .../Services/BuildSystemService.swift | 103 ++++- .../LitheModuleAPI/Plugins/PluginTypes.swift | 2 +- .../BuildSystemServiceTests.swift | 188 ++++++++- .../RustBuildSystemOperationsTests.swift | 38 ++ rust/lithe-core/src/build/commands.rs | 12 + rust/lithe-core/src/build/manifest.rs | 14 +- rust/lithe-core/src/build/mod.rs | 1 + rust/lithe-core/src/build/model.rs | 6 +- rust/lithe-core/src/build/model_inputs.rs | 398 ++++++++++++++++++ rust/lithe-core/src/build/plans.rs | 18 + .../src/lsp/interface/build_provider.rs | 22 +- rust/lithe-core/src/lsp/interface/client.rs | 3 + rust/lithe-core/src/lsp/interface/engine.rs | 53 ++- rust/lithe-core/src/plugins/capabilities.rs | 5 +- rust/lithe-core/src/plugins/declarative.rs | 7 + rust/lithe-core/src/protocol/command.rs | 3 + rust/lithe-core/src/runtime/dispatcher.rs | 5 + rust/lithe-core/src/tests/build_systems.rs | 171 ++++++++ shared/contracts/plugins/v1/README.md | 78 +++- .../fixtures/build-model-consumer.plugin.json | 170 ++++++++ .../fixtures/build-model-producer.plugin.json | 158 +++++++ .../plugins/v1/plugin-manifest.schema.json | 5 +- shared/contracts/rust-core-api.md | 9 +- .../build-provider-readonly-model-inputs.json | 30 ++ .../features/build-system-plugins.json | 2 +- 30 files changed, 1674 insertions(+), 52 deletions(-) create mode 100644 docs/development/issue-1495-plugin-migration-remaining-plan.md create mode 100644 rust/lithe-core/src/build/model_inputs.rs create mode 100644 shared/contracts/plugins/v1/fixtures/build-model-consumer.plugin.json create mode 100644 shared/contracts/plugins/v1/fixtures/build-model-producer.plugin.json create mode 100644 shared/platform-feature-matrix/features/build-provider-readonly-model-inputs.json diff --git a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md index eba7173758..e6edf6b82f 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md +++ b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md @@ -54,7 +54,7 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 本次没有新增下载入口、生成目录、缓存路径或用户数据迁移。执行复用既有 `workspaceStateDir` 和进程生命周期;插件包与发行目录只读,运行状态、日志、锁和输出仍由现有平台存储 adapter 提供位置。可变工作区状态不跨工作树共享,不影响代码签名或 Sparkle 增量更新的基线。 -契约/协议变更合并前,用 `lithe-plugin-docs` 的 SDK 端到端工具验证当前源码;合并后按[契约发布规则](2026-10-23-plugin-contract-publishing.md)同步副本。已有 SDK 模型/依赖示例须继续协商版本 1 并正常工作;SDK 的新执行处理器与 Maven 插件接入属于后续独立交付,本次不宣称它们已迁移。 +契约/协议变更合并前,用 `lithe-plugin-docs` 的 SDK 端到端工具验证当前源码;合并后按[契约发布规则](2026-10-23-plugin-contract-publishing.md)同步副本。已有 SDK 模型/依赖示例须继续协商版本 1 并正常工作;SDK 的执行处理器和 Maven 插件接入在独立仓库分别推进;本地源码验证、正式发版与旧生产路径删除必须分别记录,不互相代替。 ### 正确做法与反例 @@ -102,6 +102,64 @@ Build 树和已保存的 Provider Run 配置都有 Debug 入口,启动前先 这个纵向增量只覆盖插件自有原生可执行程序。Java/JDT、跨插件准备与长期服务仍未迁移,不能据此声称整个阶段 C 或 Java Support 拆分已完成。 +### 已启用 Provider 之间只共享只读工程输入 + +构建插件与另一个插件交换工程事实,先通过宿主提供快照,不让后端互相调用。 +生产者明确开放项目、目标和内容目录,以及选定的普通构建选项;接收者明确声明 +插件 ID 与构建系统 ID。Core 检查数据范围并删除任务、运行计划、设置和未公开的 +选项值,宿主只提供当前工作区内已启用包的事实。工程图仍由已有上游引擎产生, +Core 不从源码或构建脚本再推导一份。 + +例如构建插件开放项目路径与选中的 profile,另一个 Provider 可以据此准备自己的 +工程模型。没有生产者、还在加载、失败或模型已经过期,都有单独的结果,不能拿上 +一次的模型冒充 ready。配置改了又改回也产生新的快照代次;宿主立即取消消费者, +并在后端回复和解析完成后再次检查,防止忽略取消的旧结果发布。 + +这是 `buildModelInputs: 1` 与 Provider 协议 6 的窄入口,不是插件安装依赖或公开 +函数系统。第一版生产者不能同时消费输入,避免递归转发和失效循环;缺失生产者 +不会被下载或自动启动。比如消费者不应借公开项目数据取得生产者的进程、权限或 +私有函数。完整安装闭包、包版本选择、信任提示和事务回滚仍按 +[插件依赖与导出提案](../../proposed/architecture/2026-10-25-plugin-dependencies-and-exports.md) +独立设计,本增量没有提前实现这些决策。 + +这项能力已接入原生模型、依赖、构建、测试、发现与启动准备的计划入口,并不等于 +Java 语言后端、Maven 插件已经改用它;现有 Java/Maven 业务迁移仍待后续交付。 +共享快照只存在于请求内存,复用既有工作区资源 adapter,不引入缓存目录或下载物, +不修改安装包、签名或 Sparkle 增量更新基线。Windows 宿主仍未接入,不能以 Core +识别协议代替 Windows 功能验收。 + +### Maven 插件的首个真实执行接入 + +独立社区插件 [lithe-plugin-maven](https://github.com/Lithe-IDEA/lithe-plugin-maven) +的本地后续实现把生命周期、模型中的插件目标、自定义目标和测试改成 Provider 来源, +删除清单中对应的命令模板。后端沿用自己的 Maven 核心扩展读取反应堆(当前 Maven +构建包含的模块)和插件目标,在执行前重新确认所选工程与目标属于当前模型;不把 +宿主传来的一个 properties.goal 当作已授权的模型事实。 + +模型读取和执行都消费同一组生效选项,由插件生成 Maven 参数。嵌套工程的选择按 +工作区相对路径解释,不能再在构建根前重复拼接一段目录。显式测试请求使用 Surefire +公开的类/方法选择接口;依赖模块可以没有所选用例,但整个请求必须产生所选测试的 +新报告。比如请求不存在的方法时不能拿“退出码 0、零测试”或者旧 XML 当作成功。 +报告的完整展示继续走已有 Core JUnit XML 解析器,插件这里只核对真实执行证据。 +依据为 [Maven CLI](https://maven.apache.org/ref/3-LATEST/maven-embedder/cli.html) 与 +[Surefire 测试选择](https://maven.apache.org/surefire/maven-surefire-plugin/test-mojo)。 + +共享模型只公开规范化事实与 profiles/offline/skipTests 的生效值;不开放 settings.xml +内容、未声明选项、运行计划或私有函数。执行复用 SDK 的有界输出、取消及子进程清理, +模型快照继续使用既有工作区状态目录。构建产物和测试报告写用户工程 target,插件 +安装包保持只读,没有新增产品下载入口或运行缓存。 + +这批源码在独立仓库的 [Maven Draft PR #7](https://github.com/Lithe-IDEA/lithe-plugin-maven/pull/7) 提交,配套 SDK 见 [Draft PR #4](https://github.com/Lithe-IDEA/lithe-plugin-docs/pull/4),均尚未合并或发布。正式 SDK 锁文件仍指向已经发布的旧版, +没有伪造新校验值,也不放松正式 package.mjs 的 SHA-256 校验。发布前必须先交付配套 +Java SDK 并更新插件锁定值。开发验证通过显式 SDK JAR 构建独立临时包,记录 SDK +哈希与包文件清单,既不生成发行 ZIP,也不改 dist 或 SDK 锁文件;这些本地构建状态 +不跨工作树复用。 + +真实 Core C ABI、插件 Java 进程和 Maven 三模块工程已经一起验证模型读取、模块编译、 +精确单方法测试、报告解析及公开模型投影;没有启动完整 macOS UI。旧 MavenService、 +Agent 专属入口、项目设置、运行配置和 Java/JDT 协同仍未迁移,因此只算阶段 D 的一个 +执行切片,不能宣布阶段 C 的全部官方插件同步或阶段 D 完成。 + ## 考虑过的备选方案 - **扩展独立页面 backend。** 它是全应用范围的私有服务;改为工作区服务需要重复已有 Provider 的作用域和清理。执行首先复用现有工作区机制。 @@ -148,6 +206,22 @@ cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib lsp 原生调试准备的本地增量报告是 `provider-debug-preparation-macos.json`(375 项)、`provider-debug-preparation-core.json`(1,304 项)、`provider-debug-sdk-unit.json`(114 项)和 `provider-debug-sdk-native.json`(6 项),均有对应 HTML/JUnit。报告覆盖当前 Swift/Node/JDK 运行时的受控宿主与真实 SDK 进程;原生调试准备取消后构建子进程结束、另一工作区仍可用,完整插件包保持只读。平台交接回归另验程序不可执行、程序/实例别名变化以及插件撤回和替换;没有启动真实 LLDB、JDT 或 Windows 产品,所以功能矩阵继续保持 `pending`。 +只读模型共享增量的本地报告为 `provider-model-inputs-macos.json`(413 项)、 +`provider-model-inputs-core.json`(1,307 项)、`provider-model-inputs-sdk-unit.json` +(117 项)与 `provider-model-inputs-sdk-native.json`(6 项),均有对应 HTML/JUnit。 +其中两个独立 SDK 后端经过真实 Core 交换公开投影,消费结果保留共享快照代次与项目数; +包文件清单不变。宿主回归覆盖停用、重载、构建文件、工作区替换、选项 ABA,以及 +后台解析后仍不能发布旧事实;生产者跨 ABA 加载的模型也保持 stale。 +这些仍是本地受控与进程集成证据,不代表真实 Java/Maven 插件迁移或 Windows 验收。 + +Maven 首个执行接入的本地报告以 `provider-maven-` 为前缀:`native-maven`(12 项)、 +`native-maven-core`(7 项)、`maven-integration`(16 项)、`backend`(16 项)和 +`process-regressions`(8 项),共 59 项回归;独立开发打包另外有 3 项记录。 +本机使用 Microsoft JDK 21 与 Maven 3.9.16,后端 52 个类和扩展 5 个类都检查为 +Java 8 字节码(major 52),这不代表已经在 Java 8 运行。原生单方法测试经过 Core +计划、真实 Maven/Surefire 和 Core 报告解析;完整测试类名不能误匹配其他包的同名后缀;断言失败时的测试清理也会回收 SDK +另起进程组的工具。完整报告、命令与源码哈希保存在本地 `.artifacts/test-stability/`。 + 在 `lithe-plugin-docs` 运行以下入口可以复验 SDK 部分: ```bash @@ -163,6 +237,8 @@ node scripts/sdk-e2e.mjs --source /path/to/Lithe-IDEA --working-tree --build-wor 这些证据不替代真实插件工程、发行包和 Windows 实机验收,功能矩阵保持 `pending`。没有新增下载物或产品资源目录;本地用户配置不属于可跨工作树共享的构建缓存。新增目录或依赖时仍须遵循资源复用和安装包只读规则。 +剩余合并发布、Maven 全生产入口、Java/JDT 与跨平台收尾按[四个大任务执行计划](../../../../docs/development/issue-1495-plugin-migration-remaining-plan.md)推进;计划不是已完成功能清单。 + ## 适用范围 - `rust/lithe-core/src/build/`、`rust/lithe-core/src/lsp/interface/`、`rust/lithe-core/src/plugins/` diff --git a/docs/development/issue-1495-plugin-migration-remaining-plan.md b/docs/development/issue-1495-plugin-migration-remaining-plan.md new file mode 100644 index 0000000000..1669638aac --- /dev/null +++ b/docs/development/issue-1495-plugin-migration-remaining-plan.md @@ -0,0 +1,72 @@ +# #1495 剩余插件迁移:四个大任务 + +这是执行计划,不代表下面的迁移已经完成。总目标见 [#1495](https://github.com/1lck/Lithe-IDEA/issues/1495),职责和取舍以 [Build Provider 决策笔记](../../.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md) 为准。每个大任务可以包含多个可验收 PR,不能只增加新接口而一直保留旧业务入口。 + +## 当前交付边界 + +- 阶段 A 清单在 [PR #1513](https://github.com/1lck/Lithe-IDEA/pull/1513),独立分发在 [PR #1483](https://github.com/1lck/Lithe-IDEA/pull/1483),保持原范围,不重复认领。 +- 宿主/Core 的原生构建、测试发现/执行、运行/原生调试准备、保存的运行配置和只读模型共享在 [Draft PR #1517](https://github.com/1lck/Lithe-IDEA/pull/1517)。配套实现为 [SDK Draft PR #4](https://github.com/Lithe-IDEA/lithe-plugin-docs/pull/4) 和 [Maven Draft PR #7](https://github.com/Lithe-IDEA/lithe-plugin-maven/pull/7)。 +- Maven 插件已完成本地原生执行切片:生命周期、插件目标、自定义目标和测试由工作区后端执行;模型仍来自现有 Maven 扩展。它尚未替换宿主全部 Maven 消费者。 +- 这些是开发源码和本地验证;正式 SDK/插件发行、完整 macOS UI、Java/JDT 和 Windows 实机验收不能据此勾选完成。#1495 保持开放。 + +## 任务一:完成宿主、契约、SDK 与 Maven 插件的合并发布闭环 + +**目标:** 用户能从正常插件渠道安装一组明确兼容、校验通过的最新版本,不依赖本地 SDK JAR。 + +**范围与顺序:** + +1. 完成 #1517 的评审和本地/CI 验证,合入 preview;确认原生 Provider 与只读模型共享的最终契约。 +2. 在 `lithe-plugin-docs` 用合并后的 preview commit 运行 `node scripts/contract.mjs sync --revision `;不手改镜像或伪造锁定值。 +3. 合并 Node/Java SDK 实现,在锁定 Core 与当前 preview 上运行真实端到端场景;补 Node 20、Java 8 运行证据后,按其发布流程发布实际的新版本。 +4. Maven 插件将 `sdk.lock.json` 和构建依赖更新到实际发布的版本、URL、SHA-256;用正式 `package.mjs` 验证,开发构建覆盖不能进入发行流程。 +5. 验证最新宿主的安装、更新、能力拒绝提示和插件包只读。只有实际符合来源、签名和市场规则的工件才进入分发。 + +**退出条件:** 三仓 PR 和依赖顺序可追踪;契约副本逐字节一致;SDK 和 Maven 包都有真实发行地址与校验值;正式打包、最低支持运行时和当前平台流程通过。未实现的 Windows Provider 能力继续明确拒绝。这个任务不负责删除全部旧 Maven/Java 业务。 + +## 任务二:完成 Maven 全部生产入口、配置和用户数据迁移 + +**依赖:** 任务一可安装的宿主/SDK/插件组合;复用 [#818](https://github.com/1lck/Lithe-IDEA/issues/818)、[#1493](https://github.com/1lck/Lithe-IDEA/issues/1493) 的真实配置来源与存储规则。 + +**目标:** Maven 工程、依赖、配置与任务只有插件后端这一份领域持有方,UI 和 Agent 消费同一份生效配置。 + +**范围:** + +- 切换原生 Build、Agent Maven 能力、项目加载、项目设置、依赖导航、运行配置生成及 Java 导入配置等全部当前调用链;不能仅迁工具窗口。 +- 处理 Profiles、Skip Tests、settings、仓库与工具路径的真实来源、优先级、草稿确认、冲突保护、保存后读回和一次性旧配置迁移。失败保留源数据,不增加影子配置或双写。 +- 运行、测试、调试消费当前工程/配置快照;Maven 事实继续来自 Maven 上游,不再由宿主另解析一份 POM、模块图或依赖模型。 +- 每条新路径成立后删除对应 `MavenService`、`MavenFeatureModel`、POM 专属加载、旧存储及 fallback。移除共享 Maven 命令前,同时迁移其当前 Windows 消费者;不能为了删除行数破坏另一平台。 + +**退出条件:** 多模块工程从打开、配置读写、同步、构建,到 UI/Agent 触发相关操作使用一致配置;重开、失败、取消、配置 ABA 与工作区切换可验收;旧 macOS Maven 服务、配置副本和 fallback 已删除。共享命令的现有 Windows 调用也完成切换,迁移证据和删除清单可逐项对照。 + +## 任务三:完成 Java/JDT 领域后端及 Run/Test/Debug/框架迁移 + +**依赖:** 任务二的 Maven 配置所有权;复用 [#1210](https://github.com/1lck/Lithe-IDEA/issues/1210) 的 DebugAdapter Registry 与当前通用 LSP/DAP,不另建调试器或项目引擎。 + +**目标:** Java 插件真正拥有 Java 领域逻辑,宿主与 Core 保留通用交互、校验和生命周期。 + +**范围与里程碑:** + +1. 盘点当前 JDT LS、Java Debug Server、测试扩展及 Maven/JDT 协作的公开能力;优先复用完整上游子系统。确定 Java 消费工程配置和调试准备所需的最小公共契约,先落共享 fixture,再接生产入口;当前只读模型输入并不等于全部 Java 协作能力已经具备。 +2. 把 JDT 启动、资源解析、工作区状态和 Java 专属协调移入插件后端;运行期状态经平台可写目录提供,完整语言服务器资源在构建阶段固定来源、校验并签入插件包。 +3. 迁移入口发现、保存后同步/构建、类路径与模块路径解析、Run、精确 Test、Debug、JDT 调试端口、Spring/MyBatis,以及现有长期 Java 应用所需的生命周期。由真实迁移需求决定必要的通用服务能力,不顺带重建一个无限范围的 Services 平台。 +4. 把 AppModel、FeatureModel、平台 JDT host integration 和 Rust LSP engine 中相应特例切换到公共接口,再删除被替代的 Java/Maven 领域分支。SDK、契约和当前 Windows 消费者随具体接口同步迁移。 + +**退出条件:** 当前 macOS Java 工程清单逐项有新路径;保存→同步/构建→解析运行路径→启动的完整流程通过;测试、调试和框架行为保持;停用插件正确撤销能力且不会唤起旧实现;宿主/Core 不再保留仅供官方 Java 插件使用的领域特例。真实 JDT/DAP 和发行插件的运行证据齐全。 + +## 任务四:完成跨平台与迁移收尾,验收后关闭总任务 + +**依赖:** 前三项的代码与发行工件;Windows 同步修改随前面接口迁移推进,不等到最后才发现当前消费者已断开。 + +**范围:** + +- 复核 Windows 当前共享命令、Provider 会话、平台 adapter 与发行资源;执行真实 Windows 构建及产品流程,不用 macOS 静态边界检查代替运行验收,也不顺带删除 Windows 独有历史功能。 +- 清理被替代命令、fixture、旧测试、资源打包入口及过时文档,每项有现存调用证据、新能力及删除条件。 +- 单独复核数据库过渡入口:连接、历史与凭据先完成必要迁移;失败保留源数据。公共图标契约等仍被使用的通用资源不随过渡代码删除。 +- 更新决策笔记、共享契约、功能矩阵与资源复用清单;新下载/打包资源有版本/平台/架构身份,运行状态与可变索引不跨工作树共享。 +- 用正式包验证安装/升级/停用/关闭、并行工作区、崩溃/取消、无进程残留和 bundle/插件文件清单不变,核对代码签名与增量更新基线。 + +**退出条件:** #1495 的 macOS 能力和数据清单逐项验收;所有改变过的当前 Windows 消费者有独立验证;旧业务、旧存储和 fallback 已删除;用户数据迁移可追溯。独立分发、基础 API 或某个插件 PR 合并都不能单独作为关闭 #1495 的依据。 + +## 执行纪律 + +默认顺序为任务一→任务二→任务三→任务四。每项按完整用户流程组织里程碑与 PR,报告新增行为、删除范围、实测证据和未验证平台;PR 默认 Draft,满足本地与依赖门禁后再 ready。不要提前发布版本、放松校验或把待实施计划写成已完成。 diff --git a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift index 6681197cf8..6fbcd43a75 100644 --- a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift @@ -75,6 +75,15 @@ struct RustBuildSystemOperations: BuildSystemPlanning { return outcome.value.options } + func resolveModelInputs(_ target: BuildSystemTarget, revision: UInt64, sources: [BuildModelInputSource]) throws -> ToolingJSONValue { + struct Source: Encodable { let manifest: ToolingJSONValue; let instances: [BuildModelInputSource.Instance] } + struct Request: Encodable { let revision: UInt64; let sources: [Source] } + struct Reply: Decodable { let modelInputs: ToolingJSONValue } + let request = Request(revision: revision, sources: try sources.map { Source(manifest: try Self.json($0.manifest), instances: $0.instances) }) + let outcome: Outcome = try execute("build.resolveModelInputs", target: target, request: request) + return outcome.value.modelInputs + } + func modelPlan( _ target: BuildSystemTarget, context: BuildPlanContext, @@ -139,6 +148,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let root: String let pluginDirectory: String? let workspaceStateDirectory: String? + let modelInputs: ToolingJSONValue? let options: [String: ToolingJSONValue] let run: String let project: BuildSelection @@ -147,7 +157,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { } let outcome: Outcome = try execute(command, target: target, request: Request(root: context.root, pluginDirectory: context.pluginDirectory?.path, - workspaceStateDirectory: context.workspaceStateDirectory?.path, options: context.options, run: run, + workspaceStateDirectory: context.workspaceStateDirectory?.path, modelInputs: context.modelInputs, options: context.options, run: run, project: project, target: selectedTarget, userArguments: userArguments)) guard case .provider(let request) = try outcome.value.plan.outputPlan() else { throw DeclarativePluginRejection(code: "plugin.build.invalidOutput", message: L10n.string("The build provider returned an invalid launch plan.")) @@ -182,12 +192,13 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let result: ToolingJSONValue let pluginDirectory: String? let workspaceStateDirectory: String? + let modelInputs: ToolingJSONValue? } struct Reply: Decodable { let discovery: BuildTestDiscovery } let outcome: Outcome = try execute( "build.decodeTestDiscovery", target: target, request: Request(root: context.root, options: context.options, result: result, - pluginDirectory: context.pluginDirectory?.path, workspaceStateDirectory: context.workspaceStateDirectory?.path) + pluginDirectory: context.pluginDirectory?.path, workspaceStateDirectory: context.workspaceStateDirectory?.path, modelInputs: context.modelInputs) ) return outcome.value.discovery } @@ -393,6 +404,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let options: [String: ToolingJSONValue] let pluginDirectory: String? let workspaceStateDirectory: String? + let modelInputs: ToolingJSONValue? let project: BuildSelection? let outputFile: String? let refresh: Bool @@ -403,6 +415,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { } init(context: BuildPlanContext, project: BuildSelection?, outputFile: URL?, hints: BuildProviderHints) { + modelInputs = context.modelInputs root = context.root options = context.options pluginDirectory = context.pluginDirectory?.path @@ -419,6 +432,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let options: [String: ToolingJSONValue] let pluginDirectory: String? let workspaceStateDirectory: String? + let modelInputs: ToolingJSONValue? let task: String let project: BuildSelection? let target: BuildSelection? @@ -426,6 +440,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let userArguments: [String] init(context: BuildPlanContext, request: BuildTaskRequest) { + modelInputs = context.modelInputs root = context.root options = context.options pluginDirectory = context.pluginDirectory?.path @@ -443,6 +458,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let options: [String: ToolingJSONValue] let pluginDirectory: String? let workspaceStateDirectory: String? + let modelInputs: ToolingJSONValue? let project: BuildSelection? let target: BuildSelection? let suite: String? @@ -451,6 +467,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let projects: [String] init(context: BuildPlanContext, request: BuildTestRequest) { + modelInputs = context.modelInputs root = context.root options = context.options pluginDirectory = context.pluginDirectory?.path diff --git a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift index ba9bab2c3e..75f8c48c57 100644 --- a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift @@ -12,7 +12,7 @@ public enum BuildScope: String, Codable, Hashable, Sendable { /// Tool-window grouping of a task. Unknown groups from a newer Core read as /// `other`. -public enum BuildTaskGroup: String, Hashable, Sendable, CaseIterable, Decodable { +public enum BuildTaskGroup: String, Hashable, Sendable, CaseIterable, Codable { case lifecycle, build, test, run, verification, documentation, other public init(from decoder: Decoder) throws { @@ -111,6 +111,14 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { self.id = id; self.displayName = displayName; self.targetTags = targetTags; self.debugAdapter = debugAdapter } } + /// Optional public producer identities; never installation dependencies or private function access. + public struct ModelInput: Equatable, Sendable, Codable { + public let pluginID: String + public let buildSystemID: String + public init(pluginID: String, buildSystemID: String) { self.pluginID = pluginID; self.buildSystemID = buildSystemID } + } + public let sharesModel: Bool + public let modelInputs: [ModelInput] public let providerRuns: [ProviderRun] public let testDiscovery: Bool /// Empty when the build system lists no dependencies. @@ -122,13 +130,14 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { public init( id: String, displayName: String, icon: PluginUIIcon? = nil, modelSource: ModelSource?, options: [Option] = [], tasks: [Task], testScopes: [BuildScope], testTargetTags: [String] = [], - dependencyScopes: [BuildScope], declaresProvider: Bool = false, testDiscovery: Bool = false, providerRuns: [ProviderRun] = [] + dependencyScopes: [BuildScope], declaresProvider: Bool = false, testDiscovery: Bool = false, providerRuns: [ProviderRun] = [], sharesModel: Bool = false, modelInputs: [ModelInput] = [] ) { self.id = id; self.displayName = displayName; self.icon = icon; self.modelSource = modelSource self.options = options; self.tasks = tasks self.testScopes = testScopes; self.testTargetTags = testTargetTags; self.dependencyScopes = dependencyScopes self.declaresProvider = declaresProvider self.testDiscovery = testDiscovery + self.sharesModel = sharesModel; self.modelInputs = modelInputs self.providerRuns = providerRuns } @@ -294,8 +303,8 @@ package struct BuildDetection: Equatable, Sendable, Decodable { } /// The normalized project model of one instance. Paths are workspace-relative. -package struct BuildModel: Equatable, Sendable, Decodable { - package struct Project: Equatable, Identifiable, Sendable, Decodable { +package struct BuildModel: Equatable, Sendable, Codable { + package struct Project: Equatable, Identifiable, Sendable, Codable { package let id: String package let path: String package let name: String @@ -324,7 +333,7 @@ package struct BuildModel: Equatable, Sendable, Decodable { private enum CodingKeys: String, CodingKey { case id, path, name, displayName, parentID, properties } } - package struct Target: Equatable, Identifiable, Sendable, Decodable { + package struct Target: Equatable, Identifiable, Sendable, Codable { package let id: String package let projectID: String package let name: String @@ -345,7 +354,7 @@ package struct BuildModel: Equatable, Sendable, Decodable { private enum CodingKeys: String, CodingKey { case id, projectID, name, path, tags, properties } } - package struct Task: Equatable, Identifiable, Sendable, Decodable { + package struct Task: Equatable, Identifiable, Sendable, Codable { package let id: String package let projectID: String package let name: String @@ -393,7 +402,7 @@ package struct BuildModel: Equatable, Sendable, Decodable { } } - package struct ContentRoot: Equatable, Sendable, Decodable { + package struct ContentRoot: Equatable, Sendable, Codable { package let projectID: String package let path: String /// `source`, `testSource`, `generatedSource`, `excluded`, …. @@ -608,8 +617,27 @@ package protocol BuildOptionsStoring: AnyObject { // MARK: Plans +/// Current workspace facts from installed enabled producers. Core alone selects the public projection. +package struct BuildModelInputSource: Sendable { + package let manifest: Data + package let instances: [Instance] + package struct Instance: Encodable, Sendable { + package let buildSystemID: String + package let root: String + package let state: String + package let model: BuildModel? + package let options: [String: ToolingJSONValue] + package init(buildSystemID: String, root: String, state: String, model: BuildModel?, options: [String: ToolingJSONValue]) { + self.buildSystemID = buildSystemID; self.root = root; self.state = state; self.model = model; self.options = options + } + } + package init(manifest: Data, instances: [Instance]) { self.manifest = manifest; self.instances = instances } +} + /// Inputs every plan request shares. package struct BuildPlanContext: Equatable, Sendable { + /// Public snapshot selected by Core, absent for declarations without inputs. + package let modelInputs: ToolingJSONValue? /// Workspace-relative instance root. package let root: String package let options: [String: ToolingJSONValue] @@ -618,8 +646,9 @@ package struct BuildPlanContext: Equatable, Sendable { package init( root: String, options: [String: ToolingJSONValue], - pluginDirectory: URL? = nil, workspaceStateDirectory: URL? = nil + pluginDirectory: URL? = nil, workspaceStateDirectory: URL? = nil, modelInputs: ToolingJSONValue? = nil ) { + self.modelInputs = modelInputs self.root = root; self.options = options self.pluginDirectory = pluginDirectory; self.workspaceStateDirectory = workspaceStateDirectory } @@ -1016,6 +1045,7 @@ package enum BuildLanguageImport: Equatable, Sendable, Decodable { /// `DeclarativePluginRejection`. Detection, decoding, and fingerprints read /// workspace files, so callers keep them off the main actor. package protocol BuildSystemPlanning: Sendable { + func resolveModelInputs(_ target: BuildSystemTarget, revision: UInt64, sources: [BuildModelInputSource]) throws -> ToolingJSONValue func detect( workspaceURL: URL, sources: [BuildSystemPluginSource], diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index bb0400f318..8388d2c259 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -1250,6 +1250,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let run: [ProviderRun] let dependencies: Source? let declaresProvider: Bool + let sharesModel: Bool + let modelInputs: [DeclarativeBuildSystem.ModelInput] init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) @@ -1263,10 +1265,12 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { run = try values.decodeIfPresent([ProviderRun].self, forKey: .run) ?? [] dependencies = try values.decodeIfPresent(Source.self, forKey: .dependencies) declaresProvider = values.contains(.provider) + sharesModel = values.contains(.modelSharing) + modelInputs = try values.decodeIfPresent([DeclarativeBuildSystem.ModelInput].self, forKey: .modelInputs) ?? [] } private enum CodingKeys: String, CodingKey { - case id, displayName, icon, options, model, tasks, test, dependencies, provider, run + case id, displayName, icon, options, model, tasks, test, dependencies, provider, run, modelSharing, modelInputs } func declaration() -> DeclarativeBuildSystem { @@ -1299,7 +1303,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { testDiscovery: test?.discovery ?? false, providerRuns: run.filter { $0.source == "provider" }.map { .init(id: $0.id, displayName: $0.name ?? $0.id, targetTags: $0.fromTargets?.tags ?? [], debugAdapter: $0.debug?.adapter) - } + }, sharesModel: sharesModel, modelInputs: modelInputs ) } } diff --git a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift index ff2b9d56d6..6887501d33 100644 --- a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift +++ b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift @@ -154,6 +154,7 @@ package final class BuildSystemService: ObservableObject { /// before the change can tell its result is based on older files. private var fileRevision = 0 private var providerConfigurationRevision = 0 + private var modelInputRevision: UInt64 = 0 private var providerRunID: UUID? private var preparingApplicationID: UUID? private var detectionTask: Task? @@ -242,6 +243,7 @@ package final class BuildSystemService: ObservableObject { package func reset(workspaceURL: URL?) { stop() generation += 1 + modelInputRevision += 1 detectionTask?.cancel(); detectionTask = nil redetectTask?.cancel(); redetectTask = nil self.workspaceURL = workspaceURL?.standardizedFileURL @@ -313,6 +315,7 @@ package final class BuildSystemService: ObservableObject { switch result { case .success(let detected): let current = Dictionary(uniqueKeysWithValues: self.entries.map { ($0.id, $0) }) + let publishedBefore = self.entries.filter { $0.declaration.sharesModel } self.entries = detected.entries.map { detectedEntry in guard let currentEntry = current[detectedEntry.id], currentEntry.instance.pluginID == detectedEntry.instance.pluginID @@ -336,8 +339,10 @@ package final class BuildSystemService: ObservableObject { // detection just read. merged.dependencies = currentEntry.dependencies } + if !merged.declaration.modelInputs.isEmpty, currentEntry.isReloadRequired { merged.isReloadRequired = true } return merged } + if publishedBefore != self.entries.filter({ $0.declaration.sharesModel }) { self.invalidateModelInputs() } self.issues = detected.issues self.persistDroppedOptions() let inputs = self.entries.map { entry in @@ -394,6 +399,7 @@ package final class BuildSystemService: ObservableObject { /// Ends what a now disabled plugin started and detects again with the /// plugins enabled now, so its instances leave the tool window. package func pluginsChanged() { + invalidateModelInputs() if preparingApplicationID != nil { stopRun() } if let runEntryID, testDiscoveries[runEntryID] == .loading { stopRun() } testDiscoveries = [:] @@ -439,6 +445,10 @@ package final class BuildSystemService: ObservableObject { entry.sync?.watch.contains(where: changed.contains) == true } guard affected else { return } + for entry in entries where entry.declaration.sharesModel && entry.sync?.watch.contains(where: changed.contains) == true { + update(entry.id) { $0.isReloadRequired = true } + } + invalidateModelInputs() providerConfigurationRevision += 1 if providerRunID != nil { stopRun() } // Reject the old input immediately; consumers wait for the coalesced @@ -575,7 +585,12 @@ package final class BuildSystemService: ObservableObject { break } modelTasks[entryID]?.cancel() - update(entryID) { $0.modelState = .loading } + update(entryID) { + $0.modelState = .loading + if entry.declaration.sharesModel { $0.isReloadRequired = $0.model != nil } + } + noteModelChanged(entryID) + let publicationRevision = entry.declaration.sharesModel ? modelInputRevision : nil let generation = generation let id = UUID() // The model command depends on the options; a change while it runs @@ -596,6 +611,8 @@ package final class BuildSystemService: ObservableObject { switch result { case .success(let loaded): let configChanged: Bool = { + // An exporter cannot make an ABA-configured in-flight model public. + if let publicationRevision, self.modelInputRevision != publicationRevision { return true } guard self.fileRevision == startFileRevision else { return true } guard let current = self.entry(entryID), let currentFingerprint = self.optionFingerprint(current, workspaceURL: workspaceURL) @@ -660,7 +677,7 @@ package final class BuildSystemService: ObservableObject { ) let target = entry.target let root = entry.instance.root - return .success(try await Task.detached(priority: .userInitiated) { + let loaded = try await Task.detached(priority: .userInitiated) { let model = try planner.decodeModel( target, workspaceURL: workspaceURL, root: root, output: output, options: context.options ) @@ -674,7 +691,9 @@ package final class BuildSystemService: ObservableObject { projects: model.projects.map(\.path), options: context.options ) ) - }.value) + }.value + guard modelInputsAreCurrent(context.modelInputs) else { throw CancellationError() } + return .success(loaded) } catch { return .failure(error) } @@ -1151,11 +1170,13 @@ package final class BuildSystemService: ObservableObject { ) let target = entry.target let root = entry.instance.root - return .success(try await Task.detached(priority: .userInitiated) { + let loaded = try await Task.detached(priority: .userInitiated) { try planner.decodeDependencies( target, workspaceURL: workspaceURL, root: root, project: project?.path, output: output ) - }.value) + }.value + guard modelInputsAreCurrent(context.modelInputs) else { throw CancellationError() } + return .success(loaded) } catch { return .failure(error) } @@ -1229,6 +1250,10 @@ package final class BuildSystemService: ObservableObject { optionsStore.setEmptyMultiSelectOptions(empty, workspaceURL: workspaceURL, buildSystemID: buildSystemID) optionsStore.setOptions(stored, workspaceURL: workspaceURL, buildSystemID: buildSystemID) refreshOptions(buildSystemID: buildSystemID) + if entry.declaration.sharesModel { + for producer in entries where producer.instance.buildSystemID == buildSystemID { update(producer.id) { $0.isReloadRequired = true } } + invalidateModelInputs() + } // The options take part in the sync fingerprint, so a changed value // marks a loaded command model stale. detect() @@ -1267,6 +1292,10 @@ package final class BuildSystemService: ObservableObject { optionsStore.setEmptyMultiSelectOptions(empty, workspaceURL: workspaceURL, buildSystemID: buildSystemID) optionsStore.setOptions(stored, workspaceURL: workspaceURL, buildSystemID: buildSystemID) refreshOptions(buildSystemID: buildSystemID) + if entry.declaration.sharesModel { + for producer in entries where producer.instance.buildSystemID == buildSystemID { update(producer.id) { $0.isReloadRequired = true } } + invalidateModelInputs() + } detect() } @@ -1410,10 +1439,64 @@ package final class BuildSystemService: ObservableObject { pluginID: entry.instance.pluginID, buildSystemID: entry.instance.buildSystemID, workspaceURL: workspaceURL - ) + ), + modelInputs: try resolvedModelInputs(entry, workspaceURL: workspaceURL) ) } + private func resolvedModelInputs(_ consumer: BuildSystemEntry, workspaceURL: URL) throws -> ToolingJSONValue? { + guard !consumer.declaration.modelInputs.isEmpty else { return nil } + let enabled = sources() + let required = Set(consumer.declaration.modelInputs.map(\.pluginID)) + let facts = enabled.filter { required.contains($0.pluginID) }.map { source in + BuildModelInputSource(manifest: source.manifest, instances: entries.filter { $0.source == source && $0.declaration.sharesModel }.map { producer in + let state: String + switch producer.modelState { + case .loading: state = "loading" + case .failed: state = "failed" + case .loaded where !producer.isReloadRequired && producer.model != nil: state = "ready" + default: state = "stale" + } + return BuildModelInputSource.Instance(buildSystemID: producer.instance.buildSystemID, root: producer.instance.root, + state: state, model: state == "ready" ? producer.model : nil, options: storedOptions(for: producer, workspaceURL: workspaceURL)) + }) + } + return try planner.resolveModelInputs(consumer.target, revision: modelInputRevision, sources: facts) + } + + private func modelInputsAreCurrent(_ snapshot: ToolingJSONValue?) -> Bool { + guard let snapshot else { return true } + guard case .object(let value) = snapshot, case .integer(let revision) = value["revision"] else { return false } + return revision >= 0 && UInt64(revision) == modelInputRevision + } + + private func modelInputsAreCurrent(_ request: BuildProviderRequest) -> Bool { + guard case .object(let params) = request.params else { return false } + return modelInputsAreCurrent(params["modelInputs"]) + } + + /// Producers are independent in v1: consumers cannot re-export their inputs. + /// Revoke consumers immediately; cancellation-ignoring replies still fail revision checks. + private func invalidateModelInputs() { + modelInputRevision += 1 + let consumers = Set(entries.filter { !$0.declaration.modelInputs.isEmpty }.map(\.id)) + if let runEntryID, consumers.contains(runEntryID) { stopRun() } + for id in consumers { + modelTasks.removeValue(forKey: id)?.cancel() + for key in Array(dependencyTasks.keys) where Self.entryID(ofDependencyTask: key) == id { + dependencyTasks.removeValue(forKey: key)?.cancel() + } + update(id) { + $0.isReloadRequired = $0.model != nil + if $0.modelState == .loading { $0.modelState = $0.model == nil ? .notLoaded : .loaded } + $0.dependencies = [:] + } + modelRevisions[id, default: 0] += 1 + testDiscoveries[id] = nil + discoverySnapshots[id] = nil + } + } + /// Stored values reduced to the ones Core accepted in `resolution`. Core /// applies declaration defaults itself, so plans and fingerprints receive /// only effective user choices and never a value the current model removed. @@ -1499,7 +1582,7 @@ package final class BuildSystemService: ObservableObject { return { [weak self] in guard let self, self.isCurrent(run) else { return false } guard case .provider(let request) = plan else { return true } - guard self.providerConfigurationRevision == revision, + guard self.modelInputsAreCurrent(request), self.providerConfigurationRevision == revision, self.settings(entry.source.pluginID) == launchSettings, let current = self.entry(entry.id), current.source == entry.source else { return false } return self.optionFingerprint(current, workspaceURL: workspaceURL) == request.optionFingerprint @@ -1638,7 +1721,10 @@ package final class BuildSystemService: ObservableObject { appendOutput("\n▶ " + title + "\n→ " + request.method + "\n", in: outputDirectory(entry)) } let target = Self.providerTarget(entry, workspaceURL: workspaceURL) - return .providerResult(try await providers.send(request, to: target, timeout: timeout)) + guard modelInputsAreCurrent(request), canAppend(owner) else { throw CancellationError() } + let result = try await providers.send(request, to: target, timeout: timeout) + guard modelInputsAreCurrent(request), canAppend(owner), !Task.isCancelled else { throw CancellationError() } + return .providerResult(result) } } @@ -1746,6 +1832,7 @@ package final class BuildSystemService: ObservableObject { private func noteModelChanged(_ entryID: String) { modelRevisions[entryID, default: 0] += 1 + if entry(entryID)?.declaration.sharesModel == true { invalidateModelInputs() } } private func noteDependenciesChanged(_ entryID: String) { diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index b843e9a225..8323f4eca4 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -504,7 +504,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { /// not running it, so an entry is added only with its host support. public static let hostCapabilities: [String: [Int]] = [ "backend": [1], - "buildProviders": [1, 2, 3, 4, 5], + "buildProviders": [1, 2, 3, 4, 5, 6], "buildModelInputs": [1], "buildSystems": [1, 2], "debugAdapters": [1, 2], "executableResolution": [1], "executionProviders": [1], "languageServers": [1, 2], diff --git a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift index 40afc0341d..ab3cf7253e 100644 --- a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift @@ -2126,6 +2126,133 @@ struct BuildSystemServiceTests { #expect(planner.decodedModelOutputs.isEmpty) } + + @Test(arguments: ["disable", "optionABA", "producerReload", "files", "workspace"]) + func sharedModelChangesCancelAnUncooperativeConsumer(change: String) async throws { + // Producer facts are captured before the backend starts; changing the producer + // revokes them while the reply is deliberately held, even across an option ABA. + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + let producer = try decode(BuildSystemInstance.self, """ + {"id":"source:app","pluginID":"dev.producer","buildSystemID":"source","displayName":"Source", + "root":"app","modelSource":"provider","optionFingerprint":"o", + "model":{"projects":[{"id":"p","path":"app","name":"source"}]}} + """) + planner.detection = BuildDetection(instances: [producer, try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let sources = [ + fixtureSource(pluginID: "dev.producer", buildSystemID: "source", sharesModel: true), + fixtureSource(modelInputs: [.init(pluginID: "dev.producer", buildSystemID: "source")]) + ] + let gate = BuildTestGate() + let providers = FakeBuildProviders() + providers.replyGate = gate + providers.ignoresCancellation = true + let harness = Harness(planner: planner, sources: sources, providers: providers) + defer { gate.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { $0.count == 2 }) + let operation = try #require(harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build")) + try #require(await providers.requestStarted.waitUntilOpen()) + let facts = try #require(planner.modelInputSources.last) + #expect(facts.count == 1) + #expect(facts.first?.instances.first?.buildSystemID == "source") + if change == "disable" { + harness.sources = [sources[1]] + harness.service.pluginsChanged() + } else if change == "optionABA" { + harness.service.setOption("source:app", optionID: "release", value: .bool(true)) + harness.service.setOption("source:app", optionID: "release", value: nil) + } else if change == "producerReload" { + harness.service.loadModel("source:app") + } else if change == "files" { + harness.service.workspaceFilesChanged([Self.workspace.appendingPathComponent("build.file")]) + } else { + harness.service.reset(workspaceURL: URL(fileURLWithPath: "/workspace/other")) + } + #expect(harness.service.currentRunID != operation) + gate.open() + try #require(await providers.requestFinished.waitUntilOpen()) + #expect(harness.service.runState != .succeeded) + } + + @Test + func aProducerModelLoadedAcrossOptionABARemainsStale() async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/model") + let gate = BuildTestGate() + let providers = FakeBuildProviders() + providers.replyGate = gate + let harness = Harness(planner: planner, sources: [fixtureSource(sharesModel: true)], providers: providers) + defer { gate.open(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { $0.count == 1 }) + harness.service.loadModel(Self.instanceID) + try #require(await providers.requestStarted.waitUntilOpen()) + harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + harness.service.setOption(Self.instanceID, optionID: "release", value: nil) + gate.open() + try #require(await harness.waitForEntries { $0.first?.modelState == .loaded }) + #expect(harness.service.entry(Self.instanceID)?.isReloadRequired == true) + } + + @Test(arguments: [false, true]) + func sharedInputsStayRevokedThroughBackgroundDecode(dependencies: Bool) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: dependencies ? "lithe/build/dependencies" : "lithe/build/model") + let consumer = fixtureSource(modelInputs: [.init(pluginID: "dev.producer", buildSystemID: "source")]) + let gate = GatedBuildDecode() + planner.backgroundDecodeGate = gate + let harness = Harness(planner: planner, sources: [consumer], providers: FakeBuildProviders()) + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { $0.count == 1 }) + if dependencies { harness.service.loadDependencies(Self.instanceID, project: nil) } + else { harness.service.loadModel(Self.instanceID) } + try #require(await gate.entered.waitUntilOpen()) + // Availability changes revoke even the explicit unavailable slot snapshot. + harness.service.pluginsChanged() + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + #expect(!(await harness.waitsForStaleSharedInputResult())) + #expect(harness.service.entry(Self.instanceID)?.model == nil) + #expect(harness.service.entry(Self.instanceID)?.dependencies.isEmpty == true) + } + + @Test + func sharedModelHostIncludesOnlyEnabledExactPackageFacts() async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + let producer = try decode(BuildSystemInstance.self, """ + {"id":"source:app","pluginID":"dev.producer","buildSystemID":"source","displayName":"Source", + "root":"app","modelSource":"provider","optionFingerprint":"o"} + """) + planner.detection = BuildDetection(instances: [producer, try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let consumer = fixtureSource(modelInputs: [.init(pluginID: "dev.producer", buildSystemID: "source")]) + let producerSource = fixtureSource(pluginID: "dev.producer", buildSystemID: "source", sharesModel: true) + let providers = FakeBuildProviders() + let harness = Harness(planner: planner, sources: [producerSource, consumer], providers: providers) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { $0.count == 2 }) + harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") + try #require(await harness.waitForRunState { $0 == .succeeded }) + let instance = try #require(planner.modelInputSources.last?.first?.instances.first) + #expect(instance.state == "stale") + #expect(instance.model == nil) + harness.sources = [consumer] + harness.service.pluginsChanged() + try #require(await harness.waitForEntries { $0.count == 1 }) + harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(planner.modelInputSources.last?.isEmpty == true) + } + private static func providerRequest(method: String) -> BuildProviderRequest { BuildProviderRequest( method: method, params: .object(["root": .string("app")]), optionFingerprint: "o", @@ -2205,14 +2332,14 @@ struct BuildSystemServiceTests { } } -private func fixtureSource(pluginID: String = "dev.example", testDiscovery: Bool = false, providerRuns: [DeclarativeBuildSystem.ProviderRun] = []) -> BuildSystemPluginSource { +private func fixtureSource(pluginID: String = "dev.example", testDiscovery: Bool = false, providerRuns: [DeclarativeBuildSystem.ProviderRun] = [], buildSystemID: String = "fixture", sharesModel: Bool = false, modelInputs: [DeclarativeBuildSystem.ModelInput] = []) -> BuildSystemPluginSource { BuildSystemPluginSource( pluginID: pluginID, manifest: Data("{}".utf8), packageURL: URL(fileURLWithPath: "/plugins/\(pluginID)"), buildSystems: [DeclarativeBuildSystem( - id: "fixture", displayName: "Fixture", modelSource: .command, tasks: [], - testScopes: [], dependencyScopes: [], testDiscovery: testDiscovery, providerRuns: providerRuns + id: buildSystemID, displayName: "Fixture", modelSource: .command, tasks: [], + testScopes: [], dependencyScopes: [], testDiscovery: testDiscovery, providerRuns: providerRuns, sharesModel: sharesModel, modelInputs: modelInputs )] ) } @@ -2275,6 +2402,15 @@ private final class Harness { /// Bounded wait for a publication a correct service never makes. Returns /// true when the stale value appears, so a regression fails locally /// instead of hanging. + func waitsForStaleSharedInputResult() async -> Bool { + let gate = BuildTestGate() + let observation = service.$entries.sink { entries in + if entries.contains(where: { $0.model != nil || $0.dependencies.values.contains(where: { if case .loaded = $0 { return true }; return false }) }) { gate.open() } + } + defer { observation.cancel() } + return await gate.waitUntilOpen(timeout: .milliseconds(500)) + } + func waitsForStaleDiagnostics(_ diagnostics: [BuildDiagnostic]) async -> Bool { let gate = BuildTestGate() let observation = service.$diagnostics.sink { if $0 == diagnostics { gate.open() } } @@ -2412,6 +2548,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { var providerRequest: BuildProviderRequest? var decodedDependencyOutputs: [BuildCommandOutput] = [] var diagnostics: [BuildDiagnostic] = [] + var backgroundDecodeGate: GatedBuildDecode? var diagnosticsGate: GatedBuildDecode? var testResultsGate: GatedBuildDecode? var detectGate: GatedBuildDecode? @@ -2431,6 +2568,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { var decodedModelOutputs: [BuildCommandOutput] = [] var diagnosticOutputs: [String] = [] var taskPlanContexts: [BuildPlanContext] = [] + var modelInputSources: [[BuildModelInputSource]] = [] var modelPlanHints: [BuildProviderHints] = [] var dependencyPlanHints: [BuildProviderHints] = [] } @@ -2615,6 +2753,33 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { """) } + var backgroundDecodeGate: GatedBuildDecode? { + get { access { $0.backgroundDecodeGate } } + set { access { $0.backgroundDecodeGate = newValue } } + } + + private func waitForBackgroundDecode() throws { + if let gate = access({ let gate = $0.backgroundDecodeGate; $0.backgroundDecodeGate = nil; return gate }) { + gate.entered.open() + defer { gate.finished.open() } + guard gate.waitForRelease() else { throw GatedDecodeError.releaseTimedOut } + } + } + + var modelInputSources: [[BuildModelInputSource]] { access { $0.modelInputSources } } + + func resolveModelInputs(_ target: BuildSystemTarget, revision: UInt64, sources: [BuildModelInputSource]) throws -> ToolingJSONValue { + access { $0.modelInputSources.append(sources) } + return .object(["version": .integer(1), "revision": .integer(Int(revision)), "inputs": .array([])]) + } + + private func withInputs(_ request: BuildProviderRequest, context: BuildPlanContext) -> BuildProviderRequest { + guard let inputs = context.modelInputs, case .object(var params) = request.params else { return request } + params["modelInputs"] = inputs + return BuildProviderRequest(method: request.method, params: .object(params), optionFingerprint: request.optionFingerprint, + executesProjectCode: request.executesProjectCode, mayUseNetwork: request.mayUseNetwork) + } + func modelPlan( _ target: BuildSystemTarget, context: BuildPlanContext, @@ -2622,14 +2787,14 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { providerHints: BuildProviderHints ) throws -> BuildOutputPlan { access { $0.modelPlanHints.append(providerHints) } - if let request = providerRequest { return .provider(request) } + if let request = providerRequest { return .provider(withInputs(request, context: context)) } return .process(plan(["model"], output: modelOutput)) } func taskPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTaskRequest) throws -> BuildOutputPlan { access { $0.taskPlanContexts.append(context) } - if let request = providerRequest { return .provider(request) } + if let request = providerRequest { return .provider(withInputs(request, context: context)) } return .process(plan([request.task, "with space"], output: nil)) } @@ -2638,7 +2803,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { access { $0.testRequests.append(request) } let declaresResults = access { $0.testPlanDeclaresResults } return BuildTestPlan( - execution: providerRequest.map(BuildOutputPlan.provider) ?? .process(plan(["test"], output: nil)), + execution: providerRequest.map { .provider(withInputs($0, context: context)) } ?? .process(plan(["test"], output: nil)), results: declaresResults ? .reports(decoder: "junitXml", patterns: []) : nil ) } @@ -2646,13 +2811,13 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { func prepareRunPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { guard let providerRequest else { throw CancellationError() } - return providerRequest + return withInputs(providerRequest, context: context) } func prepareDebugPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { guard let providerRequest else { throw CancellationError() } - return providerRequest + return withInputs(providerRequest, context: context) } func decodeDebugPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, run: String, @@ -2698,7 +2863,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { providerHints: BuildProviderHints ) throws -> BuildOutputPlan { access { $0.dependencyPlanHints.append(providerHints) } - if let request = providerRequest { return .provider(request) } + if let request = providerRequest { return .provider(withInputs(request, context: context)) } return .process(plan(["dependencies"], output: .stdout)) } @@ -2709,6 +2874,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { output: BuildCommandOutput, options: [String: ToolingJSONValue] ) throws -> BuildModel { + try waitForBackgroundDecode() access { $0.decodedModelOutputs.append(output) } let name: String if case .stdout(let text) = output, text == "OLD" || text == "NEW" { @@ -2755,6 +2921,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { project: String?, output: BuildCommandOutput ) throws -> BuildDependencyTree { + try waitForBackgroundDecode() access { $0.decodedDependencyOutputs.append(output) } if case .stdout(let name) = output, name == "OLD" || name == "NEW" { return try decode(BuildDependencyTree.self, """ @@ -3017,11 +3184,12 @@ private final class FakeBuildProviders: BuildProviderRequesting { running.insert(target) requestStarted.open() defer { requestFinished.open() } - if let replyGate, !(await replyGate.waitUntilOpen()) { throw CancellationError() } + if let replyGate, !(await replyGate.waitUntilOpen(ignoringCancellation: ignoresCancellation)) { throw CancellationError() } if let failure { throw failure } return reply } + var ignoresCancellation = false var executionResult = BuildProviderExecutionResult(exitCode: 0, output: "completed") var executionProgress: (@MainActor @Sendable (BuildProviderProgress) -> Void)? diff --git a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift index 088b49c14f..97137c4024 100644 --- a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift +++ b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift @@ -382,6 +382,44 @@ struct RustBuildSystemOperationsTests { ) == nil) } + @Test + func modelInputsCrossTheRealCoreBridgeWithoutPrivatePlans() throws { + let producer = try Self.source("build-model-producer") + let consumer = try Self.source("build-model-consumer") + #expect(producer.buildSystems.first?.sharesModel == true) + #expect(consumer.buildSystems.first?.modelInputs == [.init(pluginID: producer.pluginID, buildSystemID: "source")]) + let model = try JSONDecoder().decode(BuildModel.self, from: Data(""" + {"projects":[{"id":"source:app/demo","path":"app","name":"demo","properties":{"group":"org.example"}}], + "targets":[], "contentRoots":[], "tasks":[], "run":[{"command":"private"}], "optionValues":{}} + """.utf8)) + let target = BuildSystemTarget(manifest: consumer.manifest, buildSystemID: "gradle") + let snapshot = try operations.resolveModelInputs(target, revision: 9, sources: [ + BuildModelInputSource(manifest: producer.manifest, instances: [ + .init(buildSystemID: "source", root: "app", state: "ready", model: model, options: ["offline": .bool(true)]) + ]) + ]) + let context = BuildPlanContext(root: "app", options: [:], modelInputs: snapshot) + guard case .provider(let request) = try operations.taskPlan(target, context: context, request: BuildTaskRequest(task: "build")), + case .object(let params) = request.params, case .object(let wire)? = params["modelInputs"], + case .array(let slots)? = wire["inputs"], case .object(let slot)? = slots.first, + case .array(let instances)? = slot["instances"], case .object(let instance)? = instances.first, + case .object(let shared)? = instance["model"] else { + Issue.record("Expected the shared snapshot in the typed provider request"); return + } + #expect(params["modelInputs"] == snapshot) + #expect(instance["options"] == .object(["offline": .bool(true)])) + #expect(Set(shared.keys) == ["projects", "targets", "contentRoots"]) + #expect(shared["run"] == nil) + #expect(throws: (any Error).self) { + try operations.taskPlan(target, context: BuildPlanContext(root: "app", options: [:]), request: BuildTaskRequest(task: "build")) + } + let unavailable = try operations.resolveModelInputs(target, revision: 10, sources: []) + guard case .object(let value) = unavailable, case .array(let slots)? = value["inputs"], + case .object(let missing)? = slots.first else { Issue.record("Missing slot"); return } + #expect(missing["state"] == .string("unavailable")) + #expect(missing["instances"] == .array([])) + } + // MARK: Fixtures private static func fixtureData(_ name: String) throws -> Data { diff --git a/rust/lithe-core/src/build/commands.rs b/rust/lithe-core/src/build/commands.rs index 2d2ebfeeba..7d25125415 100644 --- a/rust/lithe-core/src/build/commands.rs +++ b/rust/lithe-core/src/build/commands.rs @@ -323,3 +323,15 @@ pub(crate) fn build_decode_debug_preparation_command(payload: Value) -> Result Result { + with_build_system( + payload, + "build.resolveModelInputs", + "modelInputs", + |build, request: &super::model_inputs::ResolveRequest| { + super::model_inputs::resolve(build, request) + }, + ) +} diff --git a/rust/lithe-core/src/build/manifest.rs b/rust/lithe-core/src/build/manifest.rs index c00db39231..ae32e7ba47 100644 --- a/rust/lithe-core/src/build/manifest.rs +++ b/rust/lithe-core/src/build/manifest.rs @@ -49,6 +49,12 @@ pub(crate) const MAX_CATEGORY_DEPTH: usize = 4; pub(crate) struct BuildSystemDeclaration { pub(crate) id: String, pub(crate) display_name: String, + /// Optional, explicit read-only publication of the normalized model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) model_sharing: Option, + /// Exact optional producers; these are not installation dependencies. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) model_inputs: Vec, /// Icon of the build system's tool window; display only. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) icon: Option, @@ -943,6 +949,7 @@ pub(crate) struct DeclarationContext<'a> { pub(crate) build_systems_version: u32, /// Admission version for provider execution; independent of wire negotiation. pub(crate) build_providers_version: u32, + pub(crate) model_inputs_version: u32, pub(crate) official: bool, /// Setting IDs a provider's launch templates may name. pub(crate) declared_settings: &'a crate::plugins::settings::DeclaredSettings, @@ -1074,7 +1081,7 @@ fn program(build_system: &BuildSystemDeclaration) -> Value { /// A build-system or run ID: also a run-configuration provider segment, so /// it starts with a letter. -fn is_entry_identifier(value: &str) -> bool { +pub(super) fn is_entry_identifier(value: &str) -> bool { is_identifier(value) && value.len() <= MAX_IDENTIFIER && value @@ -1233,6 +1240,11 @@ impl BuildSystemDeclaration { self.validate_provider(field, context)?; self.validate_detection(field)?; let option_ids = self.validate_options(field)?; + super::model_inputs::validate_declaration( + self, + context.model_inputs_version, + context.build_providers_version, + )?; self.validate_invocation(field, &option_ids, context)?; let model_templates = self.validate_tasks(field, &option_ids)?; self.validate_model(field, &option_ids)?; diff --git a/rust/lithe-core/src/build/mod.rs b/rust/lithe-core/src/build/mod.rs index 2bcc4cbebe..7981621b8e 100644 --- a/rust/lithe-core/src/build/mod.rs +++ b/rust/lithe-core/src/build/mod.rs @@ -24,6 +24,7 @@ mod invocation; mod language_import; mod manifest; mod model; +mod model_inputs; mod options; mod outputs; mod plans; diff --git a/rust/lithe-core/src/build/model.rs b/rust/lithe-core/src/build/model.rs index 8e4fc2074a..2e82281a2c 100644 --- a/rust/lithe-core/src/build/model.rs +++ b/rust/lithe-core/src/build/model.rs @@ -42,7 +42,7 @@ pub(crate) struct BuildModel { pub(crate) truncated: bool, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ModelProject { pub(crate) id: String, @@ -56,7 +56,7 @@ pub(crate) struct ModelProject { pub(crate) properties: BTreeMap, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ModelTarget { pub(crate) id: String, @@ -92,7 +92,7 @@ pub(crate) struct ModelTask { pub(crate) properties: BTreeMap, } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ModelContentRoot { #[serde(rename = "projectID")] diff --git a/rust/lithe-core/src/build/model_inputs.rs b/rust/lithe-core/src/build/model_inputs.rs new file mode 100644 index 0000000000..9df8292d85 --- /dev/null +++ b/rust/lithe-core/src/build/model_inputs.rs @@ -0,0 +1,398 @@ +//! Optional read-only model inputs between already enabled workspace providers. +//! Sharing grants no installation, process, resource or private-function authority. + +use super::{ + decode, + manifest::{is_entry_identifier, BuildSystemDeclaration}, + model::{ModelContentRoot, ModelProject, ModelTarget}, + options, plans, +}; +use crate::plugins::declarative::{is_plugin_id, validate_manifest_value, Validation}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +const MAX_INPUTS: usize = 8; +const MAX_INSTANCES: usize = 64; +const MAX_BYTES: usize = 8 * 1024 * 1024; + +/// Producer opt-in; only these ordinary build options leave their owner. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ModelSharing { + #[serde(default)] + pub(crate) options: Vec, +} + +/// Exact optional producer identity; package versions and permissions are not inherited. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ModelInput { + #[serde(rename = "pluginID")] + pub(crate) plugin_id: String, + #[serde(rename = "buildSystemID")] + pub(crate) build_system_id: String, +} + +/// Enforces explicit publication/consumption and independent wire admission. +pub(crate) fn validate_declaration( + build: &mut BuildSystemDeclaration, + version: u32, + provider_version: u32, +) -> Validation<()> { + let invalid = || { + plans::invalid_selection("Model sharing requires buildModelInputs 1; consumers need a provider and buildProviders 6, exact unique producers, and declared shared option IDs; producers cannot also consume inputs.") + }; + if build.model_sharing.is_none() && build.model_inputs.is_empty() { + return Ok(()); + } + if version != 1 + || (build.model_sharing.is_some() && !build.model_inputs.is_empty()) + || build.model.is_none() + || build.model_inputs.len() > MAX_INPUTS + { + return Err(invalid()); + } + if !build.model_inputs.is_empty() && (build.provider.is_none() || provider_version < 6) { + return Err(invalid()); + } + let mut seen = BTreeSet::new(); + for input in &build.model_inputs { + if !is_plugin_id(&input.plugin_id) + || !is_entry_identifier(&input.build_system_id) + || !seen.insert(input.clone()) + { + return Err(invalid()); + } + } + build.model_inputs.sort(); + if let Some(sharing) = &mut build.model_sharing { + if sharing.options.len() > 32 + || sharing + .options + .iter() + .any(|id| !build.options.iter().any(|option| option.id == *id)) + { + return Err(invalid()); + } + let unique: BTreeSet<_> = sharing.options.iter().collect(); + if unique.len() != sharing.options.len() { + return Err(invalid()); + } + sharing.options.sort(); + } + Ok(()) +} + +/// Installed/enabled source facts supplied by the native host, not by another backend. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Source { + pub(crate) manifest: Value, + pub(crate) instances: Vec, +} + +/// Host model state; old or incomplete models never accompany an unavailable state. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Instance { + #[serde(rename = "buildSystemID")] + pub(crate) build_system_id: String, + pub(crate) root: String, + pub(crate) state: State, + #[serde(default)] + pub(crate) model: Option, + #[serde(default)] + pub(crate) options: BTreeMap, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum State { + Ready, + Loading, + Stale, + Failed, +} + +/// A bounded snapshot revision belongs to this workspace activation only. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ResolveRequest { + pub(crate) revision: u64, + pub(crate) sources: Vec, +} + +fn invalid() -> crate::plugins::declarative::PluginRejection { + plans::invalid_selection("Invalid or oversized shared build model input.") +} + +/// Selects exact public producers and strips tasks, run plans, settings and unshared options. +pub(crate) fn resolve( + consumer: &BuildSystemDeclaration, + request: &ResolveRequest, +) -> Validation { + if consumer.model_inputs.is_empty() + || request.sources.len() > MAX_INPUTS + || request.revision > 9_007_199_254_740_991 + { + return Err(invalid()); + } + let mut sources = BTreeMap::new(); + for source in &request.sources { + let manifest = validate_manifest_value(source.manifest.clone())?; + if source.instances.len() > MAX_INSTANCES + || sources + .insert(manifest.id().to_string(), (manifest, source)) + .is_some() + { + return Err(invalid()); + } + } + let mut inputs = Vec::new(); + for input in &consumer.model_inputs { + let mut value = json!({"pluginID":input.plugin_id,"buildSystemID":input.build_system_id,"state":"unavailable","instances":[]}); + if let Some((manifest, source)) = sources.get(&input.plugin_id) { + if let Some(producer) = manifest + .build_systems() + .iter() + .find(|build| build.id == input.build_system_id && build.model_sharing.is_some()) + { + let mut instances = BTreeMap::new(); + for instance in source + .instances + .iter() + .filter(|instance| instance.build_system_id == producer.id) + { + let root = decode::request_path(&instance.root, "root")?; + let mut shared = json!({"root":root,"state":instance.state}); + if instance.state == State::Ready { + let model = instance.model.as_ref().ok_or_else(invalid)?; + let truncated = match model.get("truncated") { + Some(value) => value.as_bool().ok_or_else(invalid)?, + None => false, + }; + if truncated { + shared["state"] = json!("stale"); + } else { + shared["model"] = public_model(model, &root)?; + let values: BTreeMap> = serde_json::from_value( + model + .get("optionValues") + .cloned() + .unwrap_or_else(|| json!({})), + ) + .map_err(|_| invalid())?; + let resolved = + options::resolve(producer, &instance.options, Some(&values)); + shared["configurationFingerprint"] = + json!(options::fingerprint(producer, &resolved)); + let shared_values: BTreeMap<_, _> = producer + .model_sharing + .as_ref() + .expect("selected public model") + .options + .iter() + .filter_map(|id| resolved.values.get(id).map(|value| (id, value))) + .collect(); + shared["options"] = json!(shared_values); + } + } + if instances.insert(root, shared).is_some() { + return Err(invalid()); + } + } + value["state"] = json!("available"); + value["instances"] = json!(instances.into_values().collect::>()); + } + } + inputs.push(value); + } + let value = json!({"version":1,"revision":request.revision,"inputs":inputs}); + if serde_json::to_vec(&value).map_or(usize::MAX, |bytes| bytes.len()) > MAX_BYTES { + return Err(invalid()); + } + check_snapshot(consumer, &value)?; + Ok(value) +} + +/// Public normalized facts only; executable tasks and run plans are never decoded here. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct PublicModel { + projects: Vec, + targets: Vec, + content_roots: Vec, +} + +fn text(value: &str, limit: usize) -> bool { + !value.is_empty() && value.len() <= limit && !value.chars().any(char::is_control) +} + +fn properties(values: &BTreeMap) -> bool { + values.len() <= 256 + && values.iter().all(|(key, value)| { + text(key, 256) && value.len() <= 1024 && !value.chars().any(char::is_control) + }) +} + +/// Rechecks IDs/ownership and lexical containment without inventing project facts. +fn public_model(model: &Value, root: &str) -> Validation { + let mut model: PublicModel = serde_json::from_value(model.clone()).map_err(|_| invalid())?; + if model.projects.len() > 2000 + || model.targets.len() > 10000 + || model.content_roots.len() > 10000 + { + return Err(invalid()); + } + let projects: BTreeMap<_, _> = model + .projects + .iter() + .map(|project| (project.id.as_str(), project)) + .collect(); + if projects.len() != model.projects.len() { + return Err(invalid()); + } + for project in &model.projects { + if !text(&project.id, 2048) + || !text(&project.name, 256) + || project + .display_name + .as_ref() + .is_some_and(|name| !text(name, 256)) + || !properties(&project.properties) + || decode::request_path(&project.path, "path")? != project.path + || !decode::contains(root, &project.path) + { + return Err(invalid()); + } + let mut parents = BTreeSet::from([project.id.as_str()]); + let mut parent = project.parent_id.as_deref(); + while let Some(id) = parent { + if !parents.insert(id) { + return Err(invalid()); + } + let ancestor = projects.get(id).ok_or_else(invalid)?; + if !decode::contains(&ancestor.path, &project.path) { + return Err(invalid()); + } + parent = ancestor.parent_id.as_deref(); + } + } + let mut targets = BTreeSet::new(); + for target in &model.targets { + let project = projects + .get(target.project_id.as_str()) + .ok_or_else(invalid)?; + if !text(&target.id, 2048) + || !targets.insert(&target.id) + || !text(&target.name, 256) + || !properties(&target.properties) + || target.tags.len() > 32 + || target.tags.iter().any(|tag| !text(tag, 256)) + || decode::request_path(&target.path, "path")? != target.path + || !decode::contains(&project.path, &target.path) + { + return Err(invalid()); + } + } + let mut roots = BTreeSet::new(); + for content in &model.content_roots { + let project = projects + .get(content.project_id.as_str()) + .ok_or_else(invalid)?; + if !roots.insert(content) + || decode::request_path(&content.path, "path")? != content.path + || !decode::contains(&project.path, &content.path) + { + return Err(invalid()); + } + } + model.projects.sort_by(|a, b| a.id.cmp(&b.id)); + model.targets.sort_by(|a, b| a.id.cmp(&b.id)); + model.content_roots.sort(); + serde_json::to_value(model).map_err(|_| invalid()) +} + +/// Revalidates the resolved snapshot instead of accepting arbitrary opaque backend context. +pub(crate) fn check_snapshot(consumer: &BuildSystemDeclaration, value: &Value) -> Validation<()> { + fn keys(value: &Value, allowed: &[&str]) -> bool { + value + .as_object() + .is_some_and(|object| object.keys().all(|key| allowed.contains(&key.as_str()))) + } + if !keys(value, &["version", "revision", "inputs"]) + || value["version"] != 1 + || value["revision"] + .as_u64() + .is_none_or(|revision| revision > 9_007_199_254_740_991) + || serde_json::to_vec(value).map_or(usize::MAX, |bytes| bytes.len()) > MAX_BYTES + { + return Err(invalid()); + } + let inputs = value["inputs"].as_array().ok_or_else(invalid)?; + if inputs.len() != consumer.model_inputs.len() { + return Err(invalid()); + } + for (value, slot) in inputs.iter().zip(&consumer.model_inputs) { + if !keys(value, &["pluginID", "buildSystemID", "state", "instances"]) + || value["pluginID"] != slot.plugin_id + || value["buildSystemID"] != slot.build_system_id + { + return Err(invalid()); + } + let instances = value["instances"].as_array().ok_or_else(invalid)?; + if instances.len() > MAX_INSTANCES + || !matches!(value["state"].as_str(), Some("available" | "unavailable")) + || value["state"] == "unavailable" && !instances.is_empty() + { + return Err(invalid()); + } + let mut roots = BTreeSet::new(); + for instance in instances { + let root = + decode::request_path(instance["root"].as_str().ok_or_else(invalid)?, "root")?; + if instance["root"] != root || !roots.insert(root.clone()) { + return Err(invalid()); + } + if instance["state"] == "ready" { + if !keys( + instance, + &[ + "root", + "state", + "model", + "configurationFingerprint", + "options", + ], + ) || public_model(&instance["model"], &root)? != instance["model"] + || !instance["configurationFingerprint"] + .as_str() + .is_some_and(|value| { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + || !instance["options"].as_object().is_some_and(|options| { + options.len() <= 32 + && options.values().all(|value| { + value.is_boolean() + || value.is_string() + || value + .as_array() + .is_some_and(|items| items.iter().all(Value::is_string)) + }) + }) + { + return Err(invalid()); + } + } else if !keys(instance, &["root", "state"]) + || !matches!( + instance["state"].as_str(), + Some("loading" | "stale" | "failed") + ) + { + return Err(invalid()); + } + } + } + Ok(()) +} diff --git a/rust/lithe-core/src/build/plans.rs b/rust/lithe-core/src/build/plans.rs index cfc9f64f28..8f8c4a4edd 100644 --- a/rust/lithe-core/src/build/plans.rs +++ b/rust/lithe-core/src/build/plans.rs @@ -68,6 +68,9 @@ pub(crate) struct SelectedEntity { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PlanBase { + /// Host-resolved public input snapshot, validated against declared slots. + #[serde(default)] + pub(crate) model_inputs: Option, /// Instance root, workspace-relative. pub(crate) root: String, /// Stored option values by option ID. @@ -248,6 +251,7 @@ pub(crate) struct PlanContext<'a> { pub(crate) fingerprint: String, /// Effective option values by option ID. option_values: BTreeMap, + model_inputs: Option, plugin_directory: Option, workspace_state_directory: Option, } @@ -273,6 +277,15 @@ impl<'a> PlanContext<'a> { build_system: &'a BuildSystemDeclaration, base: &PlanBase, ) -> Validation { + match (&base.model_inputs, build_system.model_inputs.is_empty()) { + (None, true) => {} + (Some(value), false) => super::model_inputs::check_snapshot(build_system, value)?, + _ => { + return Err(invalid_selection( + "The provider needs exactly its declared shared model inputs.", + )) + } + } Self::with_model_options(build_system, base, None) } @@ -286,6 +299,7 @@ impl<'a> PlanContext<'a> { let fingerprint = options::fingerprint(build_system, &resolved); Ok(Self { build_system, + model_inputs: base.model_inputs.clone(), root: decode::request_path(&base.root, "root")?, contribution, fingerprint, @@ -370,6 +384,9 @@ impl<'a> PlanContext<'a> { "environment".to_string(), json!(self.contribution.environment), ); + if let Some(inputs) = &self.model_inputs { + params.insert("modelInputs".into(), inputs.clone()); + } json!({ "buildSystemID": self.build_system.id, "root": self.root, @@ -966,6 +983,7 @@ pub(crate) fn run_entries( return Ok(Vec::new()); } let base = PlanBase { + model_inputs: None, root: root.to_string(), options: stored_options.clone(), plugin_directory: None, diff --git a/rust/lithe-core/src/lsp/interface/build_provider.rs b/rust/lithe-core/src/lsp/interface/build_provider.rs index b6eb9494a6..4da8ba7805 100644 --- a/rust/lithe-core/src/lsp/interface/build_provider.rs +++ b/rust/lithe-core/src/lsp/interface/build_provider.rs @@ -15,7 +15,7 @@ use serde_json::{json, Value}; const EXPERIMENTAL_KEY: &str = "litheBuild"; /// Build-provider protocol versions this client speaks, in preference order. -const SUPPORTED_VERSIONS: [u64; 5] = [5, 4, 3, 2, 1]; +const SUPPORTED_VERSIONS: [u64; 6] = [6, 5, 4, 3, 2, 1]; /// Feature name recorded in the session's server capabilities once a /// provider answered a supported version. @@ -25,6 +25,16 @@ pub(crate) const BUILD_PROVIDER_FEATURE: &str = "litheBuild"; pub(crate) const EXECUTE_METHOD: &str = "lithe/build/execute"; /// Version 4 prepares a bounded launch, while the host owns the application. pub(crate) const PREPARE_RUN_METHOD: &str = "lithe/build/prepareRun"; +/// The negotiated ability to accept host-resolved public model inputs. +pub(crate) const MODEL_INPUTS_FEATURE: &str = "litheBuildModelInputs"; +/// Old backends cannot silently ignore cross-provider context. +pub(crate) fn model_inputs_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + == Some(6) +} + /// Version 5 prepares a native debug target, never an adapter process. pub(crate) const PREPARE_DEBUG_METHOD: &str = "lithe/build/prepareDebug"; /// Admission feature for native debug preparation. @@ -34,7 +44,7 @@ pub(crate) fn debug_preparation_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - == Some(5) + .is_some_and(|version| matches!(version, 5 | 6)) } /// Admission feature for the version 4 method. @@ -87,7 +97,7 @@ pub(crate) fn execution_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 2 | 3 | 4 | 5)) + .is_some_and(|version| matches!(version, 2 | 3 | 4 | 5 | 6)) } /// Tests require an explicit version 3 agreement, independent of task support. @@ -95,7 +105,7 @@ pub(crate) fn tests_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 3 | 4 | 5)) + .is_some_and(|version| matches!(version, 3 | 4 | 5 | 6)) } /// A preparation has progress and cancellation, but its reply is a launch plan. @@ -108,7 +118,7 @@ pub(crate) fn run_preparation_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 4 | 5)) + .is_some_and(|version| matches!(version, 4 | 5 | 6)) } /// Normalizes bounded progress; the engine checks the pending request identity. @@ -180,7 +190,7 @@ mod tests { &json!({ "experimental": { "litheBuild": {} } }) )); assert!(!negotiated( - &json!({ "experimental": { "litheBuild": { "version": 6 } } }) + &json!({ "experimental": { "litheBuild": { "version": 7 } } }) )); assert!(!negotiated( &json!({ "experimental": { "litheBuild": { "version": "1" } } }) diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 980c5ac3ad..131bdb8c3e 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -1756,6 +1756,9 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { if super::build_provider::tests_negotiated(capabilities) { insert_unique(&mut values, super::build_provider::TEST_EXECUTION_FEATURE); } + if super::build_provider::model_inputs_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::MODEL_INPUTS_FEATURE); + } if super::build_provider::debug_preparation_negotiated(capabilities) { insert_unique( &mut values, diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 51e3d472ea..e1b7285a23 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -2155,6 +2155,19 @@ impl RuntimeSession { ) .with_details("plugin.build.unavailable")); } + if request.params.get("modelInputs").is_some() + && !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::MODEL_INPUTS_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate model inputs.", + ) + .with_details("plugin.build.unavailable")); + } let mut params = request.params; if super::build_provider::is_progress_method(&request.method) { let object = params.as_object_mut().ok_or_else(|| { @@ -11620,7 +11633,7 @@ public class Main { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"]["litheBuild"], - json!({ "versions": [5, 4, 3, 2, 1] }) + json!({ "versions": [6, 5, 4, 3, 2, 1] }) ); let error = build_request(&harness, "lithe/build/model", json!({}), None) @@ -11633,6 +11646,42 @@ public class Main { .all(|message| message["method"] != "lithe/build/model")); } + /// A legacy backend must never silently execute without a declared shared input. + #[test] + fn model_inputs_need_version_six_even_for_an_old_model_method() { + for version in [5, 6] { + let mut harness = Harness::start(|_| {}); + harness + .server + .complete_initialize(json!({"experimental":{"litheBuild":{"version":version}}})); + harness.await_state(LspLifecycleState::Ready); + let params = json!({"modelInputs":{"version":1,"revision":7,"inputs":[]}}); + let result = build_request(&harness, "lithe/build/model", params.clone(), None); + if version == 5 { + assert!(matches!(result.unwrap_err().code, ErrorCode::NotSupported)); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/model")); + } else { + let operation = result.unwrap(); + let id = harness + .server + .await_request("lithe/build/model") + .expect("version six receives its snapshot"); + assert_eq!(harness.written_request(&id)["params"], params); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":{"projects":[]}})); + harness.await_event(|event| { + event.operation_id.as_deref() == Some(&operation) + && event.kind == "requestCompleted" + }); + } + } + } + #[test] fn a_negotiated_build_request_forwards_the_plan_and_returns_the_reply() { let mut harness = negotiated_build_provider(); @@ -12127,7 +12176,7 @@ public class Main { assert_eq!( initialize["params"]["capabilities"]["experimental"], json!({ - "litheBuild": { "versions": [5, 4, 3, 2, 1] }, + "litheBuild": { "versions": [6, 5, 4, 3, 2, 1] }, "litheFunctions": { "versions": [1, 2] }, "litheVcs": { "versions": [1] } }) diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index 232d706d7e..e716bec86e 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -41,6 +41,8 @@ pub(crate) const UI: &str = "ui"; pub(crate) const BUILD_SYSTEMS: &str = "buildSystems"; /// `buildSystems[].provider`, whether it owns or shares a language server process. pub(crate) const BUILD_PROVIDERS: &str = "buildProviders"; +/// Optional public model snapshots between enabled build providers. +pub(crate) const BUILD_MODEL_INPUTS: &str = "buildModelInputs"; /// `vcsProviders[]`: version control systems implemented by a plugin /// process; see `crate::vcs`. pub(crate) const VCS_PROVIDERS: &str = "vcsProviders"; @@ -66,7 +68,8 @@ pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; /// capability keeps the meaning its section had at API level 2. pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (BACKEND, &[1]), - (BUILD_PROVIDERS, &[1, 2, 3, 4, 5]), + (BUILD_MODEL_INPUTS, &[1]), + (BUILD_PROVIDERS, &[1, 2, 3, 4, 5, 6]), (BUILD_SYSTEMS, &[1, 2]), (DEBUG_ADAPTERS, &[1, 2]), (EXECUTION_PROVIDERS, &[1]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 5398eeef43..39086403b8 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -977,6 +977,7 @@ impl DeclarativePluginManifest { plugin_api_version: self.plugin_api_version, build_systems_version: self.requires.get("buildSystems").copied().unwrap_or(1), build_providers_version: self.requires.get("buildProviders").copied().unwrap_or(1), + model_inputs_version: self.requires.get("buildModelInputs").copied().unwrap_or(0), official: is_official_namespace(&self.id), declared_settings: &declared_settings, language_servers: &server_languages, @@ -1062,6 +1063,12 @@ impl DeclarativePluginManifest { .iter() .any(|system| system.provider.is_some()), ), + ( + capabilities::BUILD_MODEL_INPUTS, + self.build_systems.iter().any(|system| { + system.model_sharing.is_some() || !system.model_inputs.is_empty() + }), + ), (capabilities::VCS_PROVIDERS, !self.vcs_providers.is_empty()), ( capabilities::DEBUG_ADAPTERS, diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 9fd0785521..3ea2b64908 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -108,6 +108,8 @@ pub enum CoreCommand { BuildTestPlan, /// Plans typed provider run preparation (`build.prepareRunPlan`). BuildPrepareRunPlan, + /// Resolves optional public build inputs (`build.resolveModelInputs`). + BuildResolveModelInputs, /// Plans native debug preparation (`build.prepareDebugPlan`). BuildPrepareDebugPlan, /// Validates native debug target facts (`build.decodeDebugPreparation`). @@ -538,6 +540,7 @@ impl CoreCommand { "build.testPlan" => Some(Self::BuildTestPlan), "build.prepareDebugPlan" => Some(Self::BuildPrepareDebugPlan), "build.decodeDebugPreparation" => Some(Self::BuildDecodeDebugPreparation), + "build.resolveModelInputs" => Some(Self::BuildResolveModelInputs), "build.prepareRunPlan" => Some(Self::BuildPrepareRunPlan), "build.decodeRunPreparation" => Some(Self::BuildDecodeRunPreparation), "build.testDiscoveryPlan" => Some(Self::BuildTestDiscoveryPlan), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 007d8ea445..286fd0fce1 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -272,6 +272,11 @@ fn execute(request: &str) -> CoreResponse { parsed.payload, crate::build::build_decode_debug_preparation_command, ), + CoreCommand::BuildResolveModelInputs => plugin_response( + id, + parsed.payload, + crate::build::build_resolve_model_inputs_command, + ), CoreCommand::BuildPrepareRunPlan => plugin_response( id, parsed.payload, diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index a469ca6667..f3f73b3c00 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -2238,3 +2238,174 @@ fn provider_debug_target_bindings_keep_exact_arguments_and_require_bounded_facts assert_eq!(arguments(invalid)["status"], "rejected"); } } + +/// Only explicitly published normalized facts and selected options reach the exact consumer. +#[test] +fn model_inputs_project_public_facts_and_preserve_configuration_identity() { + use crate::build::build_resolve_model_inputs_command; + let producer: Value = + serde_json::from_str(contract_file!("fixtures/build-model-producer.plugin.json")).unwrap(); + let consumer: Value = + serde_json::from_str(contract_file!("fixtures/build-model-consumer.plugin.json")).unwrap(); + assert_eq!( + validate(producer.clone())["status"], + "ok", + "{}", + validate(producer.clone()) + ); + assert_eq!( + validate(consumer.clone())["status"], + "ok", + "{}", + validate(consumer.clone()) + ); + let request = json!({"revision":7,"sources":[{"manifest":producer,"instances":[{ + "buildSystemID":"source","root":"app","state":"ready","options":{"offline":true}, + "model":{"projects":[{"id":"source:app/demo","path":"app","name":"demo","properties":{"group":"org.example"}}], + "targets":[{"id":"source:app/main","projectID":"source:app/demo","path":"app","name":"main","tags":["application"],"properties":{}}], + "contentRoots":[{"projectID":"source:app/demo","path":"app/src","kind":"source"}], + "tasks":[{"command":"private"}],"run":[{"command":"private"}],"optionValues":{},"settings":{"secret":"private"}} + }]}]}); + let resolve = |request: Value| { + build_resolve_model_inputs_command( + json!({"manifest":consumer,"buildSystemID":"gradle","request":request}), + ) + .unwrap() + }; + let result = resolve(request.clone()); + assert_eq!(result["status"], "ok", "{result}"); + let snapshot = &result["modelInputs"]; + let instance = &snapshot["inputs"][0]["instances"][0]; + assert_eq!(instance["options"], json!({"offline":true})); + assert!(instance["model"].get("tasks").is_none()); + assert!(instance["model"].get("run").is_none()); + assert!(instance["model"].get("settings").is_none()); + assert!(instance["model"].get("optionValues").is_none()); + assert_eq!( + instance["model"]["targets"][0]["tags"], + json!(["application"]) + ); + let plan = build_task_plan_command(json!({"manifest":consumer,"buildSystemID":"gradle","request":{"root":"app","task":"build","modelInputs":snapshot}})).unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!(plan["plan"]["provider"]["params"]["modelInputs"], *snapshot); + let mut no_sharing = request.clone(); + no_sharing["sources"][0]["manifest"]["buildSystems"][0]["modelSharing"] = json!({}); + let stripped = resolve(no_sharing); + assert_eq!( + stripped["modelInputs"]["inputs"][0]["instances"][0]["options"], + json!({}) + ); + let mut changed = request.clone(); + changed["sources"][0]["instances"][0]["options"]["offline"] = json!(false); + assert_ne!( + resolve(changed)["modelInputs"]["inputs"][0]["instances"][0]["configurationFingerprint"], + instance["configurationFingerprint"] + ); + for state in ["loading", "stale", "failed"] { + let mut pending = request.clone(); + pending["sources"][0]["instances"][0]["state"] = json!(state); + let value = resolve(pending); + assert_eq!(value["status"], "ok"); + assert_eq!( + value["modelInputs"]["inputs"][0]["instances"][0], + json!({"root":"app","state":state}) + ); + } + let missing = resolve(json!({"revision":8,"sources":[]})); + assert_eq!(missing["modelInputs"]["inputs"][0]["state"], "unavailable"); + assert_eq!(missing["modelInputs"]["inputs"][0]["instances"], json!([])); +} + +/// Capability admission and every later plan reject opaque or stale authority-shaped payloads. +#[test] +fn model_inputs_reject_invalid_manifests_models_and_snapshot_smuggling() { + use crate::build::build_resolve_model_inputs_command; + let producer: Value = + serde_json::from_str(contract_file!("fixtures/build-model-producer.plugin.json")).unwrap(); + let consumer: Value = + serde_json::from_str(contract_file!("fixtures/build-model-consumer.plugin.json")).unwrap(); + for change in ["oldWire", "noCapability", "reexport", "unknownOption"] { + let mut manifest = consumer.clone(); + match change { + "oldWire" => manifest["requires"]["buildProviders"] = json!(5), + "noCapability" => { + manifest["requires"] + .as_object_mut() + .unwrap() + .remove("buildModelInputs"); + } + "reexport" => manifest["buildSystems"][0]["modelSharing"] = json!({}), + _ => { + manifest = producer.clone(); + manifest["buildSystems"][0]["modelSharing"]["options"] = json!(["unknown"]); + } + } + assert_eq!(validate(manifest)["status"], "rejected", "{change}"); + } + let model = json!({"projects":[{"id":"p","path":"app","name":"demo","properties":{}}],"targets":[],"contentRoots":[]}); + let request = json!({"revision":1,"sources":[{"manifest":producer,"instances":[{"buildSystemID":"source","root":"app","state":"ready","model":model}]}]}); + for change in [ + "escape", + "badName", + "missingOwner", + "parentCycle", + "duplicate", + "truncated", + ] { + let mut request = request.clone(); + let model = &mut request["sources"][0]["instances"][0]["model"]; + match change { + "escape" => model["projects"][0]["path"] = json!("../private"), + "badName" => model["projects"][0]["name"] = json!({"command":"run"}), + "missingOwner" => { + model["contentRoots"] = + json!([{"projectID":"elsewhere","path":"app/src","kind":"source"}]) + } + "parentCycle" => model["projects"][0]["parentID"] = json!("p"), + "duplicate" => { + let row = model["projects"][0].clone(); + model["projects"].as_array_mut().unwrap().push(row); + } + _ => model["truncated"] = json!(true), + } + let value = build_resolve_model_inputs_command( + json!({"manifest":consumer,"buildSystemID":"gradle","request":request}), + ) + .unwrap(); + if change == "truncated" { + assert_eq!( + value["modelInputs"]["inputs"][0]["instances"][0], + json!({"root":"app","state":"stale"}) + ); + } else { + assert_eq!(value["status"], "rejected", "{change}: {value}"); + } + } + let resolved = build_resolve_model_inputs_command( + json!({"manifest":consumer,"buildSystemID":"gradle","request":request}), + ) + .unwrap(); + for change in [ + "private", + "wrongPlugin", + "optionsObject", + "unavailablePayload", + "wrongRevision", + ] { + let mut snapshot = resolved["modelInputs"].clone(); + match change { + "private" => { + snapshot["inputs"][0]["instances"][0]["model"]["run"] = json!([{"command":"run"}]) + } + "wrongPlugin" => snapshot["inputs"][0]["pluginID"] = json!("dev.other"), + "optionsObject" => { + snapshot["inputs"][0]["instances"][0]["options"] = + json!({"private":{"command":"run"}}) + } + "unavailablePayload" => snapshot["inputs"][0]["state"] = json!("unavailable"), + _ => snapshot["revision"] = json!(9007199254740992_u64), + } + let plan = build_task_plan_command(json!({"manifest":consumer,"buildSystemID":"gradle","request":{"root":"app","task":"build","modelInputs":snapshot}})).unwrap(); + assert_eq!(plan["status"], "rejected", "{change}: {plan}"); + } +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index e2320ea0cd..a8c6beca8f 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -406,7 +406,8 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | | `buildSystems` | Declarative build detection, models, and command plans | `1` | -| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; v2 tasks, v3 tests, v4 run, v5 native debug preparation | `1`, `2`, `3`, `4`, `5` | +| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; v2 tasks, v3 tests, v4 run, v5 native debug preparation, v6 read-only model inputs | `1`, `2`, `3`, `4`, `5`, `6` | +| `buildModelInputs` | Optional public model sharing between enabled workspace plugins | `1` | | `vcsProviders` | `vcsProviders[]` | `1` | | `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters); v2 adds target arguments/environment | `1`, `2` | | `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | @@ -456,7 +457,7 @@ in `plugins.declarative.validate`, `plugins.marketplace.validateIndex`, and The macOS host sends its capability table with validation, marketplace filtering and install review. It advertises `buildSystems: [1, 2]` and -`buildProviders: [1, 2, 3, 4, 5]`; native Provider models, dependencies, tasks, tests and launch preparation +`buildProviders: [1, 2, 3, 4, 5, 6]`; native Provider models, dependencies, tasks, tests and launch preparation share workspace-owned sessions. Backend and VCS support use their own entries in the same table. Core validates supported versions without a host table, but admission must use the actual host table. Windows does not yet advertise @@ -1119,6 +1120,79 @@ Service coordination, debug target registration and Java/Maven migration remain separate. See `fixtures/build-provider-run-preparation.plugin.json` and `fixtures/build-run-preparation.json`. +### Optional read-only model inputs (buildModelInputs 1) + +A producer opts into `buildSystems[].modelSharing: { options: [] }`. +Only normalized `projects`, `targets` and `contentRoots`, including their +ordinary public metadata, are published. Tasks, run entries, settings, +`optionValues`, process handles and private functions are excluded. The +options list names existing ordinary build options whose **effective values** +may be shared; omitted options stay private. A configuration fingerprint +identifies all effective options without publishing their values. + +A consumer declares `modelInputs: [{ pluginID, buildSystemID }]`, requires +`buildModelInputs: 1` and `buildProviders: 6`, and owns a provider and model. +Slots are exact optional identities; Core sorts them by plugin ID and build +system ID and rejects duplicates. Version 1 producers cannot also consume +inputs, so consumers cannot recursively re-export a model. + +The native host calls `build.resolveModelInputs` with the consumer manifest +and `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, +model?, options?}]}]}`. Sources are enabled installed packages in the current +workspace activation, never identities supplied by another backend. +Input states are `ready`, `loading`, `stale`, or `failed`; only ready +instances include a complete normalized model. A truncated model becomes stale. +Core revalidates source manifests, ownership, shape and path containment and +returns `modelInputs: {version: 1, revision, inputs}`, where each input is: + +```json +{ + "pluginID": "dev.example.source", + "buildSystemID": "source", + "state": "available", + "instances": [{ + "root": "app", + "state": "ready", + "model": {"projects": [], "targets": [], "contentRoots": []}, + "configurationFingerprint": "<64 lowercase hex characters>", + "options": {} + }] +} +``` + +A missing, disabled, non-public or mismatched producer gives +`state: "unavailable", instances: []`. An installed public producer with no +detected instance is available with an empty list. A non-ready instance has +only `root` and `state`; no old model or options travel with it. Roots and +model paths remain workspace-relative, and instances are deterministically +ordered. There are at most 8 slots, 64 instances per source, 2,000 projects and +10,000 targets/content roots per instance, and 8 MiB of resolved JSON. The +revision is a nonnegative integer no larger than 9,007,199,254,740,991. + +The host supplies this snapshot as the common plan request's optional +`modelInputs`. It is required exactly for a consumer with declared slots. +Every Provider plan copies it unchanged into `params.modelInputs`; Core +revalidates the snapshot and rejects extra fields or mismatched slots. +The running backend must negotiate `litheBuild` version 6 independently of +manifest admission. Older versions reject an input-bearing request even for +an existing method such as `lithe/build/model`. + +A revision belongs to one workspace activation. The host revokes consumer +model/dependency loads, discovery and in-flight execution/preparation when a +producer's model, options, watched configuration or package availability +changes. Revisions also detect changing an option away and back before a reply +arrives. Guard the result after decoding as well as after the RPC; do not +publish an old reply merely because cancellation was ignored. Already launched +applications retain their existing native ownership. + +This facility never installs or activates a producer, invokes its private +functions, grants permissions, leases a process or resolves a package version. +There is no general plugin-dependency/export API. Backend authors must handle +unavailable and non-ready slots explicitly. Language integration and existing +Java/Maven business migration remain separate work. Shared fixtures: +`fixtures/build-model-producer.plugin.json` and +`fixtures/build-model-consumer.plugin.json`. + ### Build providers Some tools expose their model only through their own API (the Gradle Tooling diff --git a/shared/contracts/plugins/v1/fixtures/build-model-consumer.plugin.json b/shared/contracts/plugins/v1/fixtures/build-model-consumer.plugin.json new file mode 100644 index 0000000000..57fc3c55c3 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-model-consumer.plugin.json @@ -0,0 +1,170 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.model-consumer", + "displayName": "Model Consumer Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 6, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1, + "buildModelInputs": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "source": "provider", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [] + } + ], + "run": [], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "modelInputs": [ + { + "pluginID": "dev.lithe.fixture.model-producer", + "buildSystemID": "source" + } + ] + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-model-producer.plugin.json b/shared/contracts/plugins/v1/fixtures/build-model-producer.plugin.json new file mode 100644 index 0000000000..92055e41d6 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-model-producer.plugin.json @@ -0,0 +1,158 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.model-producer", + "displayName": "Model Producer Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1, + "buildModelInputs": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:source" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "source", + "displayName": "Source", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [], + "run": [], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "modelSharing": { + "options": [ + "offline" + ] + } + } + ] +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index c2ef2e3a98..a415395fe2 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -28,7 +28,8 @@ "additionalProperties": false, "properties": { "backend": { "description": "Required exactly when backend is present: the plugin's own process serving its web pages.", "enum": [1] }, - "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution, version 3 test execution/discovery, version 4 application run preparation, and version 5 native debug preparation.", "enum": [1, 2, 3, 4, 5] }, + "buildModelInputs": {"description": "Optional public model sharing between already enabled workspace plugins; no installation, private function, or process authority. Required exactly when modelSharing or modelInputs is used. Consumers require buildProviders 6. Version 1 producers cannot also consume inputs.", "enum": [1]}, + "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution, version 3 test execution/discovery, version 4 application run preparation, and version 5 native debug preparation; version 6 optional read-only model inputs.", "enum": [1, 2, 3, 4, 5, 6] }, "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. Version 2 adds bounded target argument and environment bindings. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1, 2] }, "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, @@ -1944,6 +1945,8 @@ "invocation" ], "properties": { + "modelSharing": {"type": "object", "additionalProperties": false, "properties": {"options": {"type": "array", "maxItems": 32, "uniqueItems": true, "items": {"type": "string"}}}, "description": "Opt into publishing normalized projects, targets and content roots, plus only these explicitly shared ordinary build option values. Never tasks, run plans, settings or optionValues."}, + "modelInputs": {"type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": {"type": "object", "additionalProperties": false, "required": ["pluginID", "buildSystemID"], "properties": {"pluginID": {"type": "string"}, "buildSystemID": {"type": "string"}}}, "description": "Exact optional producer slots, sorted by Core. Missing or disabled producers are unavailable and never installed or activated automatically."}, "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 81c9ddab5c..78285b7118 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1106,6 +1106,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `build.detect` | Find every build-system instance the enabled plugins declare in a workspace, with models that need no process | | `build.invocationPlan` | List a build system's executable candidates, runtime dependencies, and version probe for one platform | | `build.checkToolVersion` | Decide whether a build tool's or runtime's version output meets the declared minimum | +| `build.resolveModelInputs` | Validate enabled producer facts and project a bounded public model snapshot for a declared consumer | | `build.resolveOptions` | Check stored build options against the declaration and return effective values, choices, and the fingerprint | | `build.modelPlan` | Expand the command that prints a command-sourced build model, or the request a build provider answers | | `build.taskPlan` | Expand one declared or model task for a workspace, project, or target | @@ -3399,9 +3400,10 @@ decodes what it printed or wrote. A build system with a `provider` requires `buildProviders: 1`, version 2 when a task uses `source: "provider"`, version 3 when tests do, or version 4 for -provider application run preparation. Core validates and plans all four versions; +provider application run preparation, version 5 for native debug preparation, and +version 6 for optional read-only model inputs. Core validates and plans all six versions; a host advertises each only after implementing its lifecycle and requests. -macOS advertises all four and routes provider tasks, tests and run preparation through +macOS advertises all six and routes provider tasks, tests and run preparation through the existing workspace session. Prepared applications use the existing native Run host. Windows does not yet advertise Provider lifecycle support. Except `build.detect`, every command accepts @@ -3431,7 +3433,7 @@ Paths in requests are workspace-relative with `/` unless named absolute. `root` is the instance root from detection. Selected entities are `{ name, path?, tags?, execution?, properties? }` as the decoded model returned them. Plan requests share `{ root, options?, pluginDirectory?, -workspaceStateDirectory? }`; `options` are the stored values by option ID, +workspaceStateDirectory?, modelInputs? }`; `options` are the stored values by option ID, and the directories are absolute host paths used only as substitution text. | Command | Request | Result | @@ -3439,6 +3441,7 @@ and the directories are absolute host paths used only as substitution text. | `build.detect` | `{ workspaceRoot, plugins: [{ manifest }], options?: { : { : value } } }` | `detection`: `{ instances, rejected: [{ index, error }], shadowed: [{ pluginID, buildSystemID, by }] }`; each instance is `{ id, pluginID, buildSystemID, displayName, root, markers, modelSource, optionFingerprint }` plus `model` or `modelError` for `static` and `hostIntegration` sources; a `provider` source has neither | | `build.invocationPlan` | `{ root, platform: "macos" \| "linux" \| "windows" }` | `invocation`: `{ buildSystemID, root, executables, environmentInputs, runtimeDependencies, version, minimumVersion }` with project-file paths joined to `root`; every runtime dependency with a known probe, host-managed ones included, carries `version: { arguments }` | | `build.checkToolVersion` | `{ runtime?, output }` | `version`: `{ satisfied, version? }`; `runtime` checks that runtime dependency instead of the tool | +| `build.resolveModelInputs` | `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, model?, options?}]}]}` from installed enabled current-workspace sources | `modelInputs`: version 1 public snapshot with exact optional producer slots, state, normalized projects/targets/content roots, configuration fingerprint and explicitly shared options; no tasks/run/settings/private functions; see the [plugin contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/plugins/v1/README.md#optional-read-only-model-inputs-buildmodelinputs-1) | | `build.resolveOptions` | `{ options, modelValues? }` | `options`: `{ options: [{ id, displayName, kind, value, source, choices, allowCustom }], dropped, fingerprint }` | | `build.modelPlan`, `build.dependencyPlan` | plan fields plus `project?` (dependencies), `outputFile?`, and for provider sources `refresh?` (boolean) and `tool?: { executable }` (absolute path) | `plan`: `{ buildSystemID, root, arguments, environment, workingDirectory, execution, optionFingerprint, output, decoder, executesProjectCode, mayUseNetwork }`; for a `provider` source `{ buildSystemID, root, provider: { method, params }, decoder, executesProjectCode, mayUseNetwork, optionFingerprint }`, where `method` is `lithe/build/model` or `lithe/build/dependencies` and `params` is `{ buildSystemID, root, options, optionArguments, environment, project?, refresh?, tool? }`; `refresh` appears only when `true` and `tool` only when the host sent it | | `build.taskPlan` | plan fields plus `task`, `project?`, `target?`, `modelTask?`, `userArguments?` | command `plan` with `arguments`, `environment`, `workingDirectory`, `execution`, and `optionFingerprint`; a provider task instead returns `provider: {method: "lithe/build/execute", params}` with effective options, selected task/entities, user arguments and effect metadata | diff --git a/shared/platform-feature-matrix/features/build-provider-readonly-model-inputs.json b/shared/platform-feature-matrix/features/build-provider-readonly-model-inputs.json new file mode 100644 index 0000000000..8bb2876c7c --- /dev/null +++ b/shared/platform-feature-matrix/features/build-provider-readonly-model-inputs.json @@ -0,0 +1,30 @@ +{ + "id": "build-provider-readonly-model-inputs", + "area": "扩展", + "group": "插件与主题", + "capability": "已启用的构建 Provider 可显式发布只读项目、目标和内容目录及选定普通构建选项;消费者声明精确可选来源,由宿主/Core 提供有界快照;配置、模型、插件或工作区变化撤销旧输入,并拒绝旧回复,协议 6 独立准入", + "macos": { + "evidence": [ + "rust/lithe-core/src/build/model_inputs.rs", + "rust/lithe-core/src/tests/build_systems.rs", + "shared/contracts/plugins/v1/fixtures/build-model-producer.plugin.json", + "shared/contracts/plugins/v1/fixtures/build-model-consumer.plugin.json", + "macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift", + "macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift", + "macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift", + "macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-core/src/build/model_inputs.rs", + "shared/contracts/plugins/v1/plugin-manifest.schema.json" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Extensions", + "verification": "本地运行 Core model_inputs 测试、BuildSystemServiceTests 和 RustBuildSystemOperationsTests:核对精确来源、只读字段投影、选项指纹、不可用/过期状态、路径/大小边界、协议 5 拒绝、协议 6 接收,以及生产者重载/选项 ABA/停用/构建文件修改/工作区替换后忽略取消的旧结果不能发布。在 lithe-plugin-docs 使用当前工作区快照执行 Node/Java 两个真实后端的模型共享端到端场景,核对插件包字节不变。手动验收仍需安装一对正式插件,在原生窗口加载生产者与消费者,再修改选项、禁用生产者并确认旧模型不能启动任务;当前 Java/Maven 插件尚未迁移,Windows 宿主缺失,均不视为通过实机验收。" +} diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index 266483b6e6..880e462c0a 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -65,5 +65,5 @@ "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。命令模型 run 配置尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实工具及 Windows 工作流仍待验收。;buildProviders 4 应用目标准备接入原生 Build 树,构建进度在 Build Output 展示,验证后转交 RunService 解析工具与启动/停止应用;准备期间源码、选项、工作区或停用变化拒绝迟到启动,旧操作 ID 不得停止替换进程;Core 拒绝绝对可执行路径、越界目录/符号链接及超限参数环境。服务编排、调试与 Java/Maven 消费者迁移尚未完成;真实插件/发行包和 Windows 工作流仍待验收。;Run v2 本地层显式保存 Provider 选择器,参数保持原始数组;无 generated.json 也能读回/显示,重复保存保留 ID/其他配置/项目环境;重启重新进入当前 Provider,停止与晚到结果按身份隔离。原生入口保存工作区文档后再准备,Run 窗口在未生成旧配置时仍显示 Provider 输出。;Agent 入口回归从真实本地配置启动,验证准备到进程退出保持公开操作 ID、准备中停止与重启重新准备,旧 ID 不能停止替换操作;Provider 与进程为可控替身,真实插件端到端验收仍待完成。;配套独立仓库 Node/Java SDK 源码补齐协议 2/3/4 的处理器、版本准入与进度;无页面示例经真实 Core 验证非零构建退出码、精确筛选、运行前构建及准备计划、并行工作区、取消工具清理和完整包不变,另保留锁定旧契约的兼容场景与 TypeScript 调用方编译检查。SDK 尚未发布,真实 Maven/JDT 与 Windows 验收仍待完成。 原生 Debug:运行声明引用同包适配器,点击 Build 树 Debug 或保存配置的 Debug 后先保存并构建,验证程序路径/参数/环境交给注册表;停止、源码或配置变化、模型重载、插件停用与工作区切换均丢弃过期结果,解码后的文件别名变化和不可执行文件在平台交接前拒绝。Node/Java SDK 进程的准备协议与 Core 解码有端到端覆盖;真实适配器、Java/JDT、跨插件协作和 Windows 尚未验收。" + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。命令模型 run 配置尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实工具及 Windows 工作流仍待验收。;buildProviders 4 应用目标准备接入原生 Build 树,构建进度在 Build Output 展示,验证后转交 RunService 解析工具与启动/停止应用;准备期间源码、选项、工作区或停用变化拒绝迟到启动,旧操作 ID 不得停止替换进程;Core 拒绝绝对可执行路径、越界目录/符号链接及超限参数环境。服务编排、调试与 Java/Maven 消费者迁移尚未完成;真实插件/发行包和 Windows 工作流仍待验收。;Run v2 本地层显式保存 Provider 选择器,参数保持原始数组;无 generated.json 也能读回/显示,重复保存保留 ID/其他配置/项目环境;重启重新进入当前 Provider,停止与晚到结果按身份隔离。原生入口保存工作区文档后再准备,Run 窗口在未生成旧配置时仍显示 Provider 输出。;Agent 入口回归从真实本地配置启动,验证准备到进程退出保持公开操作 ID、准备中停止与重启重新准备,旧 ID 不能停止替换操作;Provider 与进程为可控替身,真实插件端到端验收仍待完成。;配套独立仓库 Node/Java SDK 源码补齐协议 2/3/4 的处理器、版本准入与进度;无页面示例经真实 Core 验证非零构建退出码、精确筛选、运行前构建及准备计划、并行工作区、取消工具清理和完整包不变,另保留锁定旧契约的兼容场景与 TypeScript 调用方编译检查。SDK 尚未发布,真实 Maven/JDT 与 Windows 验收仍待完成。 原生 Debug:运行声明引用同包适配器,点击 Build 树 Debug 或保存配置的 Debug 后先保存并构建,验证程序路径/参数/环境交给注册表;停止、源码或配置变化、模型重载、插件停用与工作区切换均丢弃过期结果,解码后的文件别名变化和不可执行文件在平台交接前拒绝。Node/Java SDK 进程的准备协议与 Core 解码有端到端覆盖;真实适配器、Java/JDT、跨插件协作和 Windows 尚未验收。 Maven 独立插件的本地增量已经删除生命周期/插件目标/自定义目标/测试的命令模板,改由 Provider 3 执行;真实 Core C ABI + Java 后端 + Maven 三模块工程验证模块编译、单方法 Surefire 报告和模型共享,受控进程回归覆盖取消、非零退出和输出超限。插件与新版 SDK 尚未提交发布,正式 SDK 锁定仍待更新;旧 MavenService、Agent/设置/Run/Java 迁移与完整 macOS UI、Java 8、Windows 验收未完成,保持 partial/pending。" } From 9f702e15deeeed2e9889d396fab50c706ee48c0a Mon Sep 17 00:00:00 2001 From: Mucheen <145200703+Mucheen@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:56:00 +0800 Subject: [PATCH 06/76] fix(build): isolate producer revisions and own run saves --- ...026-10-26-build-provider-task-execution.md | 20 +++ .../assets/provider-workflow-review/README.md | 28 +++ .../build-collapsed-dark.png | Bin 0 -> 90829 bytes .../build-collapsed-light.png | Bin 0 -> 87973 bytes .../build-discovery-debug-dark.png | Bin 0 -> 121007 bytes .../build-discovery-debug-light.png | Bin 0 -> 113714 bytes .../provider-run-output-dark.png | Bin 0 -> 79934 bytes .../provider-run-output-light.png | Bin 0 -> 75678 bytes .../AppModel/AppModel+RunConfiguration.swift | 14 +- .../BuildSystemsFeatureModel.swift | 5 +- .../Services/BuildSystemService.swift | 48 +++-- .../BuildSystemServiceTests.swift | 153 +++++++++++++++- .../LitheTests/IdeCapabilitiesTests.swift | 49 +++++ .../ProviderWorkflowPresentationTests.swift | 170 ++++++++++++++++++ .../build-provider-readonly-model-inputs.json | 2 +- .../features/build-system-plugins.json | 6 +- 16 files changed, 469 insertions(+), 26 deletions(-) create mode 100644 docs/assets/provider-workflow-review/README.md create mode 100644 docs/assets/provider-workflow-review/build-collapsed-dark.png create mode 100644 docs/assets/provider-workflow-review/build-collapsed-light.png create mode 100644 docs/assets/provider-workflow-review/build-discovery-debug-dark.png create mode 100644 docs/assets/provider-workflow-review/build-discovery-debug-light.png create mode 100644 docs/assets/provider-workflow-review/provider-run-output-dark.png create mode 100644 docs/assets/provider-workflow-review/provider-run-output-light.png create mode 100644 macos/Tests/LitheTests/ProviderWorkflowPresentationTests.swift diff --git a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md index 94d4040015..d2bc5b9f4a 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md +++ b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md @@ -50,6 +50,12 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 同一路径重开仍是新的服务代次。插件更新先停止旧 Provider 再替换包;配置变化后的下一次调用按现有规则重新建立会话。Windows 当前尚未接入 Build Provider 宿主,继续不声明此能力,不以 Core 支持版本 2 代替 Windows 实机实现。 +生产者自身的模型是否过期,使用该实例独立的配置代次判断;它只随所属构建系统的 +选项或包配置变化而递增。另一个生产者开始或完成正常加载,只撤销消费者的共享 +快照,不能让本实例变成过期。全局共享快照代次继续用于拒绝消费者的旧回复; +文件代次与工作区身份仍分别检查。这样同时加载 A、B 时,两者可按任意回复顺序 +成为 ready;A 自己的选项改为另一值再改回(ABA)时,旧回复仍保持 stale。 + ### 资源与数据边界 本次没有新增下载入口、生成目录、缓存路径或用户数据迁移。执行复用既有 `workspaceStateDir` 和进程生命周期;插件包与发行目录只读,运行状态、日志、锁和输出仍由现有平台存储 adapter 提供位置。可变工作区状态不跨工作树共享,不影响代码签名或 Sparkle 增量更新的基线。 @@ -70,6 +76,13 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 准备中的源码、模型、配置、插件或工作区变化使旧结果失效,解码完成后也要再次检查。应用启动后不因普通源码编辑就终止;停用所属插件或关闭工作区才按对应操作 ID 停止,旧操作 ID 不能停止新进程。运行目录只能是工作区内、属于当前实例的已有目录;符号链接解析后的目录也必须满足这一限制。没有新增运行资源目录、下载或跨工作树缓存,既有平台进程与临时参数 adapter 继续负责可写位置,安装包保持只读。 +Build 树的应用点击在第一次异步等待之前就登记现有运行槽的 ID。文档保存作为 +这次操作的就绪步骤,由同一任务持有;保存成功后再检查运行 ID、工作区、源文件和 +模型身份,才能请求 Provider。取消或保存失败都不能进入准备。不要在 AppModel +另起无句柄的保存任务,再在保存结束时创建一次新运行:旧点击可能因此停止较新的 +任务或测试、清空它们的输出。后续运行、停止、重载、工作区切换及插件撤回都能撤销 +保存阶段;即使保存忽略取消后才返回,也不能重占槽或启动应用。 + 首次从 Build 树运行仍在 Build 窗口管理准备;从 Run 重启时,Run 的停止动作取消对应准备 ID,成功启动后改由进程 ID 管理。应用输出不再要求旧式检测器先生成 Run 配置,也不会因缺少 generated.json 被配置引导页遮住。多服务编排与 Java/JDT 调试准备仍未接入,不能把此增量表述为阶段 C 或 Java/Maven 迁移全部完成。 ### 保存选择器,重启时重新准备 @@ -180,6 +193,13 @@ Agent 专属入口、项目设置、运行配置和 Java/JDT 协同仍未迁移 macOS 使用固定的 Swift 6.3.3 工具链和原生计时 harness。相关服务回归控制准备开始、解码暂停、停止或工作区切换的顺序;原生存储用真实 Core 和隔离临时目录验证保存后读回。Run 的对外操作身份从准备到应用退出保持不变,底层准备/进程各自按身份取消;Agent 启动返回同一主运行记录,授权仍沿用既有入口。插件停用、进程替换和工作区切换不能留下旧输出、订阅或可操作的旧主运行记录。 +并行生产者回归分别暂停两个 command/Provider 模型请求,按两种顺序释放,检查 +两个模型都保持 ready、消费者能同时收到它们,并保留生产者自身的选项 ABA 回归。 +保存回归让旧应用的保存忽略取消,确认新任务或测试的身份和输出不受影响;停止、 +工作区、选项、源码、插件和模型失效均阻止迟到准备。原生视图回归另检查 Build 树 +发现的测试和 Debug 入口,以及缺少 generated.json 时的 Run 输出,覆盖深浅两种 +主题;其受控 Provider 数据和进程替身不代替发行包完整工作流验收。 + Agent 入口回归从真实本地 Run 配置读取选择器,用可控 Provider 准备与进程替身依次验证启动、准备中停止、重启、输出和退出状态。准备到进程的交接不得更换公开操作 ID;重启产生新 ID,旧 ID 的停止请求必须报告失效,不能取消新准备。替身不启动系统工具,这项证据不代替真实插件后端的端到端验收。 可复验的检查入口: diff --git a/docs/assets/provider-workflow-review/README.md b/docs/assets/provider-workflow-review/README.md new file mode 100644 index 0000000000..8bf65b4e8b --- /dev/null +++ b/docs/assets/provider-workflow-review/README.md @@ -0,0 +1,28 @@ +# Provider 工作流的原生视图检查 + +这些截图对应 PR #1517 的 review 补验,来自真实 SwiftUI/AppKit 视图。 +Build 侧使用真实 Rust Core 计划和受控 Provider 回复,Run 侧使用现有 RunService +和进程替身;没有启动构建工具、Provider 后端或应用子进程。 + +| 场景 | 浅色 | 深色 | +| --- | --- | --- | +| 展开前的 Build 树 | [截图](build-collapsed-light.png) | [截图](build-collapsed-dark.png) | +| 展开后的测试清单与 Debug 入口 | [截图](build-discovery-debug-light.png) | [截图](build-discovery-debug-dark.png) | +| 缺少 generated.json 时的 Provider Run 输出 | [截图](provider-run-output-light.png) | [截图](provider-run-output-dark.png) | + +Build 的前后两图是同一版本的折叠/展开状态对照,不是 preview 与 PR 的代码版本 +对照。通过共享树的原生无障碍操作展开,测试同时等待发现的用例和 Debug 入口出现, +不注入私有视图状态。本轮两处修复改变异步生命周期,没有调整界面布局。 + +复验方式: + +```bash +LITHE_BUILD_CAPTURE_DIR="$PWD/.artifacts/test-stability/provider-ui" \ + ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh \ + -- --filter 'ProviderWorkflowPresentationTests|preparedProviderOutputRendersWithoutGeneratedConfigurations' +``` + +默认桌面隔离仍创建真实窗口并截图,结束时关闭窗口、清理工作区和服务;不证明 +WindowServer 合成、跨应用焦点、真实发行包完整工作流或 Windows 已通过验收。 +本地逐项计时结果见忽略的 `.artifacts/test-stability/pr1517-review-macos.html` +和对应 JSON/JUnit:358 项测试、15 组套件通过。 diff --git a/docs/assets/provider-workflow-review/build-collapsed-dark.png b/docs/assets/provider-workflow-review/build-collapsed-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..fb49f6571b748e416c689be48da76e038f8d26a3 GIT binary patch literal 90829 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

PQQ%9);4 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 literal 0 HcmV?d00001 diff --git a/docs/assets/provider-workflow-review/build-collapsed-light.png b/docs/assets/provider-workflow-review/build-collapsed-light.png new file mode 100644 index 0000000000000000000000000000000000000000..8157479195e6b434ad4f7809367dd4c1c3ecd3b6 GIT binary patch literal 87973 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 literal 0 HcmV?d00001 diff --git a/docs/assets/provider-workflow-review/build-discovery-debug-dark.png b/docs/assets/provider-workflow-review/build-discovery-debug-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..f94df40d7c57c3c611be4312a17b5be32030cdc6 GIT binary patch literal 121007 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

wIeOW zRW_jU)31>~7@EHt;rc6N{S8rf{R+Z_152Tmt(%yPSqwYKUzjVN!kG=&S*2*-UzRPe zu>y>t)h4*HM;l zX_*1oxX|fy&{_K@P+nA?ZCZSlVO6Zr%nqgGt5#1bcdV)me7N5S3Qo@Z_U9iM(fP@3@m-5v0)8ITEmmKOKu+?K#1 z{vPkV$`L&X4X=yEUF|%|R#6kx5S|^ZL2V3H!9mvv1#$-nc_IN{=p(=7VUAsuHV$ct zhl^7DC%YO?{Yo`*Yh{a$YcomZk*^!-iS2hxG!B>f5Dz5X7|y>Ed)GkIze(C*MPu1E zSOvN6dTX9T1;X zU;OxP!Oc}1l?K*)D6MPkQ;Rx=k4MDk?BX=eX?c|`zf-W^%oJeH-6to3d&Mb6Qb}3% z2xef-nJ(nplB6UX^vm9zEB&mxl25~MU{4y@&h}NoV~-(yZC8sH9QsPU{Ow<|C5zR! zqhxHjxUM4%M8H|LkHF2;4%Y;er}Oe0>f2t{7wH(yB&)zZ1E`JTwLs)f0!cyo+K2F! zbkHC7mEIl8`ue^ZiL~J zqu=qpbl8kR68pfFm0Mp_Ky#94O#G0S5{=P{2OW;b;Y%0D*Iu$YBK> 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 VW$$$l9p40hq|V5SC!W@J{$EiZo1Xvx literal 0 HcmV?d00001 diff --git a/docs/assets/provider-workflow-review/provider-run-output-dark.png b/docs/assets/provider-workflow-review/provider-run-output-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4d5dc3440eca3432dfd71161722914b1c09ca2e5 GIT binary patch literal 79934 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