diff --git a/apps/web/tests/expected/plugin-install-cancel/preparing.expected.md b/apps/web/tests/expected/plugin-install-cancel/preparing.expected.md index 71764f56b0..c5e882e6ed 100644 --- a/apps/web/tests/expected/plugin-install-cancel/preparing.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/preparing.expected.md @@ -1,5 +1,5 @@ - dialog "正在准备安装…": - - button "返回编辑": 编辑 + - button "取消安装并返回编辑" - button "取消安装并关闭" - status: 正在准备安装… - paragraph: slow-package diff --git a/apps/web/tests/expected/plugin-install-cancel/unconfirmed.expected.md b/apps/web/tests/expected/plugin-install-cancel/unconfirmed.expected.md index 2a900d7053..63f92b9b4a 100644 --- a/apps/web/tests/expected/plugin-install-cancel/unconfirmed.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/unconfirmed.expected.md @@ -1,8 +1,8 @@ - dialog "安装状态尚未确认": - - button "返回编辑": 编辑 + - button "取消安装并返回编辑" - button "取消安装并关闭" - status: 安装状态尚未确认 - - alert: 尚未确认安装已停止,请重试取消或等待安装结果。 + - alert: 正在等待后端接收安装任务,收到确认后会自动重试取消。 - paragraph: slow-package - paragraph: 版本 1.0.0 - button "查看安装详情" diff --git a/apps/web/tests/expected/plugin-install-cancel/unknown.expected.md b/apps/web/tests/expected/plugin-install-cancel/unknown.expected.md new file mode 100644 index 0000000000..878890780b --- /dev/null +++ b/apps/web/tests/expected/plugin-install-cancel/unknown.expected.md @@ -0,0 +1,8 @@ +- dialog "未能获取安装结果": + - button "返回编辑": 编辑 + - button "关闭" + - status: 未能获取安装结果 + - paragraph: 后端当前没有此安装任务。请检查插件列表后再尝试安装。 + - paragraph: recovered-package + - paragraph: 版本 1.0.0 + - button "查看安装详情" diff --git a/apps/web/tests/plugin-install-cancel.e2e.ts b/apps/web/tests/plugin-install-cancel.e2e.ts index 51ca6055f8..9e2294051e 100644 --- a/apps/web/tests/plugin-install-cancel.e2e.ts +++ b/apps/web/tests/plugin-install-cancel.e2e.ts @@ -19,6 +19,7 @@ it('cancels installation through the UI, restores files, and offers the spec aga const browser = await chromium.launch() const delivery = Promise.withResolvers() const cancellationReply = Promise.withResolvers() + const loseActiveReply = Promise.withResolvers() try { const profile = join(scaffold.harnessHome, 'profiles', 'scaffold') const manifestPath = join(profile, 'package.json') @@ -27,7 +28,7 @@ it('cancels installation through the UI, restores files, and offers the spec aga await writeFile(lockPath, 'original lockfile\n') // Node stands in for the pnpm executable: `view` answers the check that precedes the run, // and the same installer owns and stops the real `add` child. - await writeFile(join(profile, 'view'), 'console.log(JSON.stringify({ name: "slow-package", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }))\n') + await writeFile(join(profile, 'view'), 'console.log(JSON.stringify({ name: process.argv[2], version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }))\n') await writeFile(join(profile, 'add'), ` import('node:fs').then(fs => { fs.writeFileSync('package.json', JSON.stringify({ ...JSON.parse(fs.readFileSync('package.json', 'utf8')), dependencies: { partial: '1.0.0' } })); @@ -72,7 +73,7 @@ it('cancels installation through the UI, restores files, and offers the spec aga await page.keyboard.press('Escape') await dialog.waitFor({ state: 'hidden' }) cancellationReply.resolve(undefined) - await page.getByText('尚未确认安装已停止,可查看安装任务并重试取消。', { exact: true }).waitFor() + await page.getByText('安装状态暂未确认,请查看安装任务了解详情。', { exact: true }).waitFor() await panel.getByRole('button', { name: '查看安装任务', exact: true }).click() await dialog.getByText('安装状态尚未确认', { exact: true }).waitFor() await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/unconfirmed.expected.md', import.meta.url)), @@ -84,6 +85,18 @@ it('cancels installation through the UI, restores files, and offers the spec aga expect(await readFile(manifestPath, 'utf8')).toBe(manifest) expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n') await page.unrouteAll({ behavior: 'wait' }) + // Drop the browser's response while the real Host still owns the child process. + const activeReplySettled = Promise.withResolvers() + await page.route('**/api/pluginManager/installBundle', async (route) => { + const response = route.fetch().then(() => undefined, (error: unknown) => error) + try { + await loseActiveReply.promise + await route.abort('failed') + expect(await response).toBeUndefined() + } finally { + activeReplySettled.resolve(undefined) + } + }) await panel.getByRole('button', { name: '添加插件', exact: true }).click() await dialog.getByRole('textbox').fill('slow-package') await dialog.getByRole('button', { name: '安装', exact: true }).click() @@ -91,6 +104,8 @@ it('cancels installation through the UI, restores files, and offers the spec aga await dialog.getByText('版本 1.0.0', { exact: true }).waitFor() await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click() await dialog.getByText('Waiting for package download', { exact: true }).waitFor() + loseActiveReply.resolve(undefined) + await dialog.getByRole('button', { name: '核对安装状态', exact: true }).waitFor() await dialog.getByRole('button', { name: '取消安装', exact: true }).click() // The Host's confirmation returns the dialog to the spec and says so in a toast. await dialog.getByRole('textbox').waitFor() @@ -98,17 +113,20 @@ it('cancels installation through the UI, restores files, and offers the spec aga expect(await readFile(manifestPath, 'utf8')).toBe(manifest) expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n') expect(await dialog.getByRole('textbox').inputValue()).toBe('slow-package') + await activeReplySettled.promise + await page.unrouteAll({ behavior: 'wait' }) const snapshot = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) .split(process.execPath).join('{{node}}') .split(scaffold.harnessHome).join('{{harnessHome}}') await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/cancelled.expected.md', import.meta.url)), snapshot, webSnapshotMode()) - // The second run installs a bundle the way pnpm leaves one: the dependency in the manifest and the package under node_modules. + // Successful runs leave the dependency in the manifest and the bundle under node_modules. await writeFile(join(profile, 'add'), ` import('node:fs').then(fs => { - fs.mkdirSync('node_modules/slow-package', { recursive: true }); - fs.writeFileSync('node_modules/slow-package/package.json', JSON.stringify({ name: 'slow-package', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })); - fs.writeFileSync('node_modules/slow-package/cordis.patch.yml', '[]\\n'); - fs.writeFileSync('package.json', JSON.stringify({ ...JSON.parse(fs.readFileSync('package.json', 'utf8')), dependencies: { 'slow-package': '1.0.0' } })); + const name = process.argv[2]; + fs.mkdirSync('node_modules/' + name, { recursive: true }); + fs.writeFileSync('node_modules/' + name + '/package.json', JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })); + fs.writeFileSync('node_modules/' + name + '/cordis.patch.yml', '[]\\n'); + fs.writeFileSync('package.json', JSON.stringify({ ...JSON.parse(fs.readFileSync('package.json', 'utf8')), dependencies: { ...JSON.parse(fs.readFileSync('package.json', 'utf8')).dependencies, [name]: '1.0.0' } })); console.log('Retry completed'); }); `) @@ -117,10 +135,26 @@ it('cancels installation through the UI, restores files, and offers the spec aga await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click() await dialog.getByText('Retry completed', { exact: true }).waitFor() expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'slow-package': '1.0.0' } }) + await dialog.getByRole('button', { name: '关闭', exact: true }).click() + await panel.getByRole('button', { name: '添加插件', exact: true }).click() + await dialog.getByRole('textbox').fill('recovered-package') + await page.route('**/api/pluginManager/installBundle', async (route) => { + await route.fetch() + await route.abort('failed') + }) + await dialog.getByRole('button', { name: '安装', exact: true }).click() + await dialog.getByText('未能获取安装结果', { exact: true }).waitFor() + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/unknown.expected.md', import.meta.url)), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) + expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'recovered-package': '1.0.0' } }) + await dialog.getByRole('button', { name: '返回编辑', exact: true }).click() + await dialog.getByRole('textbox').fill('another-package') + expect(await dialog.getByRole('button', { name: '安装', exact: true }).isEnabled()).toBe(true) expect(tripwire.pageErrors).toEqual([]) } finally { delivery.resolve(undefined) cancellationReply.resolve(undefined) + loseActiveReply.resolve(undefined) await browser.close() } } finally { diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 5835948324..b607f859e4 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/boot.md -boot.md: 6bceb5deb087d49dff6958d51549c7942d0e224a -boot.zh.md: c3addc59008e9f7e5812f5f2eefbc751cd731fba +boot.md: eba32e0571555ee1fabdc3b7983811b5aa21ed2f +boot.zh.md: 23e02dbd9bd8a6ca15e7a244b6c96d9f81da9055 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 6bceb5deb0..eba32e0571 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -104,6 +104,13 @@ Manage profile files and apply their declared reload lifecycle. */ @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise +/** Recover the result of an active installation without cancelling it. + * @param requestId The id supplied when installation started. + * @returns The installation's outcome after it settles, or null if no active request has that id. + * Completed results are not retained; null establishes neither success nor cancellation. + */ +@Remote async waitForInstall(requestId: PluginInstallRequestId): Promise + /** Stop an installation this manager owns and wait until its files are back. * @param requestId The id the installation was started with. * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index c3addc5900..23e02dbd9b 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -104,6 +104,13 @@ Manage profile files and apply their declared reload lifecycle. */ @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise +/** Recover the result of an active installation without cancelling it. + * @param requestId The id supplied when installation started. + * @returns The installation's outcome after it settles, or null if no active request has that id. + * Completed results are not retained; null establishes neither success nor cancellation. + */ +@Remote async waitForInstall(requestId: PluginInstallRequestId): Promise + /** Stop an installation this manager owns and wait until its files are back. * @param requestId The id the installation was started with. * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 7edec10f77..7f90171308 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md -README.md: 46b5447a8b3548b548bf844e1d753296861b17da -README.zh.md: d4c285b72f8d31357a12f222d741323e9b4658e6 +README.md: ed7045af8580c8223bc20b0efe9fedc741aa287c +README.zh.md: 5bf298f40b6133d8de8e411b688ba340952af5ff diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 46b5447a8b..ed7045af85 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -43,6 +43,8 @@ A plugin toggle updates only `disabled` in the last matching override in the pro `installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`. `cancelInstall(requestId)` stops the run and answers `cancelled` only after pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies a failed run from its exit and output, and `bundle` names the package a finished run added. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read. +`waitForInstall(requestId)` lets a client recover a lost response by waiting for the active installation, including its non-cancellable application phase. It returns the same outcome as the original call, or `null` if the request is not active. Completed results are not retained; `null` establishes neither success nor cancellation. + When pnpm 11 blocks dependency scripts, the failed installation reports every pending package name in the profile under `pendingBuilds`, including names left by earlier attempts; a failed run restores `package.json` and `pnpm-lock.yaml` but deliberately not `pnpm-workspace.yaml`, where pnpm records them. The Web plugin page offers **Allow these scripts and retry**; the tool can grant permission on the user's behalf through `approvedBuilds` on `install_bundle`, after the user approves those scripts in the conversation. The service validates pending names; it does not verify conversation approval. Approval persists by package name in this profile, permits commands with the host user's permissions, and survives another installation failure. Only currently undecided names can be approved; existing denials and wildcard rules cannot be overridden through this action. Approval rejects YAML anchors or aliases inside `allowBuilds`. Retry preserves the original activation choice. ### Configuration diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index d4c285b72f..5bf298f40b 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -43,6 +43,8 @@ kind: "package-reference" `installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`。`cancelInstall(requestId)` 停止运行,只在 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对失败运行分类,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。 +`waitForInstall(requestId)` 让客户端在响应丢失后等待活动安装的结果,包括不可取消的应用阶段。它返回与原调用相同的结果,请求不在活动中时返回 `null`。已完成的结果不保留;`null` 不表示成功或已取消。 + pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 profile 中所有待决定的包名,包括先前尝试留下的;失败的运行会恢复 `package.json` 与 `pnpm-lock.yaml`,但有意不恢复 pnpm 记录这些名字的 `pnpm-workspace.yaml`。Web 插件页提供**允许这些脚本并重试**;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代为授权。服务只校验待决定的名字,不核实对话中的批准。授权按包名保存在当前 profile,允许以宿主用户的权限执行命令,并在再次安装失败后保留。只能批准当前未决定的名字;已有的拒绝与通配规则不能通过此操作覆盖。`allowBuilds` 里出现 YAML 锚点或别名时拒绝授权。重试保留原来的启用选择。 ### 配置 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 2d9451d844..599e308013 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -85,8 +85,8 @@ interface InstallControl { readonly abort: AbortController /** `applying` once pnpm has exited and the bundle is being selected and loaded, which cannot be stopped. */ phase: 'installing' | 'applying' - /** Settlement of the install call, whichever way it ended. */ - settled: Promise + /** The active install's result, shared with clients recovering a lost response. */ + result: Promise } /** A manifest field that is a string, when the manifest carries one. */ @@ -337,7 +337,7 @@ export class PluginManager extends TypertRemoteService { @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise { const requestId = options?.requestId - const control: InstallControl = { abort: new AbortController(), phase: 'installing', settled: Promise.resolve() } + const control: InstallControl = { abort: new AbortController(), phase: 'installing', result: Promise.resolve(null) } const stopped = (): boolean => control.abort.signal.aborted if (requestId !== undefined) this.installs.set(requestId, control) const announce = (phase: PluginInstallProgress['phase']): void => { @@ -392,11 +392,20 @@ export class PluginManager extends TypertRemoteService { if (options?.enabled !== false) result.warnings = await this.reload() }) }, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install') - /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */ - control.settled = result.then(() => undefined, () => undefined) + control.result = result return result.finally(() => { if (requestId !== undefined) this.installs.delete(requestId) }) } + /** Recover the result of an active installation without cancelling it. + * @param requestId The id supplied when installation started. + * @returns The installation's outcome after it settles, or null if no active request has that id. + * Completed results are not retained; null establishes neither success nor cancellation. + */ + @Remote + async waitForInstall(requestId: PluginInstallRequestId): Promise { + return this.installs.get(requestId)?.result ?? null + } + /** Stop an installation this manager owns and wait until its files are back. * @param requestId The id the installation was started with. * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being @@ -409,7 +418,8 @@ export class PluginManager extends TypertRemoteService { if (control.phase === 'applying') return { status: 'too-late' } this.ownerContext.emit('plugin-manager/install-state', { requestId, phase: 'cancelling' }) control.abort.abort() - await control.settled + /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */ + await control.result.then(() => undefined, () => undefined) return { status: 'cancelled' } } diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 7592ab2919..6372e52f3d 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -531,10 +531,14 @@ it('stops a run on request, restores the files, and answers not-running or too-l const before = readFileSync(join(dir, 'package.json'), 'utf8') const run = manager.installBundle('slow', { requestId }) await started.promise + const recoveredCancellation = manager.waitForInstall(requestId) + expect(await manager.waitForInstall('missing' as PluginInstallRequestId)).toBeNull() expect(await manager.cancelInstall('00000000-0000-4000-8000-000000000000' as PluginInstallRequestId)).toEqual({ status: 'not-running' }) expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' }) expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) const cancelled = await run + expect(await recoveredCancellation).toEqual(cancelled) + expect(await manager.waitForInstall(requestId)).toBeNull() expect(cancelled).toMatchObject({ application: 'cancelled', changed: false, stage: 'install', packageResult: { exitCode: 1 } }) expect(cancelled.error).toBeUndefined() expect(phases).toEqual([{ requestId, phase: 'installing' }, { requestId, phase: 'cancelling' }]) @@ -549,9 +553,15 @@ it('stops a run on request, restores the files, and answers not-running or too-l return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') } }) let tooLate: Promise | undefined - ctx.on('plugin-manager/install-state', (progress) => { if (progress.phase === 'applying') tooLate = manager.cancelInstall(progress.requestId) }) + let recoveredApplication: Promise | undefined + ctx.on('plugin-manager/install-state', (progress) => { + if (progress.phase !== 'applying') return + tooLate = manager.cancelInstall(progress.requestId) + recoveredApplication = manager.waitForInstall(progress.requestId) + }) expect(await manager.installBundle('late', { requestId })).toMatchObject({ application: 'applied', bundle: 'late' }) expect(await tooLate).toEqual({ status: 'too-late' }) + expect(await recoveredApplication).toMatchObject({ application: 'applied', bundle: 'late' }) // A request aborted before its turn under the lock never starts pnpm. const early = manager.installBundle('never', { requestId }) const queued = manager.installBundle('after', { requestId: '11111111-1111-4111-8111-111111111111' as PluginInstallRequestId }) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index d667fe977d..d7e3aafb2e 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: 21a40d900839b635f09f2150142d4fb3d1ee2d63 -README.zh.md: 00cca5c393c491b08be0ec2f2fbf8b9c30c5bb80 +README.md: 10bac8b8cffa2eede2859ce0059de9d31d74c10f +README.zh.md: f25dca09b5a0ed1d112e6cc5934a352090da0bd4 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 21a40d9008..10bac8b8cf 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -33,7 +33,7 @@ The Agent Teams and Auto Authorization Review packages have localized names and **Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Not sure what to enter?** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. -During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A connection error shows **Installation status unconfirmed** and allows retrying cancellation. Hidden tasks report completion, failure, or unconfirmed cancellation through toasts without reopening the dialog. +During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog. ### Switching a bundle @@ -73,7 +73,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th ### The store -`PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them and `unconfirmed` when an installation or cancellation response is lost; checking and every active phase use ongoing, while the final screens use done or error. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`. Closing hides a run without discarding its state. A cancellation that overtakes installation is retried when progress or output acknowledges the request. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own. +`PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them and `unconfirmed` when an installation or cancellation response is lost; confirmed `applying` progress never regresses. Recovery uses `waitForInstall`, settling to `unknown` if no active request remains; checking and every active phase use ongoing, while the final screens use done or error. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`. Closing hides a run without discarding its state. A cancellation that overtakes installation is retried when progress or output acknowledges the request. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own. ### Configuration slots @@ -111,6 +111,7 @@ None; this package neither assembles nor sends a provider request. These limits define the reach of the management view; they are current package constraints. +- **Page lifetime** — refreshing the browser loses the tracked request and output. Reconnecting within the same page can recover an active request; completed results are not retained by the Host. Profile file locking serializes installation writes. - **Only bundles are managed** — a dependency without a bundle patch is refused before it installs; one the profile already holds is left off the page unless the profile selects it, and loading plain plugin modules stays a file operation. - **Rows show a phase, not a reason** — a failed row reads as failed without the Host's error text; the Host log has it. - **One install at a time** — the dialog runs one pnpm command; a second spec waits for the first to finish. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 00cca5c393..f25dca09b5 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -33,7 +33,7 @@ Agent Teams 和 Auto Authorization Review 两个包使用随界面语言切换 **添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 -准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。连接错误会显示**安装状态尚未确认**,允许重试取消。隐藏的任务通过 toast 通知完成、失败或取消尚未确认,不会重新弹出对话框。 +准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。 ### 切换一个组合包 @@ -73,7 +73,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ ### store -`PluginManagerController` 拥有组合包视图、忙碌键、提示、安装进度和卸载确认。每次读取先问清单 Host 是否管理着 profile,再把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,其行携带存活条目的启停状态与 fiber 阶段。它合并重叠读取,在操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现,安装或取消响应丢失时进入 `unconfirmed`;checking 与所有活动阶段使用 ongoing,最终页面使用 done 或 error。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `pluginManager.cancelInstall` 停止。关闭会隐藏任务而保留其状态。如果取消请求先于安装到达,在进度或输出确认该请求后会再次请求取消。Host 无法应用的变更、要等重启的变更、被更高层覆盖的变更,都是会自行消失的 toast。 +`PluginManagerController` 拥有组合包视图、忙碌键、提示、安装进度和卸载确认。每次读取先问清单 Host 是否管理着 profile,再把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,其行携带存活条目的启停状态与 fiber 阶段。它合并重叠读取,在操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现,安装或取消响应丢失时进入 `unconfirmed`;已确认的 `applying` 阶段不会倒退。通过 `waitForInstall` 恢复结果,无活动请求时结束为 `unknown`;checking 与所有活动阶段使用 ongoing,最终页面使用 done 或 error。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `pluginManager.cancelInstall` 停止。关闭会隐藏任务而保留其状态。如果取消请求先于安装到达,在进度或输出确认该请求后会再次请求取消。Host 无法应用的变更、要等重启的变更、被更高层覆盖的变更,都是会自行消失的 toast。 ### 配置 slot @@ -111,6 +111,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ 这些限制界定了管理视图的范围;它们是当前包的约束。 +- **页面生命周期**——刷新浏览器会丢失跟踪的请求与输出。同一页面内重连可以恢复活动请求;Host 不保留已完成的结果。profile 文件锁串行化安装写入。 - **只管理组合包**——没有组合包 patch 的依赖在安装前就被拒绝;profile 里已有的这类依赖不上页面,除非 profile 选中了它;加载普通插件模块仍是文件操作。 - **行只显示阶段,不显示原因**——失败的行只显示为失败,没有 Host 的错误文本;Host 日志里有。 - **一次只能安装一个**——对话框一次运行一个 pnpm 命令;第二个 spec 要等前一个完成。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index b20e4fa402..99a048905f 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -553,6 +553,7 @@ const SCREEN_TITLE_KEYS = { cancelling: 'installCancelling', applying: 'installApplying', unconfirmed: 'installUnconfirmedTitle', + unknown: 'installUnknownTitle', done: 'installedTitle', failed: 'installFailedTitle', } satisfies Record, PluginManagerLocaleKey> @@ -598,7 +599,7 @@ function SubjectCard({ subject, t }: { readonly subject: InstallSubject; readonl * install scripts undecided shows them for approval in place of plain retry. */ function InstallDialog({ - install, t, onClose, onEditSpec, onRun, onCancel, onToggleDetails, onEnableNow, onApproveBuilds, + install, t, onClose, onEditSpec, onRun, onCancel, onReconcile, onToggleDetails, onEnableNow, onApproveBuilds, }: { readonly install: InstallState readonly t: Translate @@ -606,6 +607,7 @@ function InstallDialog({ readonly onEditSpec: (text: string) => void readonly onRun: () => void readonly onCancel: () => void + readonly onReconcile: () => void readonly onToggleDetails: () => void readonly onEnableNow: () => void readonly onApproveBuilds: () => void @@ -703,8 +705,9 @@ function InstallDialog({ const heading = t(SCREEN_TITLE_KEYS[phase]) const pending = isInstallPending(phase) const cancellable = phase === 'starting' || phase === 'running' || phase === 'unconfirmed' - const stoppable = cancellable || phase === 'failed' - const unconfirmed = install.failure?.cancelUnconfirmed === true ? install.failure.reason : undefined + const stoppable = cancellable || phase === 'failed' || phase === 'unknown' + const uncertainty = install.failure?.uncertainty + const uncertaintyKey = uncertainty === undefined ? undefined : ({ result: 'installResultUnconfirmed', cancellation: phase === 'applying' ? 'installApplyingCancellationError' : 'installCancelUnconfirmed', acceptance: 'installAwaitingAcceptance' } as const)[uncertainty] const pendingBuilds = phase === 'failed' ? install.failure?.pendingBuilds ?? [] : [] const approvable = pendingBuilds.length > 0 const firstRun = install.runs[0] @@ -715,9 +718,9 @@ function InstallDialog({ {phase === 'done' ? : ( - )} + {uncertainty === 'result' ? : null} {pending ? (