fix: preserve manager failures and support worker preview imports

This commit is contained in:
Turtle committed 2026-09-14 23:26:43 +08:00
1 parent 8e327513ba
commit 5a9c159c27
20 files changed
+138 -23

No files matched your search

@@ -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 .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
2026-09-14-current-profile-plugin-management.md: 316fb57a7826ae937f8f6225a2b2425563476bb1
2026-09-14-current-profile-plugin-management.zh.md: c2b6a0c5420f8b028ff6e3db6839b09e4672a65a
2026-09-14-current-profile-plugin-management.md: 1234007022bb0ac5e9247a0387d03ed9a4e94cdc
2026-09-14-current-profile-plugin-management.zh.md: 0d6059089c51379bad3b99bbcbe533afa8ac4c26
@@ -14,7 +14,7 @@ Web and agent controls need to change a running profile without creating an inde
Profile files remain the persisted state: entry toggles edit only `disabled` in the YAML document, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path.
This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Startup profiles keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service, whose batched durable notices inform live Agents without waking them. The agent tool is disabled by default in the base bundle and shipped presets.
This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Startup profiles keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service, whose batched durable notices inform live Agents without waking them. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
## Alternatives considered
@@ -14,7 +14,7 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
profile 文件保持为持久状态:条目开关只修改 YAML 文档中的 `disabled`,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。
这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service;service 合并持久通知,告知存活 Agent 而不唤醒它们。base 组合包和内置预设默认禁用该 Agent 工具。
这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service;service 合并持久通知,告知存活 Agent 而不唤醒它们。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
## 考虑过的替代方案
+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/module-graph.md
module-graph.md: 27646579259f09fca6cb0090a8bc201c4fae28ef
module-graph.zh.md: 9d1c4ced2fea4bad5432ab2b2d7d0f618036fc4a
module-graph.md: 3c6e6a81c741e4f3779cda709fe00d455a65ca95
module-graph.zh.md: 5df66d6740712a4d617778c9279d3676266cb218
+6 -2
View File
@@ -126,6 +126,7 @@ flowchart TD
subgraph group_boot["packages/boot"]
pkg_app_boot["app-boot"]
pkg_cmdline["cmdline"]
pkg_hmr["hmr"]
pkg_plugin_manager["plugin-manager"]
end
subgraph group_browser_use["packages/browser-use"]
@@ -456,6 +457,7 @@ flowchart TD
pkg_spill --> pkg_brand
pkg_spill --> pkg_llm
pkg_spill --> pkg_session
pkg_app_boot --> pkg_hmr
pkg_app_boot --> pkg_home_paths
pkg_app_boot --> pkg_launch_environment
pkg_app_boot --> pkg_system_prompt
@@ -1082,6 +1084,7 @@ flowchart TD
pkg_acp --> pkg_token_meter
pkg_acp --> pkg_user_approval
pkg_plugin_manager --> pkg_agent
pkg_plugin_manager --> pkg_hmr
pkg_plugin_manager --> pkg_host_plugin_inventory
pkg_plugin_manager --> pkg_llm
pkg_plugin_manager --> pkg_subprocess
@@ -1300,6 +1303,7 @@ flowchart TD
| [`api-gateway`](../packages/api/gateway) | `api` | — |
| [`api-workspace-files`](../packages/api/workspace-files) | `api` | — |
| [`cmdline`](../packages/boot/cmdline) | `boot` | — |
| [`hmr`](../packages/boot/hmr) | `boot` | — |
| [`acp-app`](../packages/bundle/acp-app) | `bundle` | — |
| [`base`](../packages/bundle/base) | `bundle` | — |
| [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — |
@@ -1412,7 +1416,7 @@ flowchart TD
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) |
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
| [`app-boot`](../packages/boot/app-boot) | `boot` | [`hmr`](../packages/boot/hmr), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) |
@@ -1538,7 +1542,7 @@ flowchart TD
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
+6 -2
View File
@@ -128,6 +128,7 @@ flowchart TD
subgraph group_boot["packages/boot"]
pkg_app_boot["app-boot"]
pkg_cmdline["cmdline"]
pkg_hmr["hmr"]
pkg_plugin_manager["plugin-manager"]
end
subgraph group_browser_use["packages/browser-use"]
@@ -458,6 +459,7 @@ flowchart TD
pkg_spill --> pkg_brand
pkg_spill --> pkg_llm
pkg_spill --> pkg_session
pkg_app_boot --> pkg_hmr
pkg_app_boot --> pkg_home_paths
pkg_app_boot --> pkg_launch_environment
pkg_app_boot --> pkg_system_prompt
@@ -1084,6 +1086,7 @@ flowchart TD
pkg_acp --> pkg_token_meter
pkg_acp --> pkg_user_approval
pkg_plugin_manager --> pkg_agent
pkg_plugin_manager --> pkg_hmr
pkg_plugin_manager --> pkg_host_plugin_inventory
pkg_plugin_manager --> pkg_llm
pkg_plugin_manager --> pkg_subprocess
@@ -1302,6 +1305,7 @@ flowchart TD
| [`api-gateway`](../packages/api/gateway) | `api` | — |
| [`api-workspace-files`](../packages/api/workspace-files) | `api` | — |
| [`cmdline`](../packages/boot/cmdline) | `boot` | — |
| [`hmr`](../packages/boot/hmr) | `boot` | — |
| [`acp-app`](../packages/bundle/acp-app) | `bundle` | — |
| [`base`](../packages/bundle/base) | `bundle` | — |
| [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — |
@@ -1414,7 +1418,7 @@ flowchart TD
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) |
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
| [`app-boot`](../packages/boot/app-boot) | `boot` | [`hmr`](../packages/boot/hmr), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) |
@@ -1540,7 +1544,7 @@ flowchart TD
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
@@ -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-settings-plugin-inventory/README.md
README.md: 49092e4598cb42818238aa75827f07f44cc76dda
README.zh.md: 2ae1a388078e003cbab6380c990d014296652219
README.md: 54392425cb58028dafbfd1bf749323b93b10b56e
README.zh.md: 4ce94dbeb11a5ceed6a346438c5c87a6a69c1071
@@ -47,7 +47,7 @@ A failed read renders a generic failure state inside the tab; retrying re-runs t
<details>
<summary>Implementation internals — click to expand</summary>
The tab reads the Host inventory on first selection, without Remote calls during plugin activation. Hosts exposing [Plugin Manager](../../boot/plugin-manager/README.md) also provide bundle installation, removal and switches for uniquely addressable global entries. Operations refresh observed state and show failures, overrides and pending restarts; preset compositions remain read-only.
The tab reads the Host inventory on first selection, without Remote calls during plugin activation. Hosts exposing [Plugin Manager](../../boot/plugin-manager/README.md) also provide bundle installation, removal and switches for uniquely addressable global entries. Operations refresh observed state and show failures, overrides and pending restarts; a successful inventory refresh preserves the last operation error; preset compositions remain read-only.
### Registration
@@ -47,7 +47,7 @@ kind: "package-reference"
<details>
<summary>实现细节——点击展开</summary>
该标签页首次选择时读取宿主清单,插件激活期间不执行 Remote 调用。提供[插件管理器](../../boot/plugin-manager/README.zh.md)的宿主还支持组合包安装、删除及可唯一定位的全局条目开关。操作后刷新实际状态,并显示失败、覆盖与待重启结果;预设组合保持只读。
该标签页首次选择时读取宿主清单,插件激活期间不执行 Remote 调用。提供[插件管理器](../../boot/plugin-manager/README.zh.md)的宿主还支持组合包安装、删除及可唯一定位的全局条目开关。操作后刷新实际状态,并显示失败、覆盖与待重启结果;清单刷新成功时仍保留最近一次操作错误;预设组合保持只读。
### 注册
@@ -27,6 +27,7 @@ export function usePluginManagement(manager: PluginManagement | undefined, avail
const submitting = useRef(false)
const [result, setResult] = useState<ChangeResult>()
const [error, setError] = useState<string>()
const [readError, setReadError] = useState<string>()
const [refresh, setRefresh] = useState(0)
useEffect(() => {
if (!available || manager === undefined) return
@@ -35,8 +36,8 @@ export function usePluginManagement(manager: PluginManagement | undefined, avail
if (!current) return
setPlugins(plugins)
setBundles(bundles)
setError(undefined)
}, (error: unknown) => { if (current) setError(error instanceof Error ? error.message : String(error)) })
setReadError(undefined)
}, (error: unknown) => { if (current) setReadError(error instanceof Error ? error.message : String(error)) })
return () => { current = false }
}, [manager, available, revision, refresh])
const run = async (operation: () => Promise<ChangeResult>): Promise<void> => {
@@ -48,7 +49,7 @@ export function usePluginManagement(manager: PluginManagement | undefined, avail
catch (error) { setError(error instanceof Error ? error.message : String(error)) }
finally { submitting.current = false; setBusy(false); setRefresh(value => value + 1) }
}
return { plugins, bundles, busy, result, error, run, refresh }
return { plugins, bundles, busy, result, error: error ?? readError, run, refresh }
}
/** Minimal bundle installation and enablement form. */
@@ -8,6 +8,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject, NS } from '../src/client/index.ts'
import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
import type { PluginEntryId } from '@deepseek-ai/dsh-api-remotes/client'
import type { PluginInventorySettingsTabInjected } from '../src/client/PluginInventorySettingsTab.tsx'
import { apply as hostApply } from '../src/index.ts'
@@ -34,8 +35,15 @@ async function bench() {
.mockResolvedValue({ ok: true, value: EMPTY })
ctx.provide('remote.pluginInventory', { list })
const listBundles = vi.fn().mockResolvedValue({ ok: true, value: [] })
ctx.provide('remote.pluginManager', { listBundles })
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, listBundles }
const managed = {
listBundles, listPlugins: vi.fn().mockResolvedValue({ ok: true, value: [] }),
setPluginEnabled: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'applied', message: 'plugin' } }),
setBundleEnabled: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'applied', message: 'bundle' } }),
installBundle: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'restart-required', message: 'install' } }),
removeBundle: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'applied', message: 'remove' } }),
}
ctx.provide('remote.pluginManager', managed)
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, listBundles, managed }
}
function declare(slots: SlotRegistry): () => void {
@@ -71,6 +79,18 @@ describe('ui-settings-plugin-inventory browser plugin', () => {
expect(b.list).toHaveBeenCalledOnce()
await expect(injected.management?.listBundles()).resolves.toEqual([])
expect(b.listBundles).toHaveBeenCalledOnce()
const manager = injected.management!
await expect(manager.listPlugins()).resolves.toEqual([])
await expect(manager.setPluginEnabled('include:plugin' as PluginEntryId, false)).resolves.toMatchObject({ message: 'plugin' })
expect(b.managed.setPluginEnabled).toHaveBeenCalledWith('include:plugin', false)
await expect(manager.setBundleEnabled('extra', false)).resolves.toMatchObject({ message: 'bundle' })
expect(b.managed.setBundleEnabled).toHaveBeenCalledWith('extra', false)
await expect(manager.installBundle('extra@1', { enabled: false })).resolves.toMatchObject({ application: 'restart-required' })
expect(b.managed.installBundle).toHaveBeenCalledWith('extra@1', { enabled: false })
await expect(manager.removeBundle('extra')).resolves.toMatchObject({ message: 'remove' })
expect(b.managed.removeBundle).toHaveBeenCalledWith('extra')
b.listBundles.mockResolvedValueOnce({ ok: false, error: new Error('manager unavailable') })
await expect(manager.listBundles()).rejects.toThrow('manager unavailable')
b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } })
await expect(injected.list()).rejects.toThrow('pluginInventory.list failed: REMOTE_ERROR: unavailable')
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
import type {
@@ -7,6 +7,7 @@ import type {
PluginInventorySettingsTabProps,
} from '../src/client/PluginInventorySettingsTab.tsx'
import { en, type PluginInventoryLocaleKey } from '../src/client/locales.ts'
import { BundleManager, usePluginManagement, type PluginManagement } from '../src/client/management.tsx'
afterEach(cleanup)
@@ -414,11 +415,83 @@ describe('persistent profile management', () => {
await waitFor(() => { expect(management.setPluginEnabled).toHaveBeenCalledWith('managed', false) })
await waitFor(() => { expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle plugin managed' }).checked).toBe(false) })
fireEvent.change(screen.getByRole('textbox', { name: 'npm package name or local path' }), { target: { value: 'new-bundle' } })
fireEvent.click(screen.getByRole('checkbox', { name: 'Enable after installation' }))
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
await waitFor(() => { expect(management.installBundle).toHaveBeenCalledWith('new-bundle', { enabled: true }) })
await waitFor(() => { expect(management.installBundle).toHaveBeenCalledWith('new-bundle', { enabled: false }) })
expect((await screen.findByRole('alert')).textContent).toContain('Registry unavailable.')
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
await waitFor(() => { expect(management.removeBundle).toHaveBeenCalledWith('extra') })
await waitFor(() => { expect(screen.queryByRole('switch', { name: 'Toggle bundle extra' })).toBeNull() })
})
})
function managementFixture() {
const applied = { changed: true, application: 'applied' as const, message: 'saved' }
return {
listPlugins: vi.fn<PluginManagement['listPlugins']>(async () => []),
listBundles: vi.fn<PluginManagement['listBundles']>(async () => []),
setPluginEnabled: vi.fn(async () => applied), setBundleEnabled: vi.fn(async () => applied),
installBundle: vi.fn(async () => applied), removeBundle: vi.fn(async () => applied),
}
}
it('ignores inventory completion after unmount and reports current read failures', async () => {
for (const failure of [undefined, new Error('late failure')]) {
const read = Promise.withResolvers<Awaited<ReturnType<PluginManagement['listBundles']>>>()
const manager = managementFixture()
vi.mocked(manager.listBundles).mockReturnValue(read.promise)
const hook = renderHook(() => usePluginManagement(manager, true, 0))
hook.unmount()
await act(async () => {
if (failure === undefined) read.resolve([])
else read.reject(failure)
await read.promise.catch(() => {})
})
}
for (const failure of [new Error('inventory unavailable'), 'inventory rejected']) {
const manager = managementFixture()
vi.mocked(manager.listBundles).mockRejectedValue(failure)
const hook = renderHook(() => usePluginManagement(manager, true, 0))
await waitFor(() => { expect(hook.result.current.error).toBe(failure instanceof Error ? failure.message : failure) })
hook.unmount()
}
})
it('blocks duplicate submissions and refreshes after a rejected management request', async () => {
const manager = managementFixture()
const hook = renderHook(() => usePluginManagement(manager, true, 0))
const pending = Promise.withResolvers<Awaited<ReturnType<PluginManagement['removeBundle']>>>()
const operation = vi.fn(() => pending.promise)
await act(async () => {
const first = hook.result.current.run(operation)
await hook.result.current.run(operation)
pending.resolve({ changed: true, application: 'applied', message: 'removed' })
await first
})
expect(operation).toHaveBeenCalledOnce()
expect(hook.result.current.result?.message).toBe('removed')
for (const failure of [new Error('request unavailable'), 'request rejected']) {
await act(async () => { await hook.result.current.run(async () => { throw failure }) })
expect(hook.result.current.error).toBe(failure instanceof Error ? failure.message : failure)
expect(hook.result.current.busy).toBe(false)
}
})
it('shows package diagnostics, read errors and protected or missing bundles', () => {
const manager = managementFixture()
const state: ReturnType<typeof usePluginManagement> = {
plugins: [], bundles: [
{ name: 'missing', enabled: false, removable: true, error: 'package files missing' },
{ name: 'core', enabled: true, removable: false, readOnlyReason: 'required for management' },
], busy: false, refresh: 0, error: 'inventory unavailable', run: async () => {},
result: { changed: true, application: 'failed', message: 'installation failed',
packageResult: { exitCode: 1, output: 'failed', truncated: false, logPath: '/profile/operation/pnpm.log' } },
}
render(<BundleManager manager={manager} state={state} t={t} />)
expect(screen.getByText('inventory unavailable')).toBeDefined()
expect(screen.getByText('/profile/operation/pnpm.log')).toBeDefined()
expect(screen.getByText('package files missing')).toBeDefined()
expect(screen.getByText('required for management')).toBeDefined()
expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle bundle missing' }).disabled).toBe(true)
expect(screen.getByRole<HTMLInputElement>('switch', { name: 'Toggle bundle core' }).disabled).toBe(true)
})
@@ -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/experimental/webworker-runtime/README.md
README.md: ab54ec689664adc1c0e01cc3387777c77f36bc66
README.zh.md: d6593eeff22e642e2423fa9348f64d751d5de62f
README.md: 724e47a004fac83fe0f19a588faf0c227d843232
README.zh.md: 4e88b3cf10f98a423268c60b69269d2550d2c1f9
@@ -53,6 +53,7 @@ None; this package neither assembles nor sends a provider request.
- **The worker composition writes plaintext session logs** (`compression: 'none'` boot patch): it carries no Zstandard codec, so exported logs are `.jsonl`, never `.jsonl.zstd`.
- **`node:dns/promises`, `node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing native DNS, a real process, or realm isolation cannot run here.
- **Host package commands are unavailable**: `execa` reports an explicit worker-host refusal; the preview cannot run pnpm or install native dependencies.
- **PTC Node programs are unavailable**: the process shim exposes `/dsh/bin/node` as its executable identity so the provider can activate, but the Worker has neither a Node executable nor `stripTypeScriptTypes`. Program execution fails before launching a child.
- **Filesystem watchers observe only the mounted VFS**: image seeding is silent and the VFS has no symlinks or external writers. `persistent`, `ref()`, and `unref()` preserve the Node API but cannot control a dedicated Worker's lifetime because browsers expose no ref-counted event loop.
- **Worker confinement is a VFS boundary, not kernel Landlock**: `read-only` and `workspace-write` run the unchanged `@deepseek-ai/node-addon-system/landlock-run` JavaScript and launcher argv, but the process layer implements the logical `landlock-run` executable and enforces its grants on every shell filesystem request. `full` therefore covers the Worker command table and mounted VFS only; it does not claim arbitrary native-process execution or Linux kernel isolation.
@@ -53,6 +53,7 @@ kind: "package-library"
- **worker 组合写明文会话日志**(`compression: 'none'` boot patch):不带 Zstandard 编解码器,导出日志是 `.jsonl`,不会是 `.jsonl.zstd`。
- **`node:dns/promises`、`node:vm`、`node:net`、`node:sqlite`、`node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要原生 DNS、真进程或真 realm 隔离的行在此无法运行。
- **宿主包管理命令不可用**:`execa` 明确报告 worker host 不支持该调用;预览无法运行 pnpm 或安装原生依赖。
- **PTC Node 程序不可用**:process shim 用 `/dsh/bin/node` 表示可执行文件身份,使 provider 能够激活,但 Worker 既没有 Node 可执行文件,也没有 `stripTypeScriptTypes`。程序执行会在启动子进程前失败。
- **文件 watcher 只能观察已挂载的 VFS**:镜像 seed 不产生事件,VFS 也没有符号链接或外部写入方。`persistent`、`ref()` 和 `unref()` 保留 Node API,但浏览器没有引用计数事件循环,因此这些接口不能控制 dedicated Worker 的生存期。
- **Worker confinement 是 VFS 边界,不是内核 Landlock**:`read-only` 和 `workspace-write` 运行未经修改的 `@deepseek-ai/node-addon-system/landlock-run` JavaScript 与 launcher argv,进程层则实现逻辑 `landlock-run` 可执行文件,并在 shell 的每次文件系统请求上执行其授权。`full` 仅覆盖 Worker 命令表和已挂载 VFS,不表示能够执行任意 native 进程,也不表示 Linux 内核隔离。
@@ -69,6 +69,7 @@ export const MODULE_PROXIES: Record<string, string> = {
'koffi': './node/external_packages/koffi.ts',
'sharp': './node/external_packages/sharp.ts',
'node-pty': './node/external_packages/node-pty.ts',
'execa': './node/external_packages/execa.ts',
'@vscode/ripgrep': './node/external_packages/ripgrep.ts',
'@earendil-works/pi-ai': './node/external_packages/pi-ai.ts',
// Constructible fakes whose methods are never reached.
@@ -48,6 +48,7 @@ import * as nodeWorkerThreads from './builtin_modules/mock/worker_threads.ts'
import * as systemFlock from './external_packages/node-addon-system-flock.ts'
import * as koffi from './external_packages/koffi.ts'
import * as nodePty from './external_packages/node-pty.ts'
import * as execa from './external_packages/execa.ts'
import * as piAi from './external_packages/pi-ai.ts'
import * as ripgrep from './external_packages/ripgrep.ts'
import * as sharp from './external_packages/sharp.ts'
@@ -90,6 +91,7 @@ const EXTERNALS: Record<string, StaticModuleFactory> = {
'koffi': () => koffi,
'sharp': () => sharp,
'node-pty': () => nodePty,
'execa': () => execa,
'ws': () => ws,
'@vscode/ripgrep': () => ripgrep,
'@earendil-works/pi-ai': () => piAi,
@@ -0,0 +1,5 @@
/** Host package commands cannot install dependencies in the browser preview. */
import { notImplementedFail } from '../notImplementedFail.ts'
/** Refuse external package commands in the worker host. */
export const execa = notImplementedFail('execa', 'execa')
@@ -12,6 +12,7 @@ export const REPLACED_EXTERNAL_PACKAGES: readonly string[] = [
'@deepseek-ai/node-addon-system/flock',
'koffi',
'node-pty',
'execa',
'sharp',
'ws',
]
@@ -21,6 +21,7 @@ import * as stream from '../../src/node/builtin_modules/implemented/stream.ts'
import * as vm from '../../src/node/builtin_modules/mock/vm.ts'
import * as workerThreads from '../../src/node/builtin_modules/mock/worker_threads.ts'
import * as nodePty from '../../src/node/external_packages/node-pty.ts'
import * as execa from '../../src/node/external_packages/execa.ts'
import * as piAi from '../../src/node/external_packages/pi-ai.ts'
import * as ripgrep from '../../src/node/external_packages/ripgrep.ts'
import * as ws from '../../src/node/external_packages/ws.ts'
@@ -43,6 +44,7 @@ const CALLED: [string, Record<string, unknown>, readonly string[]][] = [
// these three need a real process, so they stay refusals.
['node:child_process', childProcess, ['execFileSync', 'execSync', 'fork']],
['node-pty', nodePty, ['spawn', 'open']],
['execa', execa, ['execa']],
['@deepseek-ai/pi-ai', piAi, [
'createProvider', 'createModels', 'openAICompletionsApi', 'openAIResponsesApi', 'anthropicMessagesApi',
'isContextOverflow', 'getSupportedThinkingLevels',