fix(web): recover lost plugin installation responses

This commit is contained in:
Yichen Jiang committed 2026-09-18 19:58:04 +08:00
1 parent 6e456264a9
commit f938ebad64
22 files changed
+424 -126

No files matched your search

@@ -1,5 +1,5 @@
- dialog "正在准备安装…":
- button "返回编辑": 编辑
- button "取消安装并返回编辑"
- button "取消安装并关闭"
- status: 正在准备安装…
- paragraph: slow-package
@@ -1,8 +1,8 @@
- dialog "安装状态尚未确认":
- button "返回编辑": 编辑
- button "取消安装并返回编辑"
- button "取消安装并关闭"
- status: 安装状态尚未确认
- alert: 尚未确认安装已停止,请重试取消或等待安装结果。
- alert: 正在等待后端接收安装任务,收到确认后会自动重试取消。
- paragraph: slow-package
- paragraph: 版本 1.0.0
- button "查看安装详情"
@@ -0,0 +1,8 @@
- dialog "未能获取安装结果":
- button "返回编辑": 编辑
- button "关闭"
- status: 未能获取安装结果
- paragraph: 后端当前没有此安装任务。请检查插件列表后再尝试安装。
- paragraph: recovered-package
- paragraph: 版本 1.0.0
- button "查看安装详情"
+41 -7
View File
@@ -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<undefined>()
const cancellationReply = Promise.withResolvers<undefined>()
const loseActiveReply = Promise.withResolvers<undefined>()
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<undefined>()
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 {
+2 -2
View File
@@ -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
+7
View File
@@ -104,6 +104,13 @@ Manage profile files and apply their declared reload lifecycle.
*/
@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>
/** 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<ChangeResult | 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
+7
View File
@@ -104,6 +104,13 @@ Manage profile files and apply their declared reload lifecycle.
*/
@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>
/** 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<ChangeResult | 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
@@ -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
+2
View File
@@ -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
@@ -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 锚点或别名时拒绝授权。重试保留原来的启用选择。
### 配置
+16 -6
View File
@@ -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<void>
/** The active install's result, shared with clients recovering a lost response. */
result: Promise<ChangeResult | null>
}
/** 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<ChangeResult> {
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<ChangeResult | null> {
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' }
}
@@ -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<unknown> | undefined
ctx.on('plugin-manager/install-state', (progress) => { if (progress.phase === 'applying') tooLate = manager.cancelInstall(progress.requestId) })
let recoveredApplication: Promise<unknown> | 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 })
@@ -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
+3 -2
View File
@@ -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.
@@ -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 要等前一个完成。
@@ -553,6 +553,7 @@ const SCREEN_TITLE_KEYS = {
cancelling: 'installCancelling',
applying: 'installApplying',
unconfirmed: 'installUnconfirmedTitle',
unknown: 'installUnknownTitle',
done: 'installedTitle',
failed: 'installFailedTitle',
} satisfies Record<Exclude<InstallState['phase'], 'idle' | 'checking'>, 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'
? <span />
: (
<button type="button" className={css.wizardBack} aria-label={t('installEditAria')} disabled={!stoppable} onClick={onCancel}>
<button type="button" className={css.wizardBack} aria-label={t(pending ? 'installCancelAndEdit' : 'installEditAria')} disabled={!stoppable} onClick={onCancel}>
<IconChevronLeftOutlineRegular aria-hidden="true" />
<span>{t('installEdit')}</span>
<span>{t(pending ? 'installCancelAndEdit' : 'installEdit')}</span>
</button>
)}
<button
@@ -736,7 +739,8 @@ function InstallDialog({
</span>
<h2 className={css.wizardTitle} role={phase === 'failed' ? 'alert' : 'status'}>{heading}</h2>
{phase === 'failed' ? <p className={css.wizardSub}>{failureText(install.failure, t)}</p> : null}
{unconfirmed === undefined ? null : <p className={css.wizardSub} role="alert">{t('installCancelUnconfirmed', { reason: unconfirmed })}</p>}
{uncertaintyKey === undefined ? null : <p className={css.wizardSub} role="alert">{t(uncertaintyKey, { reason: install.failure?.reason ?? '' })}</p>}
{phase === 'unknown' ? <p className={css.wizardSub}>{t('installUnknownDescription')}</p> : null}
</div>
{install.subject === null ? null : <SubjectCard subject={install.subject} t={t} />}
{approvable
@@ -767,6 +771,7 @@ function InstallDialog({
<span>{t(install.detailsOpen ? 'installDetailsHide' : 'installDetailsShow')}</span>
<IconChevronDownOutlineRegular className={css.detailsChevron} aria-hidden="true" />
</button>
{uncertainty === 'result' ? <Button variant="outline" size="sm" onClick={onReconcile}>{t('installReconcile')}</Button> : null}
{pending
? (
<Button variant="outline" size="sm" disabled={!cancellable} onClick={onCancel}>
@@ -1002,6 +1007,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
onEditSpec={props.editInstallSpec}
onRun={props.runInstall}
onCancel={props.cancelInstall}
onReconcile={props.reconcileInstall}
onToggleDetails={props.toggleInstallDetails}
onEnableNow={props.enableInstalled}
onApproveBuilds={props.approveBuildsAndRetry}
@@ -84,13 +84,21 @@ export const zh = {
installFailedTitle: '插件安装失败',
installEdit: '编辑',
installEditAria: '返回编辑',
installCancelAndEdit: '取消安装并返回编辑',
installApplyingCancellationError: '取消请求未得到确认;安装已进入收尾阶段,请等待结果。{reason}',
installReconcile: '核对安装状态',
installUnknownTitle: '未能获取安装结果',
installUnknownDescription: '后端当前没有此安装任务。请检查插件列表后再尝试安装。',
installResultUnconfirmed: '未收到安装结果,请核对安装状态。{reason}',
installAwaitingAcceptance: '正在等待后端接收安装任务,收到确认后会自动重试取消。',
installBackgroundUnknown: '未能获取安装结果,请检查插件列表。',
installCancel: '取消安装',
installCloseCancels: '取消安装并关闭',
installViewTask: '查看安装任务',
installUnconfirmedTitle: '安装状态尚未确认',
installBackgroundDone: '插件安装已完成,可查看安装结果。',
installBackgroundFailed: '插件安装失败,可查看安装详情。',
installBackgroundUnconfirmed: '尚未确认安装已停止,可查看安装任务并重试取消。',
installBackgroundUnconfirmed: '安装状态暂未确认,请查看安装任务了解详情。',
installBackgroundApplying: '安装已进入收尾阶段,无法取消,可查看安装进度。',
installStarting: '正在准备安装…',
installCancelling: '正在停止安装…',
@@ -250,13 +258,21 @@ export const en = {
installFailedTitle: 'The plugin could not be installed',
installEdit: 'Edit',
installEditAria: 'Back to editing',
installCancelAndEdit: 'Cancel installation and return to editing',
installApplyingCancellationError: 'Cancellation was not confirmed. Installation is being applied; wait for its result. {reason}',
installReconcile: 'Check installation status',
installUnknownTitle: 'Installation result unavailable',
installUnknownDescription: 'The Host has no active installation with this request id. Check the plugin list before trying again.',
installResultUnconfirmed: 'The installation result was not received. Check installation status. {reason}',
installAwaitingAcceptance: 'Waiting for the Host to accept installation. Cancellation will retry automatically after confirmation.',
installBackgroundUnknown: 'Installation result unavailable. Check the plugin list.',
installCancel: 'Cancel install',
installCloseCancels: 'Cancel install and close',
installViewTask: 'View installation',
installUnconfirmedTitle: 'Installation status unconfirmed',
installBackgroundDone: 'Installation finished. View installation details.',
installBackgroundFailed: 'Installation failed. View installation details.',
installBackgroundUnconfirmed: 'Installation has not been confirmed stopped. View the installation to retry cancellation.',
installBackgroundUnconfirmed: 'Installation status is unconfirmed. View the installation for details.',
installBackgroundApplying: 'Installation is being applied and cannot be cancelled. View installation progress.',
installStarting: 'Preparing installation…',
installCancelling: 'Stopping installation…',
@@ -35,7 +35,7 @@ export type ManagerNotice =
| { readonly kind: 'restart'; readonly packageName: string; readonly seq: number }
| { readonly kind: 'overridden'; readonly packageName: string; readonly seq: number }
| { readonly kind: 'cancelled'; readonly seq: number }
| { readonly kind: 'install'; readonly outcome: 'done' | 'failed' | 'unconfirmed' | 'applying'; readonly seq: number }
| { readonly kind: 'install'; readonly outcome: 'done' | 'failed' | 'unconfirmed' | 'applying' | 'unknown'; readonly seq: number }
| {
readonly kind: 'failed'
/** What was being done when it failed. */
@@ -105,20 +105,17 @@ export interface InstallRun {
}
/**
* The install dialog: the spec is typed while `idle`, read by the Host while
* `checking`, then installed through the Host-owned phases — `starting` until
* the Host acknowledges the run, `running`, `cancelling` while the Host stops
* it, `applying` once the bundle is being loaded, which closed the cancellation
* window — and the outcome shown as `done` or `failed`. A lost response leaves
* `unconfirmed`, with cancellation and closing available. Closing preserves the
* Host-owned operation. A refused check, a
* confirmed cancellation, and the back control return to `idle` with the spec kept.
* Installation input, Host progress, and the final outcome. `unconfirmed` keeps
* an unresolved request pending; `unknown` releases it after the Host reports
* no active request and its original reply is lost. Neither means cancellation.
* Closing any pending phase preserves its request and output for reopening.
* Confirmed cancellation and returning from an outcome keep the spec for editing.
*/
export interface InstallState {
readonly open: boolean
/** The package spec as typed. */
readonly spec: string
readonly phase: 'idle' | 'checking' | 'starting' | 'running' | 'cancelling' | 'applying' | 'unconfirmed' | 'done' | 'failed'
readonly phase: 'idle' | 'checking' | 'starting' | 'running' | 'cancelling' | 'applying' | 'unconfirmed' | 'unknown' | 'done' | 'failed'
/** Identifies this dialog's installation, including log and cancellation messages. */
readonly requestId?: PluginInstallRequestId
/** Why the spec was refused before installing; shown under the field. */
@@ -138,15 +135,15 @@ export interface InstallState {
* `code` with its diagnostic as `reason`, or the transport's words alone;
* `kind` classifies a pnpm failure, and `pendingBuilds` names the packages
* whose install scripts pnpm left undecided, offered for approval.
* `cancelUnconfirmed` is a stop the Host did not confirm, shown over the
* running screen while the run goes on.
* `uncertainty` distinguishes a lost installation reply, an unconfirmed
* cancellation, and a cancellation awaiting the Host's acceptance of the run.
*/
readonly failure: {
readonly reason: string
readonly code?: ManagementError['code']
readonly kind?: PluginInstallFailureKind
readonly pendingBuilds?: readonly string[]
readonly cancelUnconfirmed?: true
readonly uncertainty?: 'result' | 'cancellation' | 'acceptance'
} | null
/** The packages whose install scripts the finished run was allowed to execute, saved for this profile. */
readonly approvedBuilds: readonly string[]
@@ -157,7 +154,7 @@ export interface InstallState {
/**
* Whether an installation is still owned by the Host.
* @param phase - the dialog's current installation phase.
* @returns true until an authoritative result settles the installation.
* @returns true while a Host result or a check for an active request is outstanding.
*/
export function isInstallPending(phase: InstallState['phase']): boolean {
return phase === 'starting' || phase === 'running' || phase === 'cancelling' || phase === 'applying' || phase === 'unconfirmed'
@@ -205,6 +202,8 @@ export interface PluginManagerFace {
approveBuildsAndRetry: () => void
/** Leave the check or the failed screen for the spec, or ask the Host to stop the run and wait for its cleanup. */
cancelInstall: () => void
/** Ask the Host for the result of an installation whose original reply was lost. */
reconcileInstall: () => void
toggleInstallDetails: () => void
/** Enable the bundle the finished install added, then close the dialog and mark it in the list. */
enableInstalled: () => void
@@ -312,11 +311,13 @@ const IDLE_INSTALL: InstallState = {
installed: null, restartRequired: false, failure: null, approvedBuilds: [], enabling: false,
}
/** Cancellation stays owned until the Host accepts the install or confirms its outcome. */
interface InstallCancellation {
/** One installation owns acceptance, response recovery, and every cancellation attempt. */
interface InstallRequest {
readonly requestId: PluginInstallRequestId
acknowledged: boolean
waitingForStart: boolean
replyLost: boolean
recovering: boolean
cancellation?: { waitingForStart: boolean } | undefined
}
/** Reads and mutates the profile's plugins through the `pluginManager` Remote. */
@@ -329,7 +330,7 @@ export class PluginManagerController {
private pendingConfirm: (() => Promise<void>) | undefined
/** Cancels the check the dialog has in flight. */
private inspectAbort: AbortController | undefined
private cancellation: InstallCancellation | undefined
private request: InstallRequest | undefined
private noticeSeq = 0
/**
@@ -355,6 +356,7 @@ export class PluginManagerController {
/** Stop publishing and drop every late settlement. */
dispose(): void {
this.disposed = true
this.request = undefined
this.generation += 1
}
@@ -371,6 +373,7 @@ export class PluginManagerController {
openInstall: () => {
const install = this.getSnapshot().install
this.patch({ install: { ...(install.requestId === undefined ? IDLE_INSTALL : install), open: true } })
void this.reconcileInstall()
},
closeInstall: () => {
if (isInstallPending(this.getSnapshot().install.phase)) {
@@ -390,6 +393,7 @@ export class PluginManagerController {
runInstall: () => { void this.runInstall() },
approveBuildsAndRetry: () => { void this.approveBuildsAndRetry() },
cancelInstall: () => { void this.cancelInstall() },
reconcileInstall: () => { void this.reconcileInstall() },
toggleInstallDetails: () => { this.patchInstall({ detailsOpen: !this.getSnapshot().install.detailsOpen }) },
enableInstalled: () => { void this.enableInstalled() },
clearHighlight: () => { if (this.getSnapshot().highlight !== null) this.patch({ highlight: null }) },
@@ -422,16 +426,15 @@ export class PluginManagerController {
installProgress(progress: PluginInstallProgress): void {
const install = this.getSnapshot().install
if (install.requestId !== progress.requestId || !isInstallPending(install.phase)) return
const cancellation = this.cancellation
if (progress.phase === 'installing' && cancellation?.requestId === progress.requestId) {
cancellation.acknowledged = true
if (cancellation.waitingForStart) {
void this.sendCancellation(cancellation)
return
}
const request = this.request
if (request === undefined) return
request.acknowledged = true
// Host progress cannot reopen a cancellation window that has already closed.
if (install.phase === 'applying') return
if (progress.phase === 'installing' && request.cancellation !== undefined) {
if (request.cancellation.waitingForStart) void this.sendCancellation(request, request.cancellation)
return
}
// A queued start notification cannot undo the local user's cancellation request.
if (install.phase === 'cancelling' && progress.phase === 'installing') return
this.patchInstall({ phase: progress.phase === 'installing' ? 'running' : progress.phase })
}
@@ -463,6 +466,7 @@ export class PluginManagerController {
*/
load(): Promise<void> {
if (this.disposed) return Promise.resolve()
void this.reconcileInstall()
if (this.inFlight !== undefined) {
this.rerun = true
return this.inFlight
@@ -575,7 +579,8 @@ export class PluginManagerController {
private async startInstall(subject: InstallSubject, approvedBuilds?: readonly string[]): Promise<void> {
const { spec } = subject
const requestId = randomUUID() as PluginInstallRequestId
this.cancellation = undefined
const request: InstallRequest = { requestId, acknowledged: false, replyLost: false, recovering: false }
this.request = request
this.patchInstall({ phase: 'starting', requestId, subject, runs: [], failure: null, installed: null, approvedBuilds: [] })
// The Host announces `plugin-manager/changed` while the run is still on
// the wire, and every such event reads again; those reads must not cancel
@@ -583,38 +588,70 @@ export class PluginManagerController {
const result = await this.ctx.remote.pluginManager.installBundle(spec, {
enabled: false, requestId, ...approvedBuilds === undefined ? {} : { approvedBuilds: [...approvedBuilds] },
})
if (this.disposed || this.getSnapshot().install.requestId !== requestId) return
const runs = this.getSnapshot().install.runs
if (this.disposed || this.request !== request) return
if (!result.ok) {
// A failed transport does not establish whether the Host stopped the installation.
this.patchInstall({ phase: 'unconfirmed', failure: { reason: result.error.message, cancelUnconfirmed: true } })
this.notifyHiddenInstall('unconfirmed')
request.replyLost = true
this.installUncertain('result', result.error.message)
void this.reconcileInstall()
return
} else if (result.value.application === 'cancelled') {
}
this.settleInstall(result.value)
}
/** A recovery call shares the Host's active result; absent results are explicitly unknown. */
private async reconcileInstall(): Promise<void> {
const request = this.request
if (request === undefined || !request.replyLost || request.recovering) return
request.recovering = true
const result = await this.ctx.remote.pluginManager.waitForInstall(request.requestId)
if (this.disposed || this.request !== request) return
request.recovering = false
if (!result.ok) {
this.installUncertain('result', result.error.message)
return
}
if (result.value !== null) {
this.settleInstall(result.value)
return
}
this.request = undefined
this.patchInstall({ phase: 'unknown', failure: null, runs: settledRuns(this.getSnapshot().install.runs, null) })
this.notifyHiddenInstall('unknown')
void this.load()
}
private settleInstall(result: ChangeResult): void {
const runs = this.getSnapshot().install.runs
this.request = undefined
if (result.application === 'cancelled') {
this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq })
} else if (result.value.application === 'failed') {
// A run whose last chunk never reached the dialog settles from the answer.
const packages = result.value.packageResult
} else if (result.application === 'failed') {
const packages = result.packageResult
this.patchInstall({
phase: 'failed',
runs: settledRuns(runs, packages?.exitCode ?? null),
failure: failureOf(result.value.error, packages?.kind, result.value.pendingBuilds),
phase: 'failed', runs: settledRuns(runs, packages?.exitCode ?? null),
failure: failureOf(result.error, packages?.kind, result.pendingBuilds),
})
} else {
this.patchInstall({
phase: 'done',
runs: settledRuns(runs, 0),
installed: result.value.bundle ?? null,
restartRequired: result.value.application === 'restart-required',
approvedBuilds: result.value.approvedBuilds ?? [],
phase: 'done', runs: settledRuns(runs, 0), failure: null,
installed: result.bundle ?? null,
restartRequired: result.application === 'restart-required',
approvedBuilds: result.approvedBuilds ?? [],
})
}
this.cancellation = undefined
const phase = this.getSnapshot().install.phase
if (phase === 'done' || phase === 'failed') this.notifyHiddenInstall(phase)
void this.load()
}
private installUncertain(uncertainty: NonNullable<InstallState['failure']>['uncertainty'] & string, reason: string): void {
this.patchInstall({
phase: this.getSnapshot().install.phase === 'applying' ? 'applying' : 'unconfirmed',
failure: { reason, uncertainty },
})
this.notifyHiddenInstall('unconfirmed')
}
/**
* Allow the install scripts the failed run left pending and run the same
* spec again. Only the failed screen with pending names offers this.
@@ -633,55 +670,55 @@ export class PluginManagerController {
*/
private async cancelInstall(): Promise<void> {
const install = this.getSnapshot().install
if (install.phase === 'checking' || install.phase === 'failed') {
if (install.phase === 'checking' || install.phase === 'failed' || install.phase === 'unknown') {
this.abortInspect()
this.offerSpecAgain()
return
}
if ((install.phase !== 'starting' && install.phase !== 'running' && install.phase !== 'unconfirmed') || install.requestId === undefined) return
if (this.cancellation !== undefined && !this.cancellation.waitingForStart) return
const cancellation: InstallCancellation = {
requestId: install.requestId, acknowledged: install.phase === 'running', waitingForStart: false,
}
this.cancellation = cancellation
await this.sendCancellation(cancellation)
const request = this.request
if ((install.phase !== 'starting' && install.phase !== 'running' && install.phase !== 'unconfirmed') || request === undefined) return
if (request.cancellation !== undefined && !request.cancellation.waitingForStart) return
const cancellation = { waitingForStart: false }
request.cancellation = cancellation
await this.sendCancellation(request, cancellation)
}
/** A cancellation that overtakes installation is retried after the Host acknowledges that request. */
private async sendCancellation(cancellation: InstallCancellation): Promise<void> {
const { requestId, acknowledged } = cancellation
private async sendCancellation(request: InstallRequest, cancellation: NonNullable<InstallRequest['cancellation']>): Promise<void> {
const acknowledged = request.acknowledged
cancellation.waitingForStart = false
this.patchInstall({ phase: 'cancelling', failure: null })
const result = await this.ctx.remote.pluginManager.cancelInstall(requestId)
const current = this.getSnapshot().install
if (this.disposed || this.cancellation !== cancellation || current.requestId !== requestId || !isInstallPending(current.phase)) return
const result = await this.ctx.remote.pluginManager.cancelInstall(request.requestId)
if (this.disposed || this.request !== request || request.cancellation !== cancellation) return
if (!result.ok) {
cancellation.waitingForStart = !cancellation.acknowledged
if (cancellation.acknowledged) this.cancellation = undefined
this.patchInstall({ phase: 'unconfirmed', failure: { reason: result.error.message, cancelUnconfirmed: true } })
this.notifyHiddenInstall('unconfirmed')
cancellation.waitingForStart = !request.acknowledged
if (request.acknowledged) request.cancellation = undefined
this.installUncertain(request.replyLost ? 'result' : 'cancellation', result.error.message)
return
}
if (result.value.status === 'cancelled') {
const notice: ManagerNotice = { kind: 'cancelled', seq: ++this.noticeSeq }
this.offerSpecAgain(notice)
this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq })
void this.load()
} else if (result.value.status === 'too-late') {
this.cancellation = undefined
request.acknowledged = true
request.cancellation = undefined
this.patchInstall({ phase: 'applying' })
this.notifyHiddenInstall('applying')
} else if (!acknowledged) {
if (cancellation.acknowledged) {
await this.sendCancellation(cancellation)
void this.reconcileInstall()
} else if (request.replyLost) {
request.cancellation = undefined
this.installUncertain('result', '')
void this.reconcileInstall()
} else if (!acknowledged && this.getSnapshot().install.phase !== 'applying') {
if (request.acknowledged) {
await this.sendCancellation(request, cancellation)
} else {
cancellation.waitingForStart = true
this.patchInstall({ phase: 'unconfirmed', failure: { reason: '', cancelUnconfirmed: true } })
this.notifyHiddenInstall('unconfirmed')
this.installUncertain('acceptance', '')
}
} else {
this.cancellation = undefined
this.patchInstall({ phase: 'unconfirmed', failure: { reason: '', cancelUnconfirmed: true } })
this.notifyHiddenInstall('unconfirmed')
request.cancellation = undefined
this.installUncertain('cancellation', '')
}
}
@@ -690,12 +727,11 @@ export class PluginManagerController {
}
/**
* Back to the spec: the check, the run, and its request are forgotten and
* the spec is kept, with a toast when there is something to say — the Host
* stopped the run.
* Release the tracked request and return to editing with its spec retained.
* A cancellation notice is supplied only after the Host confirms it stopped.
*/
private offerSpecAgain(notice: ManagerNotice | null = null): void {
this.cancellation = undefined
this.request = undefined
const { open, spec } = this.getSnapshot().install
this.patch({ install: { ...IDLE_INSTALL, open, spec }, ...notice === null ? {} : { notice } })
}
@@ -92,7 +92,7 @@ export function noticeText(notice: ManagerNotice, t: Translate): string {
case 'cancelled': return t('installCancelled')
case 'install': return t(({
done: 'installBackgroundDone', failed: 'installBackgroundFailed',
unconfirmed: 'installBackgroundUnconfirmed', applying: 'installBackgroundApplying',
unconfirmed: 'installBackgroundUnconfirmed', applying: 'installBackgroundApplying', unknown: 'installBackgroundUnknown',
} as const)[notice.outcome])
case 'failed': {
const reason = notice.code === undefined ? notice.reason : managementText({ code: notice.code, diagnostic: notice.reason }, t)
@@ -68,6 +68,7 @@ function renderTab(state: Partial<PluginManagerState> = {}, config: Partial<Conf
editInstallSpec: vi.fn(),
runInstall: vi.fn(),
cancelInstall: vi.fn(),
reconcileInstall: vi.fn(),
toggleInstallDetails: vi.fn(),
approveBuildsAndRetry: vi.fn(),
enableInstalled: vi.fn(),
@@ -500,7 +501,7 @@ describe('PluginManagerPage', () => {
expect(screen.getByText(en.terminalNoOutput)).toBeTruthy()
// Cancel and the back control each ask the Host to stop the run; the close control asks too, and closes once the Host confirms.
fireEvent.click(screen.getByRole('button', { name: en.installCancel }))
fireEvent.click(screen.getByRole('button', { name: en.installEditAria }))
fireEvent.click(screen.getByRole('button', { name: en.installCancelAndEdit }))
expect(actions.cancelInstall).toHaveBeenCalledTimes(2)
expect(screen.queryByRole('button', { name: en.close })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: en.installCloseCancels }))
@@ -512,12 +513,12 @@ describe('PluginManagerPage', () => {
const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'starting', subject } })
expect(screen.getByRole('status').textContent).toBe(en.installStarting)
expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', false)
expect(screen.getByRole('button', { name: en.installEditAria })).toHaveProperty('disabled', false)
expect(screen.getByRole('button', { name: en.installCancelAndEdit })).toHaveProperty('disabled', false)
fireEvent.click(screen.getByRole('button', { name: en.installCloseCancels }))
expect(actions.closeInstall).toHaveBeenCalledOnce()
set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'running', subject } })
fireEvent.click(screen.getByRole('button', { name: en.installCancel }))
fireEvent.click(screen.getByRole('button', { name: en.installEditAria }))
fireEvent.click(screen.getByRole('button', { name: en.installCancelAndEdit }))
expect(actions.cancelInstall).toHaveBeenCalledTimes(2)
// While the Host stops the run the terminal reads as cancelled rather than failed.
set({
@@ -535,7 +536,7 @@ describe('PluginManagerPage', () => {
expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: en.close })).toHaveProperty('disabled', false)
// A stop the Host could not confirm says so over the running screen.
set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'running', subject, failure: { reason: 'offline', cancelUnconfirmed: true } } })
set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'running', subject, failure: { reason: 'offline', uncertainty: 'cancellation' } } })
expect(screen.getByRole('alert').textContent).toContain('offline')
expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', false)
})
@@ -728,9 +729,9 @@ describe('PluginManagerPage', () => {
expect(screen.getByRole('alert').textContent).toContain(en.failedDisable.replace('{reason}', en.reasonOperationError))
set({ notice: { kind: 'failed', action: 'rowEnable', reason: 'x', packageName: 'pkg-1', seq: 8 } })
expect(screen.getByRole('alert').textContent).toContain(en.failedRowEnable.replace('{reason}', 'x'))
for (const [index, outcome] of (['done', 'failed', 'unconfirmed', 'applying'] as const).entries()) {
for (const [index, outcome] of (['done', 'failed', 'unconfirmed', 'applying', 'unknown'] as const).entries()) {
set({ notice: { kind: 'install', outcome, seq: 9 + index } })
const key = ({ done: 'installBackgroundDone', failed: 'installBackgroundFailed', unconfirmed: 'installBackgroundUnconfirmed', applying: 'installBackgroundApplying' } as const)[outcome]
const key = ({ done: 'installBackgroundDone', failed: 'installBackgroundFailed', unconfirmed: 'installBackgroundUnconfirmed', applying: 'installBackgroundApplying', unknown: 'installBackgroundUnknown' } as const)[outcome]
expect(screen.getByRole('alert').textContent).toContain(en[key])
}
// No button to press: the toast retires on its own and the store forgets it.
@@ -744,10 +745,34 @@ describe('PluginManagerPage', () => {
}
})
it('distinguishes lost results, pending acceptance, and unknown outcomes in both languages', () => {
const { actions, set, setLanguage } = renderTab()
for (const dict of [en, zh]) {
setLanguage(dict)
set({ install: { ...IDLE_INSTALL, open: true, phase: 'unconfirmed', failure: { reason: 'offline', uncertainty: 'result' } } })
expect(screen.getByRole('alert').textContent).toBe(dict.installResultUnconfirmed.replace('{reason}', 'offline'))
fireEvent.click(screen.getByRole('button', { name: dict.installReconcile }))
expect(screen.getByRole('button', { name: dict.installCancelAndEdit }).textContent).toBe(dict.installCancelAndEdit)
set({ install: { ...IDLE_INSTALL, open: true, phase: 'unconfirmed', failure: { reason: '', uncertainty: 'acceptance' } } })
expect(screen.getByRole('alert').textContent).toBe(dict.installAwaitingAcceptance)
expect(screen.queryByRole('button', { name: dict.installReconcile })).toBeNull()
set({ install: { ...IDLE_INSTALL, open: true, phase: 'applying', failure: { reason: 'offline', uncertainty: 'cancellation' } } })
expect(screen.getByRole('alert').textContent).toBe(dict.installApplyingCancellationError.replace('{reason}', 'offline'))
expect(screen.getByRole('button', { name: dict.installCancel })).toHaveProperty('disabled', true)
set({ install: { ...IDLE_INSTALL, open: true, phase: 'unknown' } })
expect(screen.getByRole('status').textContent).toBe(dict.installUnknownTitle)
expect(screen.getByText(dict.installUnknownDescription)).toBeTruthy()
expect(screen.queryByRole('button', { name: dict.installCancel })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: dict.installEditAria }))
}
expect(actions.reconcileInstall).toHaveBeenCalledTimes(2)
expect(actions.cancelInstall).toHaveBeenCalledTimes(2)
})
it('offers the retained installation and keeps its uncertain state cancellable', () => {
const { actions } = renderTab({ install: {
...IDLE_INSTALL, open: true, phase: 'unconfirmed', requestId: 'pending-install' as PluginInstallRequestId,
failure: { reason: 'offline', cancelUnconfirmed: true },
failure: { reason: 'offline', uncertainty: 'cancellation' },
} })
fireEvent.click(screen.getByRole('button', { name: en.installViewTask }))
expect(actions.openInstall).toHaveBeenCalledOnce()
@@ -70,6 +70,7 @@ function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}
listPlugins: vi.fn(() => Promise.resolve(ok(PLUGINS))),
inspect: vi.fn(() => Promise.resolve(ok(INSPECTED))),
installBundle: vi.fn(() => Promise.resolve(ok({ ...APPLIED, bundle: 'dsh-new' }))),
waitForInstall: vi.fn(() => Promise.resolve(refused('gateway/internal', 'offline'))),
cancelInstall: vi.fn(() => Promise.resolve(ok({ status: 'cancelled' }))),
removeBundle: vi.fn(() => Promise.resolve(ok(APPLIED))),
setBundleEnabled: vi.fn(() => Promise.resolve(ok(APPLIED))),
@@ -153,7 +154,7 @@ describe('PluginManagerController', () => {
if (order === 'before') controller.installProgress({ requestId, phase: 'installing' })
firstCancel.resolve(ok({ status: 'not-running' }))
if (order === 'after') {
await vi.waitFor(() => { expect(state().install.failure?.cancelUnconfirmed).toBe(true) })
await vi.waitFor(() => { expect(state().install.failure?.uncertainty).toBe('acceptance') })
controller.installProgress({ requestId, phase: 'installing' })
}
await vi.waitFor(() => { expect(plugins.cancelInstall).toHaveBeenCalledTimes(2) })
@@ -485,7 +486,7 @@ describe('PluginManagerController', () => {
expect(plugins.cancelInstall).toHaveBeenCalledOnce()
// A stop the Host did not confirm says so over the running screen, with the transport's words when it has them.
expect(state().install.failure).toEqual(
status === 'too-late' ? null : { reason: status === 'offline' ? 'offline' : '', cancelUnconfirmed: true },
status === 'too-late' ? null : { reason: status === 'offline' ? 'offline' : '', uncertainty: 'cancellation' },
)
// The Host's own word that it stopped the run still ends it.
pending.resolve(ok({ ...failed(), application: 'cancelled' }))
@@ -555,7 +556,7 @@ describe('PluginManagerController', () => {
face.runInstall()
const requestId = await started()
face.closeInstall()
await vi.waitFor(() => { expect(state().install.failure?.cancelUnconfirmed).toBe(true) })
await vi.waitFor(() => { expect(state().install.failure?.uncertainty).toBe('acceptance') })
controller.appendLog({ requestId, jobId: 'j1', argv: ['pnpm', 'add', 'slow'], cwd: '/p', stream: 'stdout', text: 'Waiting for download' })
expect(plugins.cancelInstall).toHaveBeenCalledTimes(2)
expect(state().install).toMatchObject({ open: false, phase: 'cancelling', runs: [{ output: 'Waiting for download' }] })
@@ -607,6 +608,129 @@ describe('PluginManagerController', () => {
expect(state().install.open).toBe(true)
})
it.each(['not-running', 'too-late'] as const)('recovers a lost install reply after cancellation answers %s', async (status) => {
const original = deferred<ReturnType<typeof refused>>()
const recovery = deferred<ReturnType<typeof ok<ChangeResult | null>>>()
const { face, state, plugins, started } = bench({
installBundle: vi.fn().mockReturnValueOnce(original.promise).mockResolvedValue(ok(APPLIED)),
cancelInstall: vi.fn().mockResolvedValue(ok({ status })),
waitForInstall: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')).mockReturnValue(recovery.promise),
})
face.openInstall()
face.editInstallSpec('git+https://example.test/plugin')
face.runInstall()
const requestId = await started()
original.resolve(refused('gateway/internal', 'lost reply'))
await vi.waitFor(() => { expect(state().install.failure?.reason).toBe('offline') })
face.closeInstall()
await vi.waitFor(() => { expect(plugins.waitForInstall).toHaveBeenCalledTimes(2) })
expect(plugins.waitForInstall).toHaveBeenLastCalledWith(requestId)
expect(state().install.phase).toBe(status === 'too-late' ? 'applying' : 'unconfirmed')
recovery.resolve(ok(status === 'too-late' ? { ...APPLIED, bundle: 'recovered' } : null))
const phase = status === 'too-late' ? 'done' : 'unknown'
await vi.waitFor(() => { expect(state().install.phase).toBe(phase) })
expect(state().install.open).toBe(false)
expect(state().notice).toMatchObject({ kind: 'install', outcome: phase })
await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalled() })
face.openInstall()
expect(state().install.phase).toBe(phase)
if (status === 'not-running') {
face.cancelInstall()
expect(state().install).toMatchObject({ phase: 'idle', spec: 'git+https://example.test/plugin' })
}
face.editInstallSpec('another')
face.runInstall()
await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalledTimes(2) })
})
it('keeps Host acceptance across cancellation retries', async () => {
const original = deferred<ReturnType<typeof ok<ChangeResult>>>()
const { face, state, controller, plugins, started } = bench({
installBundle: vi.fn().mockReturnValue(original.promise),
cancelInstall: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')).mockResolvedValue(ok({ status: 'not-running' })),
})
face.openInstall()
face.editInstallSpec('slow')
face.runInstall()
const requestId = await started()
controller.installProgress({ requestId, phase: 'installing' })
face.cancelInstall()
await vi.waitFor(() => { expect(state().install.failure?.reason).toBe('offline') })
face.cancelInstall()
await vi.waitFor(() => { expect(state().install.failure).toEqual({ reason: '', uncertainty: 'cancellation' }) })
controller.installProgress({ requestId, phase: 'installing' })
expect(plugins.cancelInstall).toHaveBeenCalledTimes(2)
original.resolve(ok(APPLIED))
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
})
it.each(['offline', 'not-running'] as const)('preserves applying when a late cancellation answers %s', async (status) => {
const original = deferred<ReturnType<typeof refused>>()
const cancellation = deferred<ReturnType<typeof refused> | ReturnType<typeof ok<{ status: 'not-running' }>>>()
const { face, state, controller, started } = bench({
installBundle: vi.fn().mockReturnValue(original.promise),
cancelInstall: vi.fn().mockReturnValue(cancellation.promise),
})
face.openInstall()
face.editInstallSpec('slow')
face.runInstall()
const requestId = await started()
face.cancelInstall()
controller.installProgress({ requestId, phase: 'applying' })
cancellation.resolve(status === 'offline' ? refused('gateway/internal', 'offline') : ok({ status }))
await vi.waitFor(() => { expect(state().install.failure?.uncertainty).toBe('cancellation') })
expect(state().install.phase).toBe('applying')
controller.installProgress({ requestId, phase: 'installing' })
expect(state().install.phase).toBe('applying')
original.resolve(refused('gateway/internal', 'offline'))
await vi.waitFor(() => { expect(state().install.failure?.uncertainty).toBe('result') })
expect(state().install.phase).toBe('applying')
controller.dispose()
})
it('can retry recovery after both installation and cancellation replies are lost', async () => {
const original = deferred<ReturnType<typeof refused>>()
const cancellation = deferred<ReturnType<typeof refused>>()
const { face, state, plugins, controller, started } = bench({
installBundle: vi.fn().mockReturnValue(original.promise),
cancelInstall: vi.fn().mockReturnValue(cancellation.promise),
waitForInstall: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')).mockResolvedValue(ok(null)),
})
face.openInstall()
face.editInstallSpec('slow')
face.runInstall()
const requestId = await started()
controller.installProgress({ requestId, phase: 'installing' })
face.cancelInstall()
original.resolve(refused('gateway/internal', 'lost install reply'))
await vi.waitFor(() => { expect(state().install.failure?.reason).toBe('offline') })
cancellation.resolve(refused('gateway/internal', 'lost cancellation reply'))
await vi.waitFor(() => { expect(state().install.failure).toEqual({ reason: 'lost cancellation reply', uncertainty: 'result' }) })
face.reconcileInstall()
await vi.waitFor(() => { expect(state().install.phase).toBe('unknown') })
expect(plugins.waitForInstall).toHaveBeenCalledTimes(2)
})
it.each(['failed', 'cancelled', 'disposed'] as const)('deduplicates recovery and handles its %s outcome', async (outcome) => {
const recovery = deferred<ReturnType<typeof ok<ChangeResult>>>()
const { face, state, controller, plugins } = bench({
installBundle: vi.fn().mockResolvedValue(refused('gateway/internal', 'offline')),
waitForInstall: vi.fn().mockReturnValue(recovery.promise),
})
face.openInstall()
face.editInstallSpec('slow')
face.runInstall()
await vi.waitFor(() => { expect(plugins.waitForInstall).toHaveBeenCalledOnce() })
face.reconcileInstall()
await controller.load()
expect(plugins.waitForInstall).toHaveBeenCalledOnce()
if (outcome === 'disposed') controller.dispose()
recovery.resolve(ok({ ...failed(), application: outcome === 'cancelled' ? 'cancelled' : 'failed' }))
await recovery.promise
await Promise.resolve()
expect(state().install.phase).toBe(outcome === 'disposed' ? 'unconfirmed' : outcome === 'cancelled' ? 'idle' : 'failed')
})
it.each([false, true])('drops a stop the Host confirms once the run settled, or after disposal (%s)', async (dispose) => {
const answer = deferred<ReturnType<typeof ok<ChangeResult>>>()
const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
@@ -621,7 +745,10 @@ describe('PluginManagerController', () => {
controller.installProgress({ requestId: await started(), phase: 'installing' })
face.cancelInstall()
expect(state().install.phase).toBe('cancelling')
if (dispose) controller.dispose()
if (dispose) {
controller.dispose()
controller.installProgress({ requestId: state().install.requestId!, phase: 'applying' })
}
answer.resolve(ok({ ...APPLIED, bundle: 'slow' }))
if (!dispose) await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
cancellation.resolve(ok({ status: 'cancelled' }))
@@ -785,7 +912,7 @@ describe('PluginManagerController', () => {
await installing()
controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stderr', text: 'streamed' })
answer(refused('gateway/internal', 'offline'))
await vi.waitFor(() => { expect(state().install).toMatchObject({ phase: 'unconfirmed', failure: { reason: 'offline', cancelUnconfirmed: true } }) })
await vi.waitFor(() => { expect(state().install).toMatchObject({ phase: 'unconfirmed', failure: { reason: 'offline', uncertainty: 'result' } }) })
expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'streamed' }])
face.runInstall()
expect(plugins.installBundle).toHaveBeenCalledTimes(2)
@@ -1546,6 +1546,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and the pending build scripts to allow for this profile before pnpm runs.' }],
returns: 'Package-manager diagnostics and observed activation outcome.',
},
{
signature: '@Remote async waitForInstall(requestId: PluginInstallRequestId): Promise<ChangeResult | null>',
description: 'Recover the result of an active installation without cancelling it.',
parameters: [{ name: 'requestId', description: '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.',
},
{
signature: '@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise<PluginInstallCancellation>',
description: 'Stop an installation this manager owns and wait until its files are back.',