Merge remote-tracking branch 'origin/master' into fix/windows-menu-tooltip-overlap
This commit is contained in:
commit
73bf301227
690 files changed
+17326
-6816
No files matched your search
+2
-2
@@ -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-07-29-dsh-source-launch-tsx-esm.md
|
||||
2026-07-29-dsh-source-launch-tsx-esm.md: 5401b0257faa69eaf76b11e7b51d06afd7ec2b8c
|
||||
2026-07-29-dsh-source-launch-tsx-esm.zh.md: f32fa3ece91bf7b9829d7012ceb090cabaa833a9
|
||||
2026-07-29-dsh-source-launch-tsx-esm.md: 700577cb9b137d67f20baeb639f99f4c0877011d
|
||||
2026-07-29-dsh-source-launch-tsx-esm.zh.md: 4d322d3dcf49442ee62f82203f17bd2ab16467fe
|
||||
@@ -14,11 +14,13 @@ Startup latency also mattered: the off-thread `module.register()` hooks worker s
|
||||
|
||||
## Decision
|
||||
|
||||
The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. The root `dsh` script uses that vector directly from the repository root; artifact generation is a separate operation under the [source-launch/build separation decision](../../archived/simplification/2026-08-12-separate-source-launch-from-build.md). The CJS hook stays off because the CLI source graph is ESM-only; measured runtime launch to the TUI banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain.
|
||||
The `dsh` CLI source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. The root `dsh` script uses that vector directly from the repository root; artifact generation is a separate operation under the [source-launch/build separation decision](../../archived/simplification/2026-08-12-separate-source-launch-from-build.md). The CJS hook stays off because the CLI source graph is ESM-only; the implementation-time measurement to the TUI banner was ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain.
|
||||
|
||||
`scripts/tspath-loader.ts` and `apps/cli/src/tsconfig-paths-loader.ts` are deleted. With them went the loader's runtime rule of mapping a workspace import only for declared runtime dependencies — tsx applies the `paths` map unconditionally. Declaration completeness now rests on the static gates alone: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. (That runtime rule found real bugs: `dsh-plan-mode` and `dsh-tool-jobs` imported `@deepseek-ai/dsh-llm` while declaring it only in devDependencies; since fixed.)
|
||||
tsx owns workspace `paths` mapping without checking whether the importer declares each package as a runtime dependency. Declaration completeness rests on the static gates: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. The removed repo-owned paths loaders enforced those declarations at runtime and caught imports of `@deepseek-ai/dsh-llm` declared only in devDependencies; tsx does not provide that check.
|
||||
|
||||
The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`): a keyless piped-stdio launch of the exact production runtime vector asserting the non-zero-exit TTY refusal. Any future Node change to module hooks or TypeScript handling turns this gate red instead of breaking developers' `pnpm dsh`.
|
||||
tsx skips the paths map when the importer URL contains `/node_modules/`. The [runtime resolution](2026-09-09-profile-resolution-generations.md) therefore records real declaring-package anchors, including recursive dependencies. This lets workspace fallback imports resolve to `src/` consistently, rather than mixing built `lib/` providers with source callers whose module-local Symbols differ. Packages without a workspace mapping retain ordinary export resolution.
|
||||
|
||||
The node-compat CI matrix runs `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`) without a build: keyless launches assert the required-profile diagnostic and profile dependency resolution without mixed Tools/AgentLoop `src/` and `lib/` instances. The source and built-bin suites share ordinary-directory and npm-link profile cases; the latter launches plain Node against built exports. The required build-backed smoke also runs `apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts`: it checks real headless tool dispatch through tsx after the native addon and generated typert contributors are available, while still requiring core workspace modules to load from source. Future changes to module hooks or TypeScript handling are checked through these real entry paths.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -35,4 +37,4 @@ The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`
|
||||
- One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line.
|
||||
- TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode.
|
||||
- The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures.
|
||||
- Runtime launch improves ~0.4s over the full tsx default; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path.
|
||||
- Every CLI source profile uses the same ESM-only hook, including ACP. Workspace runtime entries reached by this launch must expose ESM exports; the launcher does not install a CJS TypeScript transform hook.
|
||||
@@ -14,11 +14,13 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。根目录的 `dsh` 脚本直接从仓库根目录使用同一启动方式;产物生成是独立操作,由[源码启动与构建分离决策](../../archived/simplification/2026-08-12-separate-source-launch-from-build.md)规定。CJS 钩子保持关闭,因为 CLI(命令行界面)源码图是纯 ESM;实测运行时启动至 TUI banner 耗时约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。
|
||||
`dsh` CLI(命令行界面)源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。根目录的 `dsh` 脚本直接从仓库根目录使用同一启动方式;产物生成是独立操作,由[源码启动与构建分离决策](../../archived/simplification/2026-08-12-separate-source-launch-from-build.md)规定。CJS 钩子保持关闭,因为 CLI 源码图是纯 ESM;实现时测得启动至 TUI banner 耗时约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。
|
||||
|
||||
`scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader「仅为已声明运行时依赖映射 workspace import」的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest(元数据清单)走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-jobs` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;后已修复。)
|
||||
tsx 负责 workspace `paths` 映射,不检查导入方是否把每个包声明为运行时依赖。声明完整性由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest(元数据清单)走 workspace constraints。已移除的仓库自有 paths loader 曾在运行时强制这些声明,并发现过将 `@deepseek-ai/dsh-llm` 仅声明在 devDependencies 中的 import;tsx 不提供这项检查。
|
||||
|
||||
node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产运行时启动向量做 keyless 管道 stdio 启动,断言进程会因 TTY 拒绝而以非零状态退出。未来 Node 对模块钩子或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。
|
||||
导入方 URL 含有 `/node_modules/` 时,tsx 会跳过 paths 映射。因此,[runtime resolution](2026-09-09-profile-resolution-generations.zh.md)记录声明包的真实锚点,也覆盖递归依赖。这让 workspace fallback import 一致解析到 `src/`,避免混用构建后的 `lib/` 提供方与模块本地 Symbol 不同的源码调用方。没有 workspace 映射的包仍使用普通导出解析。
|
||||
|
||||
node-compat CI 矩阵在不执行 build 的情况下运行 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):无密钥启动断言必需 profile 的诊断,以及不混用 Tools/AgentLoop `src/` 与 `lib/` 实例的 profile 依赖解析。源码与构建入口测试共享普通目录和 npm-link profile 用例;后者通过普通 Node 加载构建后的导出。必跑的 build-backed smoke 还运行 `apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts`:在 native addon 和生成的 typert 贡献文件就绪后,通过 tsx 检查真实 headless 工具派发,同时仍要求核心 workspace 模块从源码加载。模块钩子或 TypeScript 处理的后续变化由这些真实入口检查。
|
||||
|
||||
## 备选方案
|
||||
|
||||
@@ -35,4 +37,4 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`
|
||||
- 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。
|
||||
- TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 Agent Note「证明 Node 原生转换可用」的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。
|
||||
- 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。
|
||||
- 运行时启动相比完整 tsx 默认形态快约 0.4s;ACP(Agent Client Protocol)保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上。
|
||||
- 每个 CLI 源码 profile 都使用相同的 ESM-only 钩子,包括 ACP(Agent Client Protocol)。该启动方式访问的 workspace 运行时入口必须提供 ESM 导出;启动器不安装 CJS TypeScript 转换钩子。
|
||||
@@ -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-08-05-profile-plugin-bundles.md
|
||||
2026-08-05-profile-plugin-bundles.md: 48786a9c1ccaceb5f16c9eefed01e556cf759e6d
|
||||
2026-08-05-profile-plugin-bundles.zh.md: c88cbf98e4276549a6fae6a5b1253d3d838a64ca
|
||||
2026-08-05-profile-plugin-bundles.md: 0607f1c9a2414cc16515967b4520da8a325853fd
|
||||
2026-08-05-profile-plugin-bundles.zh.md: 9d25d3bea6337c472d161c721e44a66dca8470c9
|
||||
@@ -14,14 +14,14 @@ Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a
|
||||
|
||||
The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. A new, non-shipped target can use `--from-default-profile <template>` to copy one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship. The launcher claims the complete target directory exclusively, so existing state and concurrent creators fail without modification. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes a base-backed profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
|
||||
|
||||
Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory, so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them. Bare plugin names in patch rows use the [immutable profile resolution generation](2026-09-09-profile-resolution-generations.md), which applies the same installation-first and ordered-bundle rules in memory; retained link and dual modes can materialize the same result.
|
||||
Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory, so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them. Bare plugin names in patch rows use the [immutable runtime resolution](2026-09-09-profile-resolution-generations.md), which applies installation-first and ordered-bundle rules in memory and expands dependencies from real package directories.
|
||||
|
||||
Two supporting refactors: the webserver's built-in static dist serving became the single-owner **fallback seat** (`registerFallback`/`applyIndexTaps`), with the SPA server extracted to `@deepseek-ai/dsh-host-frontend-static` so the web bundle owns its dist as composition, not launcher code; and the personal-overlay machinery of the [dsh CLI personal-config decision](../../archived/feature/2026-07-20-dsh-cli-personal-config.md) (`loadPersonalPatches`, `$DSH_HOME/config.yaml`) was retargeted to the per-profile and home-level `cordis.patch.yml` layers (`loadOptionalPatches`, `watchUserPatches` taking a filename), superseding that note's entry modes and file location while keeping its Harness-home root, patch semantics, and fail-loud parsing.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan.
|
||||
- **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony.
|
||||
- **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. Two-anchor resolution plus runtime fallback keeps bundles installation-owned without persisting those links.
|
||||
- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-provided host slots (`ctx.cmdlineArgs`, `ctx.appExit`, and the environment snapshot) are provided in `boot()`'s `prepare` hook, before any config-tree entry mounts.
|
||||
- **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file.
|
||||
- **Dynamic template inheritance or cloning a local profile**: recording a parent would require merge and upgrade rules for bundle membership, dependencies, and user patches, while copying local state would duplicate machine-specific choices. Template-based creation copies only installation-owned defaults once.
|
||||
|
||||
@@ -14,14 +14,14 @@ Status: implemented
|
||||
|
||||
默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。新的非内置目标可以使用 `--from-default-profile <template>`,在启动或配置 dump 之前复制一个默认模板的 bundle 列表与 patch 重载策略。这会创建依赖为空、用户 patch 为空的独立 profile:它既不读取与模板同名的本地 profile,也不记录继承关系。launcher 会以独占方式领取完整的目标目录,因此既有状态和并发创建者都会在不作修改的情况下失败。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化一个以 base 为基础的 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
|
||||
|
||||
解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析,因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们。patch 行中的裸插件名称使用[不可变 profile resolution generation](2026-09-09-profile-resolution-generations.zh.md),在内存中应用相同的安装优先与有序 bundle 规则;保留的 link 与 dual 模式可以物化同一结果。
|
||||
解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析,因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们。patch 行中的裸插件名称使用[不可变 runtime resolution](2026-09-09-profile-resolution-generations.zh.md),在内存中应用安装优先与有序 bundle 规则,并从包的真实目录展开依赖。
|
||||
|
||||
两项配套重构:webserver 内置的静态 dist 服务改为单一所有者的**回退席位**(`registerFallback`/`applyIndexTaps`),SPA 服务器提取到 `@deepseek-ai/dsh-host-frontend-static`,使 web 组合包以组合的方式持有自己的 dist,而不是靠启动器代码;[dsh CLI 个人配置决策](../../archived/feature/2026-07-20-dsh-cli-personal-config.md)的个人 overlay 机制(`loadPersonalPatches`、`$DSH_HOME/config.yaml`)改为面向逐 profile 与 home 级的 `cordis.patch.yml` 层(`loadOptionalPatches`、接受文件名的 `watchUserPatches`),取代该笔记的各入口模式与文件位置,同时保留其 Harness home 根目录、patch 语义与响亮失败的解析。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式,没有暗中扫描。
|
||||
- **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。
|
||||
- **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加运行时 fallback 可以让组合包保持由安装持有,无需持久化这些链接。
|
||||
- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器提供的宿主 slot(`ctx.cmdlineArgs`、`ctx.appExit` 与环境快照)在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。
|
||||
- **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。
|
||||
- **动态模板继承或克隆本地 profile**:记录父级会要求为 bundle 成员关系、依赖和用户 patch 制定合并与升级规则,而复制本地状态会重复机器特定选择。基于模板的创建只会一次性复制安装自有的默认值。
|
||||
|
||||
+2
-2
@@ -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-08-18-experimental-agent-teams-packages.md
|
||||
2026-08-18-experimental-agent-teams-packages.md: a1d29b5e96b185a5539d664e19f2f5cc342a6aba
|
||||
2026-08-18-experimental-agent-teams-packages.zh.md: f4c4eb481ae6ad6319ddac50eda82fa9b9feebf7
|
||||
2026-08-18-experimental-agent-teams-packages.md: 92eba148b328135301b196c9a17e49ee23ddb3f6
|
||||
2026-08-18-experimental-agent-teams-packages.zh.md: 6f4d004a78e32e2c27ad65648c446b3cd54c07ad
|
||||
+1
-1
@@ -20,7 +20,7 @@ The generic caller-reserved continuable child identity and selective direct-chil
|
||||
|
||||
The [single-bundle decision](2026-09-18-agent-teams-single-bundle.md) supersedes the separate Host and Web bundle composition. This note retains publication, dependency isolation, and promotion rationale. The current Team profile remains opt-in and disables the global continuable-child controls whose model-visible names overlap the Team tools; the [optional-bundle decision](../process/2026-09-15-shipped-optional-bundles.md) owns its inclusion in the installation.
|
||||
|
||||
Profile startup resolves selected bundles before computing the [immutable profile resolution generation](2026-09-09-profile-resolution-generations.md). The generation retains installation-first precedence, traverses each explicit bundle root completely in profile order, and keeps pnpm-managed profile packages authoritative. Runtime mode enforces the result in memory; retained link and dual modes materialize the same result as shared and profile-owned projections. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution.
|
||||
Profile startup resolves selected bundles before computing the [immutable runtime resolution](2026-09-09-profile-resolution-generations.md). The runtime resolution retains installation-first precedence, traverses each explicit bundle root completely in profile order, and keeps pnpm-managed profile packages authoritative. The runtime interception enforces the result in memory without materializing fallback links. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution.
|
||||
|
||||
Experimental status changes compatibility and support expectations, not publication for these four packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations.
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ dsh 打包与发布集合以及本地基线发布器包含这四个 Agent Teams
|
||||
|
||||
[单组合包决策](2026-09-18-agent-teams-single-bundle.zh.md)取代了独立 Host 与 Web 组合包的组合方式。本记录保留发布、依赖隔离与 promotion 的理由。当前 Team profile 仍须显式启用,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control;[可选组合包决策](../process/2026-09-15-shipped-optional-bundles.zh.md)负责其随安装交付的规则。
|
||||
|
||||
profile 启动会先解析所选 bundle,再计算[不可变 profile resolution generation](2026-09-09-profile-resolution-generations.zh.md)。generation 保留安装优先顺序,按 profile 顺序完整遍历每个显式 bundle 根,并让 pnpm 管理的 profile 包保持优先。runtime 模式在内存中强制该结果;保留的 link 与 dual 模式把同一结果物化为共享和 profile 自有投影。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。
|
||||
profile 启动会先解析所选 bundle,再计算[不可变 runtime resolution](2026-09-09-profile-resolution-generations.zh.md)。runtime resolution 保留安装优先顺序,按 profile 顺序完整遍历每个显式 bundle 根,并让 pnpm 管理的 profile 包保持优先。运行时拦截在内存中强制该结果,不物化 fallback 链接。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。
|
||||
|
||||
对这四个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。
|
||||
|
||||
|
||||
+2
-2
@@ -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-08-24-browser-token-authentication.md
|
||||
2026-08-24-browser-token-authentication.md: cdfed1f250de6def39e386cf9fcd2e536c6c97e4
|
||||
2026-08-24-browser-token-authentication.zh.md: 29f3a6c0a2d83ed1c17b9c0bc5988c9472741f3f
|
||||
2026-08-24-browser-token-authentication.md: 47d6afe1bf4cd8169eb4e8ec92a30bf0fee32c8a
|
||||
2026-08-24-browser-token-authentication.zh.md: 13ecf55e41c7bb1455177bdd6a87a3a83d94ca6f
|
||||
@@ -12,9 +12,9 @@ The Web Host runs tool-capable Sessions with the current operating-system user's
|
||||
|
||||
`dsh-client-connection` authenticates the complete Host API before dispatch. Every API Proxy method, Remote unary call, generic Connection channel, and Remote WebSocket stream requires the same browser session; endpoint ownership and method names do not alter authority. The existing Host/Origin checks run first and retain their DNS-rebinding and cross-site-request role, returning 403 when they fail. A trusted Host without a valid browser session receives 401. The browser-trust rules remain owned by the [carrier-level browser trust decision](2026-07-28-api-browser-trust-boundary.md).
|
||||
|
||||
Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `/`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `/`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
|
||||
Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `./`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `./`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
|
||||
|
||||
The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation or reverse-proxy-specific handling.
|
||||
The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation.
|
||||
|
||||
The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection loads or creates the record during activation and retains the secret for synchronous request verification. An active Connection continues using its loaded secret if the durable record changes; the next activation loads the replacement or creates a missing record, so deleting the record and restarting the process revokes every existing cookie. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority.
|
||||
|
||||
|
||||
+2
-2
@@ -12,9 +12,9 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session
|
||||
|
||||
`dsh-client-connection` 在分发前认证完整 Host API。每个 API Proxy 方法、Remote 一元调用、通用 Connection channel 和 Remote WebSocket stream 都要求同一个浏览器会话;endpoint 所有权与方法名称不改变 authority。既有 Host/Origin 校验先执行,继续负责 DNS rebinding 和跨站请求防御,失败时返回 403。Host 可信但没有有效浏览器会话时返回 401。浏览器信任规则仍由[载体级浏览器信任决策](2026-07-28-api-browser-trust-boundary.zh.md)持有。
|
||||
|
||||
每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `/`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `/`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
|
||||
每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `./`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `./`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
|
||||
|
||||
cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作或反向代理专用处理。
|
||||
cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作。
|
||||
|
||||
HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 在激活期间加载或创建该记录,并保留密钥以同步校验请求。持久记录发生变化后,当前 Connection 继续使用已加载的密钥;下一次激活会加载替换记录或创建缺失记录,因此删除记录并重启进程会撤销全部既有 cookie。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。
|
||||
|
||||
|
||||
+2
-2
@@ -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-08-desktop-bundled-runtime-and-external-plugins.md
|
||||
2026-09-08-desktop-bundled-runtime-and-external-plugins.md: a5f9266e0e7a8def3f0ff6fc271bddde3db30163
|
||||
2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 2cc9fd635102f08e6ca4afb4f086a0cf6b8b05f0
|
||||
2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 61fa9eafb72133d4cbba1b65193275cc8474f7e5
|
||||
2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 8d94d5823526ab4e615dab13f8453c8b6da5d8b6
|
||||
+8
-8
@@ -28,17 +28,17 @@ The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.t
|
||||
|
||||
Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the Electron [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin.
|
||||
|
||||
The shared profile runner projects missing installation and selected-bundle dependencies within the Desktop profile. pnpm-installed packages take precedence. Links resolve to real package directories under normal Node resolution, so Host and plugin imports reaching the same export share its module instance. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations.
|
||||
The shared profile runner supplies missing installation and selected-bundle dependencies through the runtime resolution. pnpm-installed packages take precedence. Runtime resolution delegates to the selected package paths, so Host and plugin imports reaching the same export share its module instance. Distinct ESM and CommonJS conditional exports remain distinct entry points; runtime resolution does not merge a package’s dual implementations.
|
||||
|
||||
External plugins use normal Node package resolution. Desktop does not recursively check peer versions, duplicate packages, linked packages, or ancestor dependency resolution. These checks duplicate package-manager and loader responsibilities and reject pnpm-supported installation sources. Shared fallback links supply missing packages, but a plugin can resolve another installed copy; incompatible plugins may fail during Host startup and require recovery through the independent shell UI.
|
||||
External plugins use normal Node package resolution. Desktop does not recursively check peer versions, duplicate packages, linked packages, or ancestor dependency resolution. These checks duplicate package-manager and loader responsibilities and reject pnpm-supported installation sources. The runtime resolution supplies missing packages, but a plugin can resolve another installed copy; incompatible plugins may fail during Host startup and require recovery through the independent shell UI.
|
||||
|
||||
The profile manifest records pnpm-installed dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) assigns initialization, bundle reconciliation, and module links to shared app-boot helpers; Desktop holds no separate link ledger or runtime-state identity.
|
||||
The profile manifest records pnpm-installed dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) assigns initialization, bundle reconciliation, and runtime module resolution to shared app-boot helpers; Desktop holds no separate link ledger or runtime-state identity.
|
||||
|
||||
## Transactions and upgrades
|
||||
|
||||
First launch creates profile metadata and host links without running pnpm, preserving unrelated files. Compatible release changes or application relocation refresh links in place. Node version, platform, or architecture changes preserve installed plugins; pnpm and the loader report installation and compatibility failures.
|
||||
First launch creates profile metadata without running pnpm, preserving unrelated files. Each launch computes the runtime resolution from the current installation, including after a compatible release change or application relocation. Node version, platform, or architecture changes preserve installed plugins; pnpm and the loader report installation and compatibility failures.
|
||||
|
||||
Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation. Profile cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation.
|
||||
Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation.
|
||||
|
||||
The shared [plugin manager](../../../../packages/boot/plugin-manager/README.md) owns supported package specifications, bundle validation, activation, and installation failure handling. The bundled pnpm reads normal user and profile settings. Development uses the same Web manager against a separate Desktop profile, with workspace packages supplied by the development runtime.
|
||||
|
||||
@@ -51,9 +51,9 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s
|
||||
Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation decision](2026-09-09-desktop-build-release-validation.md) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory.
|
||||
|
||||
- **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility.
|
||||
- **Force host dependency versions into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Shared fallback supplies missing packages while pnpm-owned entries retain independent versions.
|
||||
- **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Directory symlinks and Windows junctions express the intended package target.
|
||||
- **Use `NODE_PATH` or preserve symlink paths.** These do not provide uniform ESM resolution or shared module identity. Normal package lookup through explicit links is directly testable.
|
||||
- **Force host dependency versions into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. The runtime resolution supplies missing packages while pnpm-owned entries retain independent versions.
|
||||
- **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Runtime resolution requires no filesystem projections.
|
||||
- **Use `NODE_PATH` or preserve symlink paths.** These do not provide uniform ESM resolution or shared module identity. The runtime resolution gives ESM and CommonJS the same package selection.
|
||||
- **Keep core packages in ASAR.** Ordinary `extraResources` preserves native loading and subprocess paths. ASAR requires separate package-resolution qualification.
|
||||
|
||||
## Consequences
|
||||
|
||||
+8
-8
@@ -28,17 +28,17 @@ Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成
|
||||
|
||||
包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;Electron 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。
|
||||
|
||||
共享 profile runner 在 Desktop profile 内补全安装包与选中 bundle 缺失的依赖。pnpm 安装的包优先。链接通过正常 Node 解析指向真实包目录,因此 Host 与插件导入同一导出时共享其模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的双重实现。
|
||||
共享 profile runner 通过 runtime resolution 补全安装包与选中 bundle 缺失的依赖。pnpm 安装的包优先。运行时解析委托给所选包路径,因此 Host 与插件导入同一导出时共享其模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;运行时解析不会合并包的双重实现。
|
||||
|
||||
外部插件使用正常 Node 包解析。Desktop 不递归检查 peer 版本、重复包、链接包或上级目录依赖解析。这些检查重复包管理器及加载器的职责,并拒绝 pnpm 支持的安装来源。共享补全链接提供缺失的包,但插件可以解析到另一份已安装副本;不兼容插件可能在 Host 启动时失败,需要通过独立壳 UI 恢复。
|
||||
外部插件使用正常 Node 包解析。Desktop 不递归检查 peer 版本、重复包、链接包或上级目录依赖解析。这些检查重复包管理器及加载器的职责,并拒绝 pnpm 支持的安装来源。runtime resolution 提供缺失的包,但插件可以解析到另一份已安装副本;不兼容插件可能在 Host 启动时失败,需要通过独立壳 UI 恢复。
|
||||
|
||||
profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。禁用插件保留其包、锁文件条目及用户配置。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)将初始化、bundle 协调和模块链接交给共享 app-boot helper;Desktop 不保存独立链接账本或运行时状态身份。
|
||||
profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。禁用插件保留其包、锁文件条目及用户配置。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)将初始化、bundle 协调和运行时模块解析交给共享 app-boot helper;Desktop 不保存独立链接账本或运行时状态身份。
|
||||
|
||||
## 事务与升级
|
||||
|
||||
首次启动创建 profile 元数据和宿主链接,不运行 pnpm,并保留无关文件。兼容的发布变化或应用移动会直接刷新链接。Node 版本、平台或架构变化时保留已安装插件;安装和兼容性错误由 pnpm 与加载器报告。
|
||||
首次启动创建 profile 元数据,不运行 pnpm,并保留无关文件。每次启动都从当前安装计算 runtime resolution,包括兼容的发布变化或应用移动之后。Node 版本、平台或架构变化时保留已安装插件;安装和兼容性错误由 pnpm 与加载器报告。
|
||||
|
||||
共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。profile 清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。
|
||||
共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。
|
||||
|
||||
共享[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)负责支持的包规格、bundle 验证、激活和安装失败处理。内置 pnpm 读取正常的用户和 profile 设置。开发模式使用相同的 Web 管理器操作独立 Desktop profile,工作区包由开发运行时提供。
|
||||
|
||||
@@ -51,9 +51,9 @@ profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。
|
||||
完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。
|
||||
|
||||
- **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。
|
||||
- **强制插件使用 Host 依赖版本。** 这会让普通插件依赖不必要地耦合于 Host。共享模块补全提供缺失的包,pnpm 拥有的条目保留独立版本。
|
||||
- **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。
|
||||
- **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。通过明确链接进行正常包查找可以直接测试。
|
||||
- **强制插件使用 Host 依赖版本。** 这会让普通插件依赖不必要地耦合于 Host。runtime resolution 提供缺失的包,pnpm 拥有的条目保留独立版本。
|
||||
- **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。运行时解析不需要文件系统投影。
|
||||
- **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。runtime resolution 为 ESM 与 CommonJS 提供相同的选包结果。
|
||||
- **把核心包留在 ASAR。** 普通 `extraResources` 保留原生加载和子进程路径。ASAR 需要单独验证包解析。
|
||||
|
||||
## 影响
|
||||
|
||||
+2
-2
@@ -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-09-profile-resolution-generations.md
|
||||
2026-09-09-profile-resolution-generations.md: ad6f05ba88b25ea52da97834cf4638c8feb961ba
|
||||
2026-09-09-profile-resolution-generations.zh.md: ffa9abc5a6a04bc9553878946fa3fb422328312c
|
||||
2026-09-09-profile-resolution-generations.md: ced79eefc8c46a43cabd5b6a79db2644526c022f
|
||||
2026-09-09-profile-resolution-generations.zh.md: 98c509488aaeaa8d1b5e722bf6e3ea6346d45c46
|
||||
+60
-30
@@ -8,27 +8,65 @@ English | [中文](2026-09-09-profile-resolution-generations.zh.md)
|
||||
|
||||
A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. Bridging the trees through shared symlinks, profile-owned links, or packaged-executable proxy packages persists package selections across processes and installations. Those files require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically.
|
||||
|
||||
The runtime design preserves the existing selection rules rather than introducing a second package policy. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement accepts only additive package sets and never mutates a live table entry by entry.
|
||||
The runtime design keeps installation-first, ordered-bundle, and local-before-fallback precedence. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement accepts only additive package sets and never mutates a live table entry by entry.
|
||||
|
||||
## Decision
|
||||
|
||||
Profile startup computes one immutable `ResolutionGeneration` from the same dependency traversal that supplies the disk module fallback. The launcher defaults to runtime mode, which installs the generation into Node's ESM and CommonJS resolvers without materializing fallback links. Plain Node callers and tests can explicitly select link mode to materialize the generation or dual mode to materialize and verify it. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement.
|
||||
Profile startup computes one immutable `RuntimeResolution` and installs it into Node's ESM and CommonJS resolvers. Runtime is the only resolution backend; there is no mode selector or disk materializer. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement.
|
||||
|
||||
### One selection algorithm
|
||||
|
||||
The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Ordinary Node callers can select link, dual, or runtime mode, while an omitted mode selects runtime. Packaged executables and the Electron Host select runtime mode because their dependency trees may live in a virtual filesystem; dual remains an internal comparison path.
|
||||
The package traversal belongs to `@deepseek-ai/dsh-app-boot` beside profile loading. Ordinary Node, source launches, packaged executables, and the Electron Host consume the same runtime resolution and interception.
|
||||
|
||||
The installation manifest is the first root. Its graph traverses `dependencies` followed by `peerDependencies` breadth-first, resolving each edge from the manifest that declares it. The first installed package reached under a name owns that name. Selected bundle roots then run in profile order, with each earlier root's complete graph taking precedence over every later root. Names supplied by the installation are reserved, and bundle package roots themselves do not become plugin fallbacks. Missing declared packages are skipped as before.
|
||||
The installation manifest is the first root. Its graph traverses `dependencies` followed by `peerDependencies` breadth-first, resolving each edge from the manifest that declares it. The first installed package reached under a name owns that name. Selected bundle roots then run in profile order, with each earlier root's complete graph taking precedence over every later root. Names supplied by the installation are reserved, and bundle package roots themselves do not become plugin fallbacks. Missing declared packages are skipped.
|
||||
|
||||
Profile-local and plugin-private `node_modules` entries stay outside the fallback entries, and Node checks them before the virtual fallback position. The generation records only installed direct profile package names for a no-I/O native fast path. Each fallback entry records the package name, version, selected lookup directory, declaring manifest anchor, and scope needed to rerun Node's native resolution from the selected package and validate that a successor preserves existing mappings.
|
||||
During dependency expansion, every installation root, selected bundle root, and recursive dependency manifest uses its package's real directory as the lookup anchor. Ancestor `node_modules` searches therefore follow the location from which Node normally executes the package, not the location of a symlink pointing to it. The same declaring anchor is recorded for runtime delegation. This can change the selected version or remove an otherwise discoverable dependency when logical and real ancestors differ; it is not merely a spelling change to stored paths.
|
||||
|
||||
The existing `healProfilesModuleFallback()` remains as the disk materializer for the same computed result, which permits direct comparison without rewriting the selection rules. Named profile launches use it only in explicit link or dual mode. Runtime mode computes the generation without materializing it, while dual mode materializes and installs that generation for comparison.
|
||||
Profile-local and plugin-private `node_modules` entries take precedence over runtime resolution entries. The runtime resolution records installed direct profile package names for a no-I/O native fast path. Each entry records the package name, version, selected lookup directory, declaring manifest anchor, and scope needed for native resolution and successor validation.
|
||||
|
||||
The profile-scope entries of the runtime resolution remain necessary for dependencies that the profile cannot find through its own ancestor directories. They expand installed manifest dependencies into the active profile's runtime resolution, not a separate graph for each plugin. It neither downloads packages nor scans source imports. Runtime hooks consume that table after native local candidates have been considered.
|
||||
|
||||
### Ordinary and linked package examples
|
||||
|
||||
In both layouts below, the profile selects `my-bundle`, whose manifest declares `bridge`; only `bridge` declares `leaf`. Neither the installation nor the profile's direct packages supplies `leaf`, and `bridge` has no private `node_modules/leaf`. The paths illustrate the two layouts exercised by the [shared CLI profile tests](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts).
|
||||
|
||||
With ordinary installed directories, `bridge` searches its containing bundle's `node_modules` and finds version 1.0.0. The unrelated version 2.0.0 is not on that search path:
|
||||
|
||||
```text
|
||||
/case/home/profiles/headless/node_modules/my-bundle/
|
||||
node_modules/bridge/
|
||||
node_modules/leaf/ # 1.0.0
|
||||
/case/dependencies/node_modules/leaf/ # 2.0.0
|
||||
```
|
||||
|
||||
With an npm-link-style bundle and a linked transitive dependency, the bundle expands from `/case/work/my-bundle`, and the next dependency expansion starts at `/case/dependencies/bridge`. That expansion finds version 2.0.0 beside the real package:
|
||||
|
||||
```text
|
||||
/case/home/profiles/headless/node_modules/my-bundle -> /case/npm-global/node_modules/my-bundle
|
||||
/case/npm-global/node_modules/my-bundle -> /case/work/my-bundle
|
||||
/case/work/my-bundle/node_modules/bridge -> /case/dependencies/bridge
|
||||
/case/work/my-bundle/node_modules/leaf/ # 1.0.0
|
||||
/case/dependencies/node_modules/leaf/ # 2.0.0
|
||||
```
|
||||
|
||||
| Profile package layout | Declaring directory used to resolve `leaf` | `tsx/esm` source launch | Plain Node `lib` launch |
|
||||
|---|---|---|---|
|
||||
| Ordinary directories | `/case/home/profiles/headless/node_modules/my-bundle/node_modules/bridge` | 1.0.0 | 1.0.0 |
|
||||
| Linked bundle and bridge | `/case/dependencies/bridge` | 2.0.0 | 2.0.0 |
|
||||
|
||||
ESM `import` and CommonJS `require` select these versions. Within each module format, importing `leaf` through profile fallback and importing it inside `bridge` reaches the same module instance. A higher-priority native profile package or an installation-reserved fallback name still wins according to the selection rules; the fallback does not force every importer to use version 2.0.0.
|
||||
|
||||
### Source and built module identity
|
||||
|
||||
The [source launcher](2026-07-29-dsh-source-launch-tsx-esm.md) uses tsx's ESM-only hook. tsx skips tsconfig `paths` for an importer URL containing `/node_modules/`. A logical workspace symlink used as a fallback declaring anchor can therefore select built `lib/` exports, while imports from the resulting real workspace files select `src/` through the paths map. Real declaring anchors let workspace imports follow the source map consistently; packages without a matching workspace mapping keep ordinary package-export resolution.
|
||||
|
||||
For example, `@deepseek-ai/dsh-tools` creates its scheduler key with `Symbol()`. A Tools instance loaded from `lib/` cannot expose that scheduler through a key imported from the separate `src/` module instance. Source launches keep Tools and AgentLoop in `src/`; plain Node launches keep them in `lib/`. The scheduler retains its local Symbol; correct imports share one module instance.
|
||||
|
||||
### Immutable generations
|
||||
|
||||
A resolver registration holds one `current` generation. Each synchronous resolution captures that reference once. Generation construction reads every required manifest before publication; an error leaves the current generation unchanged. Successful publication replaces one reference, and in-flight calls may finish against the generation they captured.
|
||||
A runtime interception holds one `current` generation. Each synchronous resolution captures that reference once. Generation construction reads every required manifest before publication; an error leaves the current generation unchanged. Successful publication replaces one reference, and in-flight calls may finish against the generation they captured.
|
||||
|
||||
Selection and package-metadata caches belong to a generation. Publishing a successor invalidates them by making the old generation unreachable after its callers finish; update code does not mutate or clear individual entries. A generation hit and a successful native selection can be cached, but a generation miss is rescanned so a profile-local package installed after the miss becomes visible as it does in link mode. Calls with explicit CommonJS paths or non-default conditions never reuse a default-resolution cache entry.
|
||||
Selection and package-metadata caches belong to a generation. Publishing a successor invalidates them by making the old generation unreachable after its callers finish; update code does not mutate or clear individual entries. A generation hit and a successful native selection can be cached, but a generation miss is rescanned so a profile-local package installed after the miss becomes visible through native lookup. Calls with explicit CommonJS paths or non-default conditions never reuse a default-resolution cache entry.
|
||||
|
||||
The launcher constructs one startup generation. The service accepts an additive successor, but no package-manager transaction invokes replacement in this implementation.
|
||||
|
||||
@@ -36,7 +74,7 @@ The launcher constructs one startup generation. The service accepts an additive
|
||||
|
||||
The resolver uses `node-addon-require-builtin` to read `internal/modules/esm/loader` and `internal/modules/cjs/loader`. The ESM adapter wraps the per-thread singleton `CascadedLoader` resolve methods. The CommonJS adapter wraps the internal builtin's `Module._resolveFilename`; that `Module` is the same object exported by `node:module`.
|
||||
|
||||
Both adapters call one routing function. It ignores builtins, relative or absolute paths, URLs, parents outside the profile scope, and explicit calls outside the supported lookup. A `#imports` request uses Node's mapping from its owning manifest; an external bare target follows the same local, generation, and after-fallback package order with the request's conditions, while Node retains exact target resolution. For a scoped bare request, a package self-reference keeps the original parent even when an npm alias gives its installed directory another name. A profile-local or plugin-private package also keeps the original parent when Node resolves the requested entry before the virtual shared-fallback position; a CommonJS package directory without `exports` does not suppress the fallback when only its requested subpath is absent. Otherwise the router uses a generation hit through that entry's declaring anchor or continues native lookup after the virtual fallback. Explicit CommonJS path lists apply the same insertion rule independently to each path in caller order.
|
||||
Both adapters call one routing function. It ignores builtins, relative or absolute paths, URLs, parents outside the profile scope, and explicit calls outside the supported lookup. A `#imports` request uses Node's mapping from its owning manifest; an external bare target follows the same local, interception, and native-after-interception package order with the request's conditions, while Node retains exact target resolution. For a scoped bare request, a package self-reference keeps the original parent even when an npm alias gives its installed directory another name. A profile-local or plugin-private package also keeps the original parent when Node resolves the requested entry before the virtual shared-fallback position; a CommonJS package directory without `exports` does not suppress the fallback when only its requested subpath is absent. Otherwise the router uses an interception hit through that entry's declaring anchor or continues native lookup after the virtual fallback. Explicit CommonJS path lists apply the same insertion rule independently to each path in caller order.
|
||||
|
||||
The adapters call the captured native resolver after routing. Node remains responsible for exports, import and require conditions, main files, subpaths, extensions, native caches, and error codes. Routed ESM failures replace the internal lookup anchor in Node's diagnostic with the original importer. A selected package's invalid export or missing target does not trigger another same-name candidate. CommonJS does not replace `_findPath` or reproduce `_resolveFilename`.
|
||||
|
||||
@@ -44,11 +82,11 @@ The guarantee covers Node's default `import`, `import()`, `import.meta.resolve`,
|
||||
|
||||
### Active plugin list and package metadata
|
||||
|
||||
The resolution generation lists available fallback packages; Loader entries form the active plugin list. Consumers keep using Loader's existing entry lifecycle and filter the entries relevant to their own scope. Consumers that need package metadata pass a specifier and owning tree base URL to a lightweight `app-boot` service without requiring a `./package.json` export. An installed generation is authoritative, including a miss; a service created without a generation retains native lookup for low-level embedders.
|
||||
The runtime resolution lists the packages it supplies; Loader entries form the active plugin list. Consumers keep using Loader's existing entry lifecycle and filter the entries relevant to their own scope. Consumers that need package metadata pass a specifier and owning tree base URL to a lightweight `app-boot` service without requiring a `./package.json` export. An installed runtime resolution is authoritative, including a miss; a service created without one retains native lookup for low-level embedders.
|
||||
|
||||
The resolver does not expose `imported(entry)` and does not observe ModuleJobs, wrap Entry methods, associate fibers with import calls, replace registry or tree methods, or adapt HMR transactions. A repeated query uses the same generation and therefore cannot drift from the route used for the import. Non-Node importers that need package metadata must explicitly implement the same deterministic resolver interface.
|
||||
|
||||
The implementation lives under `app-boot/src/profile-resolution/`. `service.ts` provides the long-lived `ctx.pluginPackages` and owns the main-thread resolver and Worker-generation lifetimes; `resolver.ts` implements generation lookup and the Node Internal adapters; `worker-bootstrap.ts` installs an inherited generation in one thread. Existing profile selection and disk materialization remain in `profile.ts`. Workers reference the bootstrap only through the public `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` export.
|
||||
The implementation lives under `app-boot/src/profile-resolution/`. `service.ts` provides the long-lived `ctx.pluginPackages` and owns the main-thread interception and the Worker resolution lifetime; `resolver.ts` implements runtime resolution lookup and the Node Internal adapters; `worker-bootstrap.ts` installs the inherited runtime resolution in one thread. Profile selection and runtime resolution construction remain in `profile.ts`. Workers reference the bootstrap only through the public `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` export.
|
||||
|
||||
The service definition and provider remain together in `app-boot` because profile boot owns the resolver lifetime. Extracting a separate capability seam becomes warranted when a launcher-independent provider or independently evolving consumers require it.
|
||||
|
||||
@@ -64,21 +102,11 @@ A caller adding a package completes its pnpm transaction before constructing a s
|
||||
|
||||
Replacing, upgrading, or removing an already loaded package requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. Generation replacement does not claim to unload modules.
|
||||
|
||||
### Disk migration
|
||||
### Filesystem and runtime carriers
|
||||
|
||||
Runtime-only launch paths do not create, update, or retire symlinks and proxy packages. The resolver treats the legacy shared fallback and `.dsh-module-fallback` projections as virtual insertion positions: a generation hit uses the table target, while a miss skips those old positions before continuing native ancestor lookup. During a dual phase, the launcher materializes and installs the same generation; tests disable each backend in turn and compare their targets.
|
||||
The resolver does not create, update, or remove fallback symlinks and proxy packages. Runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`; every other name sees that directory as an ordinary ancestor, and symlinks anywhere in the profile tree are ordinary filesystem content without dedicated recognition. Profile-local package metadata and bundle dependency discovery follow the same Node lookup; ordinary pnpm-installed packages retain native precedence. The [lookup-order Note](2026-09-19-profile-resolution-lookup-order.md) records the complete order. Writable profile state and package-manager transactions remain outside the resolver.
|
||||
|
||||
Legacy disk state remains available to link-only launches, old processes, and rollback without participating in runtime-only selection. Removing that state is a separate maintenance operation outside this change.
|
||||
|
||||
### Mode behavior
|
||||
|
||||
Link, dual, and runtime modes use the same generation schema and dependency-selection policy. Link mode persists the computed result, runtime mode installs it only in the process, and dual mode requires Node's materialized result to equal the generation route.
|
||||
|
||||
The `dsh` launcher selects runtime mode when an ordinary Node caller omits `resolutionMode`. A pkg executable always selects runtime mode, and the Electron Host explicitly selects runtime mode in both development and packaged builds before any profile row mounts. Plain Node tests and low-level embedders can explicitly select link, dual, or runtime.
|
||||
|
||||
Runtime mode requires a supported Node Internal loader interface and does not create, update, or retire fallback links. Dual mode retains link writes and fails when Node's disk result differs from the generation. Writable profile state and package-manager transactions remain outside the resolver.
|
||||
|
||||
Pkg and Electron carriers force runtime resolution. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`; packaged builds read the dsh tree from ASAR and map executable ASAR entries to electron-builder's unpacked tree. Their runtime resolvers do not create, update, or remove legacy resolution links.
|
||||
Runtime resolution requires a supported Node Internal loader interface. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`; packaged builds read the dsh tree from ASAR and map executable ASAR entries to electron-builder's unpacked tree. Pkg and Electron use the same runtime resolution mechanism as ordinary Node launches.
|
||||
|
||||
### Performance and verification
|
||||
|
||||
@@ -86,11 +114,13 @@ Generation construction is startup or update work, not resolve work, and its abs
|
||||
|
||||
One-off local measurements taken during implementation ran built JavaScript under plain Node in fresh processes and compared it with a process that installed no hook. The measurement script and results are not committed, and these figures are not a benchmark or CI budget. Seven alternating rounds covered outside, profile-local, and fallback imports through dynamic import, `import.meta.resolve`, require, and `require.resolve`. Across Node 22.19, 24.18, and 26.8, the largest positive hot-path median was 4.5%. On Node 24.18, a 256-package cold workload regressed by at most 11.2% and generation construction took 16.027 ms median; the 32-package local `require.resolve` case added 1.033 ms across the batch (+34.7%) from fixed startup cost.
|
||||
|
||||
Behavior tests compare the runtime generation with the disk materializer over the same package trees, then exercise root order, transitive and peer dependencies, local and external precedence, exports and subpath errors, conditions, and explicit CommonJS options. The Node compatibility matrix runs the resolver, service, and bootstrap specifications across the supported internal-loader variants. Worker tests verify environment-data publication and bootstrap installation with mocked thread and native-loader interfaces; they do not launch a built Worker. Generation tests prove failed construction does not publish partial state and successful replacement is atomic.
|
||||
Behavior tests exercise root order, transitive and peer dependencies, local and external precedence, exports and subpath errors, conditions, and explicit CommonJS options. The Node compatibility matrix runs the resolver, service, and bootstrap specifications across the supported internal-loader variants. Worker tests verify environment-data publication and bootstrap installation with mocked thread and native-loader interfaces; they do not launch a built Worker. Generation tests prove failed construction does not publish partial state and successful replacement is atomic.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep disk projections permanently.** This preserves native lookup without process hooks, but retains cross-process mutation, stale generations, proxy manifests, writer locks, and packaged-runtime divergence. A bounded dual migration remains useful because both backends consume the same generation.
|
||||
**Keep disk projections permanently.** This preserves native lookup without process hooks, but retains cross-process mutation, stale generations, proxy manifests, writer locks, and packaged-runtime divergence. Retaining link and dual as comparison modes would also keep the materializer and its lifecycle without a supported launcher that needs them.
|
||||
|
||||
**Preserve logical symlink anchors.** This keeps lookup under the symlink's ancestors, but can select a different dependency from the one imported by the real package and can suppress tsx's workspace paths map. Canonical anchors keep eager dependency discovery and runtime delegation aligned with the package's execution location.
|
||||
|
||||
**Expand the dependency graph lazily during resolve.** This spreads manifest reads and errors across first-use calls, changes timing from the disk implementation, complicates Worker startup, and makes the hot path depend on graph size. Complete generation construction is easier to compare and replace atomically.
|
||||
|
||||
@@ -104,15 +134,15 @@ Behavior tests compare the runtime generation with the disk materializer over th
|
||||
|
||||
## Verification
|
||||
|
||||
- One eager computation supplies the retained disk materializer and runtime generation.
|
||||
- Link-only, dual, and runtime-only tests consume the same generation; runtime startup neither writes nor retires module-resolution data.
|
||||
- One eager computation supplies the runtime resolution; startup neither writes nor retires module-resolution data.
|
||||
- [Generation tests](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts) cover installation and selected-bundle graphs with ordinary directories and recursive symlinks, including different dependency versions beside logical and real anchors.
|
||||
- [Source-launch tests](../../../../apps/cli/tests/source-launch.compat.spec.ts) and [built-bin tests](../../../../apps/cli/tests/built-bin.e2e.ts) run both profile layouts through the real CLI. They assert ESM/CJS versions, loaded paths, per-format dependency identity, and consistent Tools/AgentLoop module instances with an accessible scheduler key.
|
||||
- Pkg and Electron carriers select runtime resolution; Electron executes its Host in Node mode from the ASAR-backed dsh tree while native executable entries remain unpacked.
|
||||
- ESM and CommonJS adapters share one router and delegate final resolution to Node without `module.registerHooks` or `_findPath` replacement.
|
||||
- Production metadata lookup does not record Loader import results or wrap Entry, registry, tree, or HMR methods.
|
||||
- The Node compatibility matrix runs main-thread resolver specifications across supported loader interfaces; service and bootstrap specifications cover Worker environment-data and installation interfaces without launching a built Worker.
|
||||
- One-off built plain-Node measurements produced the hot and cold observations above against no-hook Node; the script and results are not committed evidence.
|
||||
- Package READMEs, architecture references, generated catalogs, and the bilingual pair describe the shipped implementation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Runtime is the ordinary Node launcher default, link and dual remain explicit comparison options, and pkg plus Electron carriers force runtime resolution without the resolver retiring old links. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart.
|
||||
Runtime startup avoids disk mutation and proxy manifests while retaining package-precedence rules. Real-directory anchors align dependency discovery with default Node loading and tsx workspace mapping, including cases where a logical symlink path would select another version. The implementation accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker; it provides no disk-only backend or dual comparison mode. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart.
|
||||
+61
-31
@@ -6,29 +6,67 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。通过共享 symlink、profile 自有链接或打包可执行文件的代理包连接两棵依赖树,会让选包结果跨进程和安装版本持续存在。这些文件需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest,也无法原子表示进程内变更。
|
||||
profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。通过共享 symlink、profile 自有链接或打包可执行文件的代理包连接两棵依赖树,会让选包结果跨进程和安装版本持续存在。这些文件需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest(元数据清单),也无法原子表示进程内变更。
|
||||
|
||||
运行时设计保留现有选包规则,不另建一套包策略。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换只接受新增包的集合,不会逐项修改正在使用的表。
|
||||
运行时设计保留安装优先、有序 bundle 和本地包优先于 fallback 的顺序。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换只接受新增包的集合,不会逐项修改正在使用的表。
|
||||
|
||||
## Decision
|
||||
|
||||
profile 启动从磁盘 module fallback 使用的同一套依赖遍历生成一个不可变 `ResolutionGeneration`。launcher 默认使用 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS 解析器,不物化 fallback 链接。普通 Node 调用方和测试可以显式选择 link 模式以物化 generation,或选择 dual 模式以物化并校验它。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。
|
||||
profile 启动生成一个不可变 `RuntimeResolution`,并将其安装到 Node 的 ESM 与 CommonJS 解析器。runtime 是唯一的解析后端,不提供模式选择器或磁盘物化器。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。
|
||||
|
||||
### 唯一选包算法
|
||||
|
||||
包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。普通 Node 调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 runtime。打包可执行文件与 Electron Host 会选择 runtime,因为其依赖树可能位于虚拟文件系统;dual 保留为内部对比路径。
|
||||
包遍历属于 `@deepseek-ai/dsh-app-boot`,与 profile 加载代码放在一起。普通 Node、源码启动、打包可执行文件和 Electron Host 消费相同的 runtime resolution 与拦截。
|
||||
|
||||
安装 manifest 是第一个根。它按 BFS 依次遍历 `dependencies` 和 `peerDependencies`,每条边从声明它的 manifest 解析,同名包由第一次找到的已安装包占有。所选 bundle 随后按 profile 顺序逐根遍历;每个较早根的完整依赖图优先于所有较晚根。安装闭包中的名称被保留,bundle 包根本身不成为插件 fallback。与旧行为相同,已声明但未安装的包会被跳过。
|
||||
安装 manifest 是第一个根。它按 BFS 依次遍历 `dependencies` 和 `peerDependencies`,每条边从声明它的 manifest 解析,同名包由第一次找到的已安装包占有。所选 bundle 随后按 profile 顺序逐根遍历;每个较早根的完整依赖图优先于所有较晚根。安装闭包中的名称被保留,bundle 包根本身不成为插件 fallback。已声明但未安装的包会被跳过。
|
||||
|
||||
profile 本地和插件私有 `node_modules` 不进入 fallback entries,由 Node 在虚拟 fallback 位置之前选择。generation 只记录已安装的 profile 直接包名用于 native 快速分流;每个 fallback 记录包名、版本、旧规则选中的查找目录、声明该边的 manifest 锚点和作用域,足以从选定包重新进入 Node 原生解析并验证换代保持既有映射。
|
||||
依赖展开时,每个安装根、所选 bundle 根和递归依赖 manifest 都以所属包的真实目录作为查找锚点。因此,祖先 `node_modules` 查找沿 Node 通常执行该包的位置进行,而不是沿指向该包的软链接位置进行。同一声明锚点会被记录下来,供运行时委托解析使用。逻辑路径与真实路径的祖先不同时,这可能改变所选版本,也可能使原本能发现的依赖不再被收录;它不只是对已存路径换一种写法。
|
||||
|
||||
旧 `healProfilesModuleFallback()` 保留为相同纯计算结果的磁盘 materializer,便于直接比较并避免重写旧规则。具名 profile 启动只在显式 link 或 dual 模式下调用它。runtime 模式只计算 generation 而不物化,dual 模式会物化并安装该 generation 进行比较。
|
||||
profile 本地和插件私有 `node_modules` 条目优先于 runtime resolution 条目。runtime resolution 记录已安装的 profile 直接包名,用于无 I/O 的原生快速分流。每个条目记录包名、版本、选定的查找目录、声明该边的 manifest 锚点和作用域,供原生解析与后继 generation 校验使用。
|
||||
|
||||
对于 profile 无法通过自身祖先目录找到的依赖,runtime resolution 的 profile 作用域条目仍然必要。它们将已安装 manifest 中的依赖展开到当前 profile 的 runtime resolution,而不是为每个插件建立独立依赖图。它既不下载包,也不扫描源码 import。运行时钩子在考虑原生本地候选后使用这张表。
|
||||
|
||||
### 普通安装与软链接示例
|
||||
|
||||
下面两种布局中,profile 都选中 `my-bundle`,其 manifest 声明 `bridge`;只有 `bridge` 声明 `leaf`。安装包集合和 profile 的直接包都不提供 `leaf`,`bridge` 也没有私有 `node_modules/leaf`。这些路径示意了[共享 CLI(命令行界面)profile 测试](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts)覆盖的两种布局。
|
||||
|
||||
普通安装目录中,`bridge` 向上查找所属 bundle 的 `node_modules`,找到版本 1.0.0。另一个版本 2.0.0 不在这条查找路径上:
|
||||
|
||||
```text
|
||||
/case/home/profiles/headless/node_modules/my-bundle/
|
||||
node_modules/bridge/
|
||||
node_modules/leaf/ # 1.0.0
|
||||
/case/dependencies/node_modules/leaf/ # 2.0.0
|
||||
```
|
||||
|
||||
bundle 采用 npm-link 风格链接,且其传递依赖也被软链接时,bundle 从 `/case/work/my-bundle` 展开,下一层依赖则从 `/case/dependencies/bridge` 开始展开。该次展开找到真实包旁边的版本 2.0.0:
|
||||
|
||||
```text
|
||||
/case/home/profiles/headless/node_modules/my-bundle -> /case/npm-global/node_modules/my-bundle
|
||||
/case/npm-global/node_modules/my-bundle -> /case/work/my-bundle
|
||||
/case/work/my-bundle/node_modules/bridge -> /case/dependencies/bridge
|
||||
/case/work/my-bundle/node_modules/leaf/ # 1.0.0
|
||||
/case/dependencies/node_modules/leaf/ # 2.0.0
|
||||
```
|
||||
|
||||
| Profile 包布局 | 解析 `leaf` 时使用的声明目录 | `tsx/esm` 源码启动 | 普通 Node `lib` 启动 |
|
||||
|---|---|---|---|
|
||||
| 普通目录 | `/case/home/profiles/headless/node_modules/my-bundle/node_modules/bridge` | 1.0.0 | 1.0.0 |
|
||||
| bundle 与 bridge 均为软链接 | `/case/dependencies/bridge` | 2.0.0 | 2.0.0 |
|
||||
|
||||
ESM `import` 和 CommonJS `require` 都选中这些版本。在每种模块格式内部,通过 profile fallback 导入 `leaf` 与在 `bridge` 内部导入它,会得到同一个模块实例。优先级更高的原生 profile 包,或安装集合已保留的同名 fallback,仍按选包规则优先;fallback 不会强制所有导入方都使用版本 2.0.0。
|
||||
|
||||
### 源码与构建产物的模块身份
|
||||
|
||||
[源码启动器](2026-07-29-dsh-source-launch-tsx-esm.zh.md)使用 tsx 的 ESM-only 钩子。导入方 URL 含有 `/node_modules/` 时,tsx 会跳过 tsconfig `paths`。因此,把 workspace 软链接的逻辑路径用作 fallback 声明锚点,可能选中构建后的 `lib/` 导出,而这些模块在真实 workspace 路径中发起的 import 又通过 paths 映射选中 `src/`。真实声明锚点让 workspace import 一致遵循源码映射;没有匹配 workspace 映射的包仍使用普通包导出解析。
|
||||
|
||||
例如,`@deepseek-ai/dsh-tools` 使用 `Symbol()` 创建 scheduler 键。从 `lib/` 加载的 Tools 实例,无法通过另一个 `src/` 模块实例导入的键暴露该 scheduler。源码启动让 Tools 与 AgentLoop 都位于 `src/`;普通 Node 启动让两者都位于 `lib/`。scheduler 保留模块本地的 Symbol;正确的 import 共享同一个模块实例。
|
||||
|
||||
### 不可变 generation
|
||||
|
||||
一个解析器 registration 持有一个 `current` generation。每个同步 resolve 在入口只捕获一次该引用,完整调用只读该引用。generation 构造在发布前读取所有必需 manifest;失败时当前 generation 不变。发布成功只替换一个引用,执行中的调用可以继续使用它已捕获的 generation。
|
||||
一个 runtime interception 持有一个 `current` generation。每个同步 resolve 在入口只捕获一次该引用,完整调用只读该引用。generation 构造在发布前读取所有必需 manifest;失败时当前 generation 不变。发布成功只替换一个引用,执行中的调用可以继续使用它已捕获的 generation。
|
||||
|
||||
选包缓存和包元数据缓存归 generation 所有。发布下一代后,旧 generation 在调用方退出后自然不可达,不逐项清理缓存。generation 命中和原生解析成功结果可以缓存,但 generation 未命中会重新扫描,因此未命中后安装的 profile 本地包会像 link 模式一样变为可见。显式 CommonJS paths 或非默认 conditions 不得复用默认解析缓存。
|
||||
选包缓存和包元数据缓存归 generation 所有。发布下一代后,旧 generation 在调用方退出后自然不可达,不逐项清理缓存。generation 命中和原生解析成功结果可以缓存,但 generation 未命中会重新扫描,因此未命中后安装的 profile 本地包会通过原生查找变为可见。显式 CommonJS paths 或非默认 conditions 不得复用默认解析缓存。
|
||||
|
||||
launcher 只构造启动 generation。服务接受新增型后继 generation,但本实现没有包管理器事务调用替换操作。
|
||||
|
||||
@@ -36,7 +74,7 @@ launcher 只构造启动 generation。服务接受新增型后继 generation,
|
||||
|
||||
resolver 使用 `node-addon-require-builtin` 读取 `internal/modules/esm/loader` 和 `internal/modules/cjs/loader`。ESM 适配器包装每线程单例 `CascadedLoader` 的 resolve 方法。CommonJS 适配器包装内部 builtin 导出的 `Module._resolveFilename`;该 `Module` 与 `node:module` 导出的对象相同。
|
||||
|
||||
两个适配器调用同一个路由函数。builtin、相对或绝对路径、URL、profile 作用域外 parent 和支持的查找以外的显式调用都直接委托原生实现。`#imports` 请求使用所属 manifest 中的 Node 映射;外部 bare target 按相同 conditions 遵循本地包、generation 和 after-fallback 的选包顺序,精确 target 解析仍由 Node 负责。对于作用域内的 bare request,package self-reference 保留原 parent,即使 npm alias 使安装目录使用另一个名称。Node 能在虚拟共享 fallback 之前从 profile 本地包或插件私有包解析到所请求入口时,也保留原 parent;没有 `exports` 的 CommonJS 包目录仅缺少所请求 subpath 时,不会压过 fallback。其他请求在 generation 命中时通过该条目的声明锚点解析,未命中时从虚拟 fallback 之后继续原生查找。显式 CommonJS path 列表按调用方顺序,对每个 path 独立应用相同的插入规则。
|
||||
两个适配器调用同一个路由函数。builtin、相对或绝对路径、URL、profile 作用域外 parent 和支持的查找以外的显式调用都直接委托原生实现。`#imports` 请求使用所属 manifest 中的 Node 映射;外部 bare target 按相同 conditions 遵循本地包、interception 和 native-after-interception 的选包顺序,精确 target 解析仍由 Node 负责。对于作用域内的 bare request,package self-reference 保留原 parent,即使 npm alias 使安装目录使用另一个名称。Node 能在虚拟共享 fallback 之前从 profile 本地包或插件私有包解析到所请求入口时,也保留原 parent;没有 `exports` 的 CommonJS 包目录仅缺少所请求 subpath 时,不会压过 fallback。其他请求在 interception 命中时通过该条目的声明锚点解析,未命中时从虚拟 fallback 之后继续原生查找。显式 CommonJS path 列表按调用方顺序,对每个 path 独立应用相同的插入规则。
|
||||
|
||||
适配器完成路由后调用捕获的原生 resolver。exports、import/require conditions、main、subpath、扩展名、原生缓存和错误码仍归 Node 处理。路由后的 ESM 失败会把 Node 诊断中的内部查找锚点替换为原始 importer。选中包的无效 export 或缺失目标不会触发另一个同名候选。CommonJS 不替换 `_findPath`,也不复制 `_resolveFilename`。
|
||||
|
||||
@@ -44,11 +82,11 @@ resolver 使用 `node-addon-require-builtin` 读取 `internal/modules/esm/loader
|
||||
|
||||
### 活动插件列表与包元数据
|
||||
|
||||
resolution generation 列出可用 fallback 包;Loader entries 组成活动插件列表,两者不能合并。消费方继续使用 Loader 原有 entry 生命周期,并按自身 scope 过滤相关 entries。需要 package metadata 的消费方将 specifier 和所属树的 base URL 交给 app-boot 中的轻量服务,无需 package 导出 `./package.json`。安装 generation 后,即使查询未命中也以 generation 为准;底层嵌入方只安装服务而不提供 generation 时,服务保留 Node 原生查找。
|
||||
runtime resolution 列出它提供的包;Loader entries 组成活动插件列表,两者不能合并。消费方继续使用 Loader 原有 entry 生命周期,并按自身 scope 过滤相关 entries。需要 package metadata 的消费方将 specifier 和所属树的 base URL 交给 app-boot 中的轻量服务,无需 package 导出 `./package.json`。安装 runtime resolution 后,即使查询未命中也以它为准;底层嵌入方只安装服务而不提供 runtime resolution 时,服务保留 Node 原生查找。
|
||||
|
||||
解析器不提供 `imported(entry)`,不观察 ModuleJob,不包装 Entry 方法,不把 fiber 与 import 调用关联,也不替换 registry、tree 或 HMR 方法。重复查询读取同一个 generation,因此不会偏离 import 使用的路线。需要包元数据的非 Node importer 必须显式实现同一个确定性 resolver 接口,不能把调用来源推断重新引入 Node 主路径。
|
||||
|
||||
实现集中在 `app-boot/src/profile-resolution/`。`service.ts` 提供长期存在的 `ctx.pluginPackages`,并拥有主线程 resolver 与 Worker generation 的生命周期;`resolver.ts` 实现 generation 查询和 Node Internal 适配器;`worker-bootstrap.ts` 在线程内安装继承的 generation。旧 profile 选包和磁盘 materialize 逻辑留在 `profile.ts`。Worker 只通过 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` 公开入口引用 bootstrap。
|
||||
实现集中在 `app-boot/src/profile-resolution/`。`service.ts` 提供长期存在的 `ctx.pluginPackages`,并拥有主线程拦截与 Worker runtime resolution 的生命周期;`resolver.ts` 实现 runtime resolution 查询和 Node Internal 适配器;`worker-bootstrap.ts` 在线程内安装继承的 runtime resolution。profile 选包和 runtime resolution 构造留在 `profile.ts`。Worker 只通过 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` 公开入口引用 bootstrap。
|
||||
|
||||
服务定义与提供方继续放在 `app-boot`,因为 profile boot 拥有 resolver 生命周期。出现与 launcher 无关的提供方或需要独立演进的消费方时,再抽出单独的能力 seam。
|
||||
|
||||
@@ -64,21 +102,11 @@ resolution generation 列出可用 fallback 包;Loader entries 组成活动插
|
||||
|
||||
替换、升级或删除已加载包需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。generation 换代不声称卸载模块。
|
||||
|
||||
### 磁盘迁移
|
||||
### 文件系统与运行时载体
|
||||
|
||||
runtime-only 启动流程不创建、更新或退休 symlink 和代理包。resolver 把旧共享 fallback 和 `.dsh-module-fallback` 投影视为虚拟插入位置:generation 命中时使用表中目标,未命中时越过旧位置继续原生祖先查找。dual 阶段物化并安装同一个 generation;测试分别禁用一个后端并比较目标。
|
||||
解析器不创建、更新或删除 fallback 软链接与代理包。runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置;其余包名把该目录当作普通祖先,profile 树内任何位置的软链接都是普通文件系统内容,没有专门识别。profile 本地包元数据与 bundle 依赖展开遵循同一套 Node 查找;正常由 pnpm 安装的包保留原生优先级。完整顺序见[查找顺序 Note](2026-09-19-profile-resolution-lookup-order.zh.md)。可写 profile 状态和包管理器事务不属于解析器。
|
||||
|
||||
旧磁盘状态继续供 link-only 启动、旧进程和回滚使用,但不参与 runtime-only 选择。清理旧链接是本次变更之外的独立维护操作。
|
||||
|
||||
### 模式行为
|
||||
|
||||
link、dual 与 runtime 模式使用同一种 generation schema 和依赖选择策略。link 模式持久化计算结果,runtime 模式只在进程内安装,dual 模式要求 Node 的磁盘结果与 generation 路由一致。
|
||||
|
||||
普通 Node 调用方省略 `resolutionMode` 时,`dsh` launcher 选择 runtime 模式。pkg 可执行文件始终选择 runtime,Electron Host 在开发与打包构建中也会在挂载任何 profile 条目前显式选择 runtime。普通 Node 测试与底层嵌入方可以显式选择 link、dual 或 runtime。
|
||||
|
||||
runtime 模式要求受支持的 Node Internal loader 接口,并且不会创建、更新或退休 fallback 链接。dual 模式保留链接写入,并在 Node 的磁盘结果与 generation 不同时失败。可写 profile 状态和包管理器事务不属于 resolver。
|
||||
|
||||
pkg 与 Electron 载体强制使用 runtime 解析。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行;打包构建从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。它们的运行时解析器不会创建、更新或删除旧解析链接。
|
||||
运行时解析要求受支持的 Node Internal loader 接口。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行;打包构建从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。pkg 与 Electron 使用和普通 Node 启动相同的 runtime resolution 机制。
|
||||
|
||||
### 性能与验证
|
||||
|
||||
@@ -86,11 +114,13 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
|
||||
|
||||
实现期间的一次性本地测量用 plain Node 在全新进程中执行构建后的 JavaScript,并与完全没有安装 hook 的进程比较。测量脚本和结果未提交,这些数据不是 benchmark 或 CI 预算。七轮交替顺序覆盖 outside、profile-local 和 fallback 的 dynamic import、`import.meta.resolve`、require、`require.resolve`。Node 22.19、24.18 和 26.8 的热路径中位数最大正向回退为 4.5%。Node 24.18 的 256 包 cold workload 最大回退为 11.2%,generation 构造中位数为 16.027 ms;32 包本地 `require.resolve` 因固定启动成本在整批增加 1.033 ms(+34.7%)。
|
||||
|
||||
行为测试在同一包树上比较运行时 generation 与磁盘 materializer,再覆盖根顺序、传递依赖和 peer、本地与外层优先级、exports 与 subpath 错误、conditions 和显式 CommonJS options。Node 兼容矩阵会在受支持的内部 loader 变体上运行 resolver、service 和 bootstrap 规格。Worker 测试通过 mock 线程与 native loader 接口验证 environment data 发布和 bootstrap 安装,但不会启动构建后的 Worker。generation 测试证明构造失败不发布部分状态,成功换代只做原子引用替换。
|
||||
行为测试覆盖根顺序、传递依赖和 peer、本地与外层优先级、exports 与 subpath 错误、conditions 和显式 CommonJS options。Node 兼容矩阵会在受支持的内部 loader 变体上运行解析器、服务和 bootstrap 规格。Worker 测试通过 mock 线程与原生 loader 接口验证 environment data 发布和 bootstrap 安装,但不会启动构建后的 Worker。generation 测试证明构造失败不发布部分状态,成功换代只做原子引用替换。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**永久保留磁盘投影。** 这能在没有进程 hook 时沿用原生查找,但仍有跨进程写入、陈旧 generation、代理 manifest、写锁和打包运行时差异。迁移期 dual 模式仍有价值,因为两个后端消费同一个 generation。
|
||||
**永久保留磁盘投影。** 这能在没有进程钩子时沿用原生查找,但仍有跨进程写入、陈旧 generation、代理 manifest、写锁和打包运行时差异。把 link 与 dual 保留为对比模式,也会留下没有受支持启动器需要的物化器及其生命周期。
|
||||
|
||||
**保留软链接逻辑锚点。** 这会沿软链接的祖先查找,但可能选中与真实包内部 import 不同的依赖,也可能抑制 tsx 的 workspace paths 映射。规范化锚点让 eager 依赖发现与运行时委托解析都对齐包的执行位置。
|
||||
|
||||
**在 resolve 时惰性扩展依赖图。** 这会把 manifest 读取和错误分散到首次使用,改变磁盘实现的时机,使 Worker 启动更复杂,并让热路径成本随依赖图变化。完整构造 generation 更容易比较和原子替换。
|
||||
|
||||
@@ -104,15 +134,15 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
|
||||
|
||||
## Verification
|
||||
|
||||
- 一次 eager 计算同时供应保留的磁盘 materializer 和运行时 generation。
|
||||
- link-only、dual 和 runtime-only 测试消费同一个 generation;runtime 启动既不写入也不退休模块解析数据。
|
||||
- 一次 eager 计算供应 runtime resolution;启动既不写入也不退休模块解析数据。
|
||||
- [Generation 测试](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts)覆盖普通目录和递归软链接下的安装图与所选 bundle 图,包括逻辑锚点和真实锚点旁存在不同依赖版本的情况。
|
||||
- [源码启动测试](../../../../apps/cli/tests/source-launch.compat.spec.ts)与[构建入口测试](../../../../apps/cli/tests/built-bin.e2e.ts)通过真实 CLI 运行两种 profile 布局,断言 ESM/CJS 版本、加载路径、各模块格式内的依赖身份,以及一致的 Tools/AgentLoop 模块实例和可访问的 scheduler 键。
|
||||
- pkg 与 Electron 载体选择 runtime 解析;Electron 以 Node 模式从 ASAR 承载的 dsh 依赖树执行 Host,原生可执行条目保持 unpacked。
|
||||
- ESM 与 CommonJS 适配器共享同一个路由器,并把最终解析委托给 Node,不使用 `module.registerHooks` 或替换 `_findPath`。
|
||||
- 生产 package metadata 查询不记录 Loader import 结果,也不包装 Entry、registry、tree 或 HMR 方法。
|
||||
- Node 兼容矩阵会在受支持的 loader 接口上运行主线程 resolver 规格;service 和 bootstrap 规格覆盖 Worker environment data 与安装接口,但不会启动构建后的 Worker。
|
||||
- 一次性 plain Node 构建产物测量得到上述相对无 hook Node 的热路径和 cold 观察结果;脚本与结果并未提交为证据。
|
||||
- package README、架构引用、生成目录和双语文档对描述已交付实现。
|
||||
|
||||
## Consequences
|
||||
|
||||
runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。runtime 是普通 Node launcher 的默认值,link 与 dual 保留为显式对比选项;pkg 与 Electron 载体强制使用 runtime,解析器不退休旧链接。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。
|
||||
runtime 启动避免磁盘修改和代理 manifest,同时保留包优先级规则。真实目录锚点让依赖发现对齐 Node 默认加载与 tsx workspace 映射,也覆盖软链接逻辑路径会选中另一版本的情况。实现需要持续维护 Node Internal 兼容测试,并在每个自有 Worker 中尽早执行自包含 bootstrap;它不提供纯磁盘后端或 dual 对比模式。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。
|
||||
@@ -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-10-desktop-web-wrapper.md
|
||||
2026-09-10-desktop-web-wrapper.md: 05210e2ab85f0afe6d0275e25f31ce8addf11332
|
||||
2026-09-10-desktop-web-wrapper.zh.md: 84e72c9261250b103245f974e1a0546a4898e9ca
|
||||
2026-09-10-desktop-web-wrapper.md: 4f43cc650777596c807cf79463edc7a9131db44b
|
||||
2026-09-10-desktop-web-wrapper.zh.md: bc41b4f8eff1dca7fc3dd9b934dac257a9d10a6b
|
||||
@@ -14,7 +14,7 @@ Separate Desktop composition and request transport require their own configurati
|
||||
|
||||
The private Desktop Host invokes the CLI's shared profile runner against the independently owned Desktop profile. The complete Web composition owns authentication, HTTP routes, client assets, RPC, and response streaming. Electron loads packaged static Web assets before the child is ready. Child IPC carries readiness, structured boot injections, and shutdown. The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns local-document HTTP forwarding and authenticated WebSocket access; Web retains application dispatch and stream framing.
|
||||
|
||||
The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template and uses its Plugin Manager in the main application. Electron owns windows, menus, native directory selection, recovery, and release updates.
|
||||
The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, the runtime resolution, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template and uses its Plugin Manager in the main application. Electron owns windows, menus, native directory selection, recovery, and release updates.
|
||||
|
||||
The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The public CLI continues to reject the reserved Desktop profile. Electron profile preparation and native recovery remain available without a working Host.
|
||||
|
||||
@@ -22,7 +22,7 @@ Independent package ownership prevents CLI and Desktop from modifying each other
|
||||
|
||||
The shared Web plugin manager owns installation, activation, errors, and restart requirements. Electron has no separate plugin-management renderer, preload, shell asset route, plugin IPC, or package-mutation runner. Packaging explicitly selects the main entry and application preload so stale build outputs cannot restore the removed bridge.
|
||||
|
||||
Shared `initProfile` creates missing profile files and preserves existing content. The Host’s `healIsolatedProfileModuleFallback` is the sole owner of installation and bundle projections; package operations use shared `unlinkProfileModuleFallback` to detach only its own links before pnpm. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism. One-time cleanup of `desktop-runtime-state.json` removes only matching recorded links and retires that metadata.
|
||||
Shared `initProfile` creates missing profile files and preserves existing content. The Host computes its runtime resolution with `createRuntimeResolution` and installs it through `PluginPackages`, without writing fallback links. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism.
|
||||
|
||||
This partially supersedes the private composition and portless transport in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That design avoided listening ports and used framed byte pipes to avoid Base64 expansion and cross-version V8 serialization. Shared HTTP gives up the portless guarantee and assigns serving and authentication to the existing Web implementation. Release identity, signing, process ownership, and native shell features remain active decisions.
|
||||
|
||||
@@ -36,7 +36,7 @@ Native directory selection in the local application uses a narrow preload IPC ca
|
||||
|
||||
**Merge CLI and Desktop plugin installations.** Shared boot code does not require shared executable dependencies. Separate installations allow independently qualified releases and plugin versions while their existing data owners govern shared sessions and settings.
|
||||
|
||||
**Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single fallback owner can protect pnpm directories without maintaining release identity in the plugin profile.
|
||||
**Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single owner of the runtime resolution can protect pnpm directories without maintaining release identity in the plugin profile.
|
||||
|
||||
**Keep a separate native plugin-management page.** It can operate while the Web Host is unavailable, but duplicates package operations, renderer IPC, localization, and backend restart handling. Native recovery already supports disabling third-party bundles and backing up the profile patch without the Host. Reintroduction requires a repair operation that this recovery cannot provide.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 在子进程就绪前加载打包静态 Web 资源。子进程 IPC 承载就绪、结构化启动注入与关闭。[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定本地文档 HTTP 转发与认证 WebSocket 访问;Web 保留应用分派与流帧处理。
|
||||
|
||||
共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载和应用生命周期。Desktop 从共享 Web 模板初始化 profile,并在主应用中使用其插件管理器。Electron 负责窗口、菜单、原生目录选择、恢复和发布更新。
|
||||
共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、runtime resolution、配置重载和应用生命周期。Desktop 从共享 Web 模板初始化 profile,并在主应用中使用其插件管理器。Electron 负责窗口、菜单、原生目录选择、恢复和发布更新。
|
||||
|
||||
[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。公开 CLI 继续拒绝保留的 Desktop profile。Electron profile 准备和原生恢复在 Host 不可用时仍可执行。
|
||||
|
||||
@@ -22,7 +22,7 @@ Status: implemented
|
||||
|
||||
共享 Web 插件管理器负责安装、激活、错误和重启要求。Electron 不提供独立插件管理渲染器、preload、shell 资源路由、插件 IPC 或包变更执行器。打包明确选择主入口和应用 preload,避免旧构建产物重新带入已删除的桥接。
|
||||
|
||||
共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 的 `healIsolatedProfileModuleFallback` 是安装包与 bundle 投影的唯一归属方;包操作在 pnpm 前通过共享 `unlinkProfileModuleFallback` 仅分离它自己拥有的链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。`desktop-runtime-state.json` 的一次性清理仅移除与记录匹配的链接,并清除该元数据。
|
||||
共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 通过 `createRuntimeResolution` 计算 runtime resolution,并通过 `PluginPackages` 安装它,不写入 fallback 链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。
|
||||
|
||||
本记录部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的私有组合与无端口传输。该设计避免监听端口,并使用分帧字节管道避免 Base64 膨胀与跨版本 V8 序列化。共享 HTTP 放弃无端口保证,将服务与认证交给已有 Web 实现。发布身份、签名、进程归属及原生壳功能仍是有效决策。
|
||||
|
||||
@@ -36,7 +36,7 @@ Status: implemented
|
||||
|
||||
**合并 CLI 与 Desktop 插件安装。** 共享启动代码不要求共享可执行依赖。独立安装允许分别验收发布与插件版本,共享会话和设置则仍由已有数据归属方负责。
|
||||
|
||||
**保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一模块补全归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。
|
||||
**保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一 runtime resolution 归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。
|
||||
|
||||
**保留独立原生插件管理页。** 它可以在 Web Host 不可用时运行,但重复维护包操作、渲染器 IPC、本地化和后端重启处理。原生恢复已能在没有 Host 时禁用第三方 bundle 并备份 profile patch。只有出现此恢复方式无法提供的修复操作时,才应重新引入。
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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-web-document-relative-app-routes.md
|
||||
2026-09-14-web-document-relative-app-routes.md: fbf30253bbfcc23eb09847f93c1b1f047459d31b
|
||||
2026-09-14-web-document-relative-app-routes.zh.md: c302ec88a4205239148132513d1ff2a4385033e8
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Web app-owned routes are document-relative
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-14-web-document-relative-app-routes.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Every browser reference to a shell-owned route was origin-root absolute: `/api/...` RPC, `/plugins/??...` plugin bundles, the Remote stream mux, the HMR event stream, and the launch entry's token-cleanup redirect to `/`. One listener served behind a prefix-stripping proxy (a mount such as `https://host/tools/dsh/` that forwards `/tools/dsh/...` as `/...`) therefore saw those requests at the origin root, where no route answers them: the shell, its plugin bundles, and its streams all missed. A single bundle has to serve both the origin root and any mount without a second build.
|
||||
|
||||
The mount cannot be recovered from the transport. A configured base URL makes it deployment input, forwarded-prefix inference makes a request header authoritative for routing, and response rewriting in the proxy reimplements the same fact in fragments. The one place that already knows the mount is the served document itself.
|
||||
|
||||
## Decision
|
||||
|
||||
The served HTTP shell owns its document base. Its index carries exactly one `<base href="./">`, spliced in after the opening head tag once the index taps have run, so it precedes the boot manifest, every plugin-resource row, and any markup a tap inserted. The base freezes the entry directory: `/mount/` and `/mount/index.html` are the supported entries, and a document URL that later moves (an in-page `pushState`) does not move the base. Nothing infers a prefix, so deep direct loads and reloads are not entry points; a bare mount still needs the proxy's own normalization to its slash form.
|
||||
|
||||
Browser references to those routes are relative to that document — `api/x`, `plugins/x` — while server route keys stay absolute pathnames: the RPC channel key `/api`, the `webServer` registration paths, and the response tables keyed by the combo route `/plugins/??...&rev=...`. A shared constant that names a key (`*_PATH`, `*_ENDPOINT`) is stripped at the boundary (`KEY.slice(1)`) before browser code uses it, and the browser form lives beside it as `*_ROUTE`.
|
||||
|
||||
The Host composes browser references in one place per producer. Module graph rows and batch descriptors carry the document-relative combo reference; the source-map trailer carries the combo query alone, because a script's source-map reference resolves against that script's own directory (`.../plugins/`) rather than the document. The response table stays keyed by the absolute route, which is also what a request reaches after the proxy strips its prefix.
|
||||
|
||||
The launch entry follows the same rule. `authenticatedUrl` adds the process token to the caller's URL and keeps its authority and mount, and the token exchange redirects to `./`, which removes the query token while preserving the request URL's directory. The Host/Origin fence and the cookie rules are unchanged.
|
||||
|
||||
Consumers that need an absolute URL resolve against `document.baseURI`: the Gateway when it constructs its WebSocket URL and selects `ws:` or `wss:`, and the Inspector when it locates its own bundle from the boot graph.
|
||||
|
||||
Desktop is the other document owner. Its window loads `dsh-app://app/`, an origin that serves the same dist at that root and forwards every app-owned path to the shell-owned Host, so its document directory already is the root and no base row is injected for it; it names that Host origin for the Gateway through the transport's `streamBaseUrl` instead. Its resources live at that root, which is also why the served HTTP shell's `./` does not apply to it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A configured base URL as the routing base (`publicUrl`).** Rejected: the document already knows its own URL, so this adds a second, drift-prone source for a fact the page can read, and it would have to be plumbed to every consumer instead of one shell decision. The advertised public URL remains an operator-facing print/open input, not a routing input.
|
||||
|
||||
**Inferring the mount from a forwarded-prefix header.** Rejected: it makes a request header authoritative for routing, so any client that sets it could move app-owned routes, and the proxy contract becomes server-visible configuration.
|
||||
|
||||
**A shared `Connection.resolveUrl` or routes base.** The HTTP carrier's document base and the worker tunnel's Host-root mapping differ. Resolving every target in Connection would prepend a static preview path to worker-local routes or lose a served mount. Each carrier resolves its own targets; UI resource consumers use the document base.
|
||||
|
||||
**An absolute `/` redirect with the proxy rewriting `Location`.** Rejected: the browser already knows the request's directory, and a configured redirect target cannot be right for both the loopback entry and a proxy mount.
|
||||
|
||||
## Consequences
|
||||
|
||||
One bundle serves the origin root and any mount, and the served document is the single source of the mount. The costs are explicit: the supported entries are only the dist root and the configured index path, a bare mount depends on the proxy's slash normalization, and a reload of a deep in-page path re-enters at that path rather than the frozen one.
|
||||
|
||||
Routes owned by feature packages (open-in-app, deliverables, session-log export, uploads, markdown media) follow the same rule through their own `*_ROUTE` constants; the static gate that keeps browser sources on this rule is recorded with those changes.
|
||||
|
||||
## Testing
|
||||
|
||||
The frontend-static real-composition test serves the index through the Loader and asserts one `<base href="./">` that precedes an injected plugin-resource row and tap markup. The client-modules node-half suite asserts that graph rows, batch descriptors, and the source-map trailer are document-relative while the response table still serves them by route, and the client test roster plus the assembled-boot fixture carry the same relative form. `browser-auth.host.spec.ts` covers the `./` redirect and the authority- and mount-preserving `authenticatedUrl`; `frontend-static.spec.ts` and `apps/cli/tests/web-auth.e2e.ts` run the exchange through a real Loader composition and the built CLI.
|
||||
|
||||
`apps/web/tests/public-mount.e2e.ts` is the browser proof: a real Chromium reaches one listener through the prefix-stripping proxy, the mounted WebSocket streams both ways, a `pushState` to a deeper path leaves `document.baseURI` at the mount, and a document-relative `fetch('api/session/create')` still succeeds beneath it.
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Web 应用自有路由相对文档寻址
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-14-web-document-relative-app-routes.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
浏览器对外壳自有路由的每一处引用都以源站根绝对形式书写:`/api/...` RPC、`/plugins/??...` 插件 bundle、Remote 流 mux、HMR 事件流,以及启动入口清理 token 时重定向到的 `/`。当一个监听端口位于剥离前缀的代理之后(例如 `https://host/tools/dsh/` 这样的挂载,把 `/tools/dsh/...` 转发为 `/...`),这些请求会落在源站根,那里没有任何路由应答:外壳、插件 bundle、流全部落空。同一份 bundle 必须同时服务源站根与任一挂载,而不能为此再构建一次。
|
||||
|
||||
挂载点无法从传输层恢复。配置 base URL 会把它变成部署输入,转发前缀推断会让请求头对路由具有权威性,代理内改写在碎片里重复同一事实。唯一已经知道挂载点的地方,就是所服务的文档自身。
|
||||
|
||||
## 决策
|
||||
|
||||
所服务的 HTTP 外壳拥有自己的文档 base。其 index 携带唯一的 `<base href="./">`,在原始 index 转换执行后拼接于起始 head 标签之后,因此它先于启动 manifest、每一行插件资源,以及任何转换插入的标记。该 base 冻结入口目录:`/mount/` 与 `/mount/index.html` 是受支持的入口,而文档 URL 之后移动(页面内 `pushState`)不会移动 base。系统不推断任何前缀,因此深层直接加载与重新加载不是入口;裸挂载仍需依赖代理自身的规范化补齐尾部斜杠。
|
||||
|
||||
浏览器对这些路由的引用相对该文档——`api/x`、`plugins/x`——而服务端路由键保持绝对路径名:RPC channel 键 `/api`、`webServer` 注册路径,以及以 combo 路由 `/plugins/??...&rev=...` 为键的响应表。命名路由键的共享常量(`*_PATH`、`*_ENDPOINT`)在边界处被剥去前导斜杠(`KEY.slice(1)`)后浏览器代码才使用,浏览器形式以 `*_ROUTE` 与之并列。
|
||||
|
||||
宿主在每个生产者里只在一处组合浏览器引用。模块启动图 row 与批次描述符携带文档相对的 combo 引用;source-map trailer 只携带 combo 查询,因为脚本的 source-map 引用相对该脚本自身目录(`.../plugins/`)解析,而非相对文档。响应表仍以绝对路由为键,而请求在代理剥去前缀后到达的也正是该路由。
|
||||
|
||||
启动入口遵循同一规则。`authenticatedUrl` 把进程 token 加到调用方的 URL 上并保留其 authority 与挂载;token 交换重定向到 `./`,移除 query token 的同时保留请求 URL 的目录。Host/Origin 栅栏与 cookie 规则不变。
|
||||
|
||||
需要绝对 URL 的消费方相对 `document.baseURI` 解析:Gateway 构造 WebSocket URL 并选择 `ws:` 或 `wss:` 时,以及 Inspector 从启动图定位自身 bundle 时。
|
||||
|
||||
Desktop 是另一个文档所有者。它的窗口加载 `dsh-app://app/`:该来源在根目录提供同一份 dist,并把其他所有应用路径转发给它拥有的 Host,因此它的文档目录本就是根目录,无需为它注入 base 行;它改为通过传输层的 `streamBaseUrl` 为 Gateway 指定该 Host origin。它的资源就位于该根目录,这也是所服务 HTTP 外壳的 `./` 不适用于它的原因。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**把配置 base URL 当作路由 base(`publicUrl`)。** 否决:文档已经知道自己的 URL,因此这是为页面本可读取的事实引入第二个易漂移来源,而且必须把它接入每个消费方,而不是只做一次外壳决策。对外公告的公开 URL 仍是面向运维的打印/打开输入,不是路由输入。
|
||||
|
||||
**从转发前缀请求头推断挂载点。** 否决:这会让请求头对路由具有权威性,任何设置该头的客户端都能挪动应用自有路由,代理约定也会变成服务端可见的配置。
|
||||
|
||||
**共享 `Connection.resolveUrl` 或 routes base。** HTTP 载体的文档基准与 worker tunnel 的 Host 根路径映射不同。在 Connection 中统一解析所有目标,会把静态 preview 路径加到 worker 本地路由之前,或丢失所服务的挂载。各载体解析自己的目标;UI 资源消费方使用文档基准。
|
||||
|
||||
**保留绝对 `/` 重定向并让代理改写 `Location`。** 否决:浏览器已经知道请求所在目录,而配置出来的重定向目标无法同时适用于 loopback 入口与代理挂载。
|
||||
|
||||
## 后果
|
||||
|
||||
一份 bundle 服务源站根与任一挂载,所服务的文档是挂载点的唯一来源。代价是显式的:受支持的入口只有 dist 根与配置的 index 路径;裸挂载依赖代理的斜杠规范化;页面内深层路径重新加载会在该路径重新进入,而非冻结目录。
|
||||
|
||||
功能包自有的路由(open-in-app、deliverables、会话日志导出、上传、markdown 媒体)通过各自的 `*_ROUTE` 常量遵循同一规则;让浏览器源码持续遵守该规则的静态门禁随那些改动一并记录。
|
||||
|
||||
## 测试
|
||||
|
||||
frontend-static 的真实组合测试通过 Loader 提供 index,并断言唯一的 `<base href="./">` 先于注入的插件资源行与转换标记。client-modules node 半侧测试断言启动图 row、批次描述符与 source-map trailer 均为文档相对,而响应表仍按路由提供它们,客户端测试 roster 与 assembled-boot fixture 携带同一相对形式。`browser-auth.host.spec.ts` 覆盖 `./` 重定向与保留 authority 和挂载的 `authenticatedUrl`;`frontend-static.spec.ts` 与 `apps/cli/tests/web-auth.e2e.ts` 分别经真实 Loader 组合与构建后的 CLI 运行该交换。
|
||||
|
||||
`apps/web/tests/public-mount.e2e.ts` 是浏览器证据:真实 Chromium 经剥离前缀的代理到达同一监听端口,挂载下的 WebSocket 双向成流,`pushState` 到更深路径后 `document.baseURI` 仍停在挂载点,而文档相对的 `fetch('api/session/create')` 在其下依然成功。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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-17-frame-shell-leading-window-chrome-seat.md
|
||||
2026-09-17-frame-shell-leading-window-chrome-seat.md: cc3ef576900e38dc26c46438fd5d490ac4361be0
|
||||
2026-09-17-frame-shell-leading-window-chrome-seat.zh.md: c652f313d4f4863decc14e556a1674487d1d3fea
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Agent Note: Frame-owned shell.leading window-chrome seat
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-frame-shell-leading-window-chrome-seat.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [macOS hidden-titlebar work](../feature/2026-09-13-macos-hidden-titlebar-vibrancy.md) hid the collapsed sidebar column entirely on darwin and moved the reopen and New Session controls into a conversation-header slot (`conversation.session.header.leading`), shown via CSS while `data-sidebar-collapsed` was published. Only the Conversation had that seat: with any other main panel selected (the plugin manager, or any future global panel), a collapsed window kept the floating traffic lights over the panel's content with no reopen control anywhere on screen. Each new panel would have had to grow its own leading seat and repeat the same clearance geometry.
|
||||
|
||||
## Decision
|
||||
|
||||
The window chrome belongs to the frame, not to one panel. ui-layout declares a fifth root-scoped child slot, `shell.leading` (single), and AppFrame renders its seat as a frame-level box over the columns — mounted only on the darwin desktop while `sidebarCollapsed`, the full-hide state that leaves window chrome without a home (Windows' zero-width collapse keeps its fixed caption controls, so the seat must not mount a second set there). The seat sits at the frame's top-left (left 88px clears the hiddenInset traffic lights, top 11px centers the 28px controls on the conversation title row, z-index 15 above column content and below frame overlays) and carries `-webkit-app-region: no-drag`, subtracting itself from any drag band beneath it.
|
||||
|
||||
Under the same collapsed condition the frame publishes `--dsh-frame-leading-clearance: 160px` — the inline band the traffic lights plus the seat's two controls occupy, measured from the frame's left edge. Window fullscreen hides the traffic lights (the desktop preload mirrors the state onto `html[data-fullscreen]`): the seat moves to `left: 12px` into their vacated band and the clearance drops to `84px`. The conversation title row is the one consumer: it pads `max(0px, clearance - 20px)` (its header already pads 20px). Entry pages that start below the window strip in both sidebar states (the plugin manager's page head) instead pad their top by `--dsh-frame-top-clearance` (48px, published on the darwin frame unconditionally), which clears the strip vertically rather than indenting inline.
|
||||
|
||||
ui-sidebar registers `HeaderLeadingControls` into `shell.leading` instead of the conversation seat, reusing the shell's inject face and locale; the component renders unconditionally because the frame owns the mount condition, and its platform check and CSS collapse-gating are deleted. This also drops ui-sidebar's dependency on ui-conversation. The original `conversation.session.header.leading` slot is gone per the pre-stable API rule (every consumer updated, no compatibility shim); the conversation header now exposes its own sessionless `conversation.header.leading` seat (root-scoped, rendered without a Session) as a public extension point, empty in the shipped composition because the window-chrome controls live in `shell.leading`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keeping the conversation seat and adding one per panel.** Every main panel would repeat the seat markup, the drag-region subtraction, and the traffic-light geometry, and a panel that forgot would strand a collapsed window without a reopen control. One frame-owned seat makes the guarantee structural.
|
||||
|
||||
**Mounting the occupant always and gating visibility in CSS (the prior mechanism).** This kept dead DOM and a no-drag subtraction in the header on every platform and every collapse state, and it could not help non-conversation panels at all. A frame-level TSX mount keeps the condition, the seat geometry, and the clearance publication in one file.
|
||||
|
||||
**Gating the seat on rightbar fullscreen as well.** Unnecessary: the fullscreen right panel (z-index 40) covers the seat (15) visually, and it already opts its whole box out of window dragging, so the seat's geometry cannot puncture it.
|
||||
|
||||
**Publishing clearance as a fixed padding each consumer hardcodes.** A raw `160px` in each panel would silently desynchronize from the seat's real band; one custom property published under exactly the mount condition keeps consumers correct when the geometry changes.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every current and future main panel keeps the reopen and New Session controls while the sidebar is hidden, at zero per-panel cost; the conversation opts into `--dsh-frame-leading-clearance`, and entry pages take the unconditional `--dsh-frame-top-clearance` instead.
|
||||
- `conversation.session.header.leading` no longer exists in the client catalog; `shell.leading` carries the window chrome as a public root-scoped seat, while the conversation header's sessionless `conversation.header.leading` remains a separate public seat with no shipped occupant.
|
||||
- ui-sidebar no longer depends on ui-conversation (inject list, devDependency, and tsconfig reference removed).
|
||||
- The mount condition names darwin explicitly: Windows' `data-windows-titlebar` collapse also yields a zero-width column but keeps its own fixed caption controls, which a frame seat would duplicate. A platform that later hides its column without leaving its own chrome opts in by widening the condition.
|
||||
|
||||
## Testing
|
||||
|
||||
The ui-layout app-frame spec pins the seat's mount and unmount across the darwin collapse and its absence beside the 56px rail; the apply spec pins the five-slot declaration and teardown. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on dispose; the sidebar-root spec pins the two controls' actions.
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Agent Note: 框架持有的 shell.leading 窗口 chrome 座
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-frame-shell-leading-window-chrome-seat.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
[macOS 隐藏标题栏工作](../feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)在 darwin 上把收起的侧边栏整列隐藏,并把重新打开与 New Session 控件移入会话头部的 slot(`conversation.session.header.leading`),在 `data-sidebar-collapsed` 发布期间由 CSS 显示。但只有会话界面有这个座:选中任何其他主面板(插件管理器或未来的全局面板)时,收起的窗口只剩悬浮的红绿灯压在面板内容上,屏幕上没有任何重新打开控件。每个新面板都得自建 leading 座并重复同一套避让几何。
|
||||
|
||||
## Decision
|
||||
|
||||
窗口 chrome 属于框架,不属于某个面板。ui-layout 声明第五个 root 作用域子 slot `shell.leading`(single),AppFrame 把它的座渲染为覆盖各列的框架级盒子——仅在 darwin 桌面且 `sidebarCollapsed` 时挂载,即让窗口 chrome 无处安放的全隐藏状态(Windows 的零宽收起保留其固定 caption 控件,该座在那里挂载会出现第二组控件)。座位于框架左上角(left 88px 避开 hiddenInset 红绿灯,top 11px 让 28px 控件对齐会话标题行中线,z-index 15 高于列内容、低于框架浮层),并带 `-webkit-app-region: no-drag`,把自己从下方任何拖拽带中减去。
|
||||
|
||||
在同一收起条件下框架发布 `--dsh-frame-leading-clearance: 160px`——红绿灯加座内两个控件占据的行内带宽,自框架左边缘起量。窗口全屏隐藏红绿灯(桌面 preload 把状态镜像到 `html[data-fullscreen]`):座移入其空出的带宽(`left: 12px`),避让量降到 `84px`。会话标题行是唯一消费者:它 pad `max(0px, clearance - 20px)`(其头部已 pad 20px)。两种侧栏状态下都始于窗口顶带之下的入口页(插件管理器页头)改用 `--dsh-frame-top-clearance`(48px,darwin 框架上无条件发布)做顶部内边距,纵向避开顶带而非行内缩进。
|
||||
|
||||
ui-sidebar 把 `HeaderLeadingControls` 改注册到 `shell.leading` 而非会话座,复用 shell 的 inject face 与 locale;因挂载条件归框架所有,组件无条件渲染,其平台判断与 CSS 收起门控被删除,同时解除 ui-sidebar 对 ui-conversation 的依赖。原 `conversation.session.header.leading` slot 按 pre-stable API 规则不复存在(更新所有消费者,不留兼容垫层);会话头现在自带无会话可用的 `conversation.header.leading` 座(root 作用域、无 Session 也渲染)作为公开扩展点,因窗口 chrome 控件已住进 `shell.leading`,出厂组合中它保持空置。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留会话座并为每个面板各加一个。** 每个主面板都要重复座标记、拖拽区减除与红绿灯几何,漏掉的面板会让收起的窗口失去重新打开控件。框架持有的单一座使该保证成为结构性的。
|
||||
|
||||
**始终挂载占用方、由 CSS 控制可见性(先前机制)。** 它在所有平台与所有收起状态下都保留死 DOM 和头部的 no-drag 减除,且完全帮不到非会话面板。框架级 TSX 挂载把条件、座几何与避让量发布集中在一个文件。
|
||||
|
||||
**同时以右栏全屏为座的门控条件。** 不必要:全屏右面板(z-index 40)在视觉上盖住座(15),且它已让整个盒子退出窗口拖拽,座的几何无法穿透它。
|
||||
|
||||
**把避让量作为各消费者硬编码的固定内边距。** 各面板写死 `160px` 会与座的真实带宽悄然失同步;在恰为挂载条件下发布一个自定义属性,几何变化时消费者仍保持正确。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 侧边栏隐藏时,现有与未来的每个主面板都免费获得重新打开与 New Session 控件;会话接入 `--dsh-frame-leading-clearance`,入口页改用无条件发布的 `--dsh-frame-top-clearance`。
|
||||
- `conversation.session.header.leading` 从客户端目录中消失;`shell.leading` 作为公开 root 作用域座承载窗口 chrome,会话头无会话可用的 `conversation.header.leading` 则是另一个公开座,出厂组合中无占用者。
|
||||
- ui-sidebar 不再依赖 ui-conversation(inject 列表、devDependency 与 tsconfig 引用均已移除)。
|
||||
- 挂载条件显式点名 darwin:Windows 的 `data-windows-titlebar` 收起同样得到零宽列,但保留自己的固定 caption 控件,框架座会与之重复。未来某平台若隐藏整列且不留自己的 chrome,通过放宽该条件接入。
|
||||
|
||||
## Testing
|
||||
|
||||
ui-layout 的 app-frame spec 钉住 darwin 收起下座的挂载/卸载及 56px 控制栏旁座的缺席;apply spec 钉住五 slot 声明与拆除。ui-sidebar 的 apply spec 钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 dispose 时的移除;sidebar-root spec 钉住两个控件的动作。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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-17-web-feature-routes-and-route-gate.md
|
||||
2026-09-17-web-feature-routes-and-route-gate.md: 3939bdcd0c53e766827e8fcf05f64fb4d284c59b
|
||||
2026-09-17-web-feature-routes-and-route-gate.zh.md: fb329c8fc1f93b9f979612b7163e838cc521428b
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Feature routes follow the document base, and a gate keeps them there
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-web-feature-routes-and-route-gate.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
[Web app-owned routes are document-relative](2026-09-14-web-document-relative-app-routes.md) makes the shell, its plugin bundles, and its streams reach one listener under any mount. Routes owned by feature packages still addressed the origin root: `/open-in-app/...`, the deliverables `present.*` and `changes.*` routes, the session-log export download, the upload worker's `/api/session/uploadFileBinary` post, and the `/api/file` route behind local markdown image paths. Under a prefix-stripping proxy each of those requests missed. Nothing stopped a new browser-face reference from binding the bundle to one mount again.
|
||||
|
||||
## Decision
|
||||
|
||||
Every feature producer keeps its absolute registration key and derives the browser form beside it: `OPEN_IN_APP_*_PATH`/`*_ROUTE`, `PRESENT_*_PATH`/`*_ROUTE`, `CHANGED_FILES_PATH` and `CHANGES_*_PATH`/`*_ROUTE`, `SESSION_LOG_EXPORT_PATH`/`_ROUTE`, `FILE_UPLOAD_PATH`/`_ROUTE`. Browser code addresses only the `*_ROUTE` form, and a Fetch-shaped carrier a page installs (`FileUploadFetch`, `RpcFetch`) receives that relative route and resolves it against its own base.
|
||||
|
||||
Two consumers need an absolute URL and resolve against `document.baseURI`: the upload worker, whose own base is a `blob:` URL, and the markdown image vocabulary, which emits only absolute `http(s)`, `blob`, or `data` destinations.
|
||||
|
||||
`verify-client-route-resolution` runs in `hygiene` and CI static checks over every browser source the client compiler face compiles. It governs request targets — the first argument of a request constructor, a `fetch`-shaped call, or a dynamic import; an assigned resource property; or a JSX `src`/`href` attribute — and rejects a root-absolute, protocol-relative, or absolute app route (`api`, `plugins`, `open-in-app` prefixes), and a relative app route resolved against a `location` read. A shared `*_PATH`/`*_ENDPOINT` key used as a request target must be stripped. The same pass checks the Host producers of browser references: a `url`, `src`, or `href` field stamped with a root-absolute app route is rejected, while route keys and response-table keys stay absolute and out of scope.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Banning every `location` read in browser code.** Rejected: identity comparisons, the preview's own query read, and the worker tunnel's host-relative mapping are all legitimate. The gate governs request targets, which is where a mount-bound route appears.
|
||||
|
||||
**Rewriting feature responses in the proxy.** Rejected: it duplicates the mount in deployment configuration for each route the proxy does not know about, and the document already resolves relative references.
|
||||
|
||||
## Consequences
|
||||
|
||||
The gate reads inline literal fragments only, so a composed reference (`comboReference(...)`, `artifact.url.slice(1)`) is not traced; `packages/client/modules/tests/node-half.client.spec.ts` owns the regression signal for composed graph rows, batch descriptors, and the source-map trailer. Preview pages do not expose their worker's file API at the document URL, so markdown local-file image URLs cannot reach that Host. [Session prose local media display](../feature/2026-09-07-session-prose-local-media-display.md) owns the image vocabulary.
|
||||
|
||||
## Testing
|
||||
|
||||
The gate's own spec pins a negative control per rule: interpolated targets (`${origin}/api/file`), protocol-relative and absolute forms, an unstripped shared key, a relative route resolved against a `location` read, and root-absolute producer fields. Each feature package's specs assert the relative route its browser half sends; `markdown-images.e2e.ts` keeps the local-image path covered end to end. The discovery spec pins which client projects contribute browser sources: a DOM project the Host aggregate also compiles contributes its plain `src/` unless it has a `src/client` half.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 功能路由遵循文档 base,并由门禁维持
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-web-feature-routes-and-route-gate.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[Web 应用自有路由相对文档寻址](2026-09-14-web-document-relative-app-routes.zh.md)让外壳、插件 bundle 与流在任一挂载下到达同一监听端口。功能包自有的路由仍寻址源站根:`/open-in-app/...`、deliverables 的 `present.*` 与 `changes.*` 路由、会话日志导出下载、上传 worker 的 `/api/session/uploadFileBinary` 提交,以及本地 markdown 图片路径背后的 `/api/file` 路由。在剥离前缀的代理之后,这些请求全部落空。也没有任何机制阻止新的浏览器侧引用再次把 bundle 绑定到单一挂载。
|
||||
|
||||
## 决策
|
||||
|
||||
每个功能生产者保留其绝对注册键,并在旁边派生浏览器形式:`OPEN_IN_APP_*_PATH`/`*_ROUTE`、`PRESENT_*_PATH`/`*_ROUTE`、`CHANGED_FILES_PATH` 与 `CHANGES_*_PATH`/`*_ROUTE`、`SESSION_LOG_EXPORT_PATH`/`_ROUTE`、`FILE_UPLOAD_PATH`/`_ROUTE`。浏览器代码只寻址 `*_ROUTE` 形式;页面安装的 Fetch 形载体(`FileUploadFetch`、`RpcFetch`)收到该相对路由,并相对自身基准解析。
|
||||
|
||||
两个消费方需要绝对 URL,并相对 `document.baseURI` 解析:上传 worker(其自身基准是 `blob:` URL)与 markdown 图片词汇表(只输出绝对的 `http(s)`、`blob` 或 `data` 目标)。
|
||||
|
||||
`verify-client-route-resolution` 在 `hygiene` 与 CI 静态检查中运行,覆盖 client 编译面编译的每个浏览器源文件。它治理请求目标——请求构造器、`fetch` 形调用或动态 import 的第一个参数;赋值的资源属性;JSX 的 `src`/`href` 属性——拒绝根绝对、协议相对或绝对的应用路由(`api`、`plugins`、`open-in-app` 前缀),以及相对 `location` 读取解析的相对应用路由。作为请求目标使用的共享 `*_PATH`/`*_ENDPOINT` 键必须先剥去前导斜杠。同一遍检查还覆盖宿主侧浏览器引用的生产者:以根绝对应用路由写入的 `url`、`src` 或 `href` 字段被拒绝,而路由键与响应表键保持绝对,不在范围内。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**在浏览器代码中禁止一切 `location` 读取。** 否决:身份比较、preview 自身查询读取、worker tunnel 的宿主相对映射都属正当。门禁治理请求目标——绑定挂载点的路由真正出现的位置。
|
||||
|
||||
**在代理中改写功能响应。** 否决:这会为代理并不知晓的每条路由在部署配置里重复挂载点,而文档本就能解析相对引用。
|
||||
|
||||
## 后果
|
||||
|
||||
门禁只读取内联字面片段,因此组合出来的引用(`comboReference(...)`、`artifact.url.slice(1)`)不被追踪;组合而成的启动图 row、批次描述符与 source-map trailer 的回归信号由 `packages/client/modules/tests/node-half.client.spec.ts` 持有。Preview 页面不会在文档 URL 下暴露其 worker 的文件 API,因此 markdown 本地文件图片 URL 无法到达该 Host。[会话正文本地媒体展示](../feature/2026-09-07-session-prose-local-media-display.zh.md)持有图片词汇表。
|
||||
|
||||
## 测试
|
||||
|
||||
门禁自身的 spec 为每条规则固定了负向对照:插值目标(`${origin}/api/file`)、协议相对与绝对形式、未剥前导斜杠的共享键、相对路由相对 `location` 读取解析,以及生产者中根绝对的引用字段。各功能包的 spec 断言其浏览器半侧发出的相对路由;`markdown-images.e2e.ts` 端到端维持本地图片路径的覆盖。发现逻辑的 spec 固定了哪些 client 项目贡献浏览器源文件:同时被 Host 聚合编译的 DOM 项目,若没有 `src/client` 半边,则其整个 `src/` 都计入。
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/bug-fix/2026-09-15-desktop-profile-core-cleanup.md
|
||||
2026-09-15-desktop-profile-core-cleanup.md: da189664e2b55a9310b36ed02f0b275e2c1b1370
|
||||
2026-09-15-desktop-profile-core-cleanup.zh.md: dec12e4fe7f34beb5931961183041c3f642b44ae
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md
|
||||
2026-09-18-plugin-install-registries.md: bee870a87b1f5427af8f05a32396bd52bd1524b7
|
||||
2026-09-18-plugin-install-registries.zh.md: 65fe35c78910d1cdf1ce45aea7d9fa7ae8617006
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Plugin install registries
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-18-plugin-install-registries.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The install dialog handed the spec to `pnpm add` with no registry of its own, so pnpm resolved packages from whatever its configuration named, which for a person who never wrote an `.npmrc` is `registry.npmjs.org`. On a network that cannot reach it, every install failed with a network error after pnpm's own retries, which take over a minute for a registry that does not answer; the check before the install failed the same way, and nothing offered a mirror. A mirror is the ordinary answer in mainland China, but pnpm's `--registry` is a CLI flag the dialog could not pass, and an `.npmrc` is not something the dialog's audience edits.
|
||||
|
||||
## Decision
|
||||
|
||||
**The manager asks a plan of registries, not one.** `PluginManager.Config` gains `registry`, the registry asked first (absent: `null`, the one pnpm's own configuration names), and `fallbackRegistries`, the registries asked after it, defaulting to npmmirror. `registryPlan` resolves one operation's plan: the requested registry, else the configured first, then the rest of the configured set. A requested registry outside that set is asked alone: a private registry never falls through to a public one, where a name that exists on both would install someone else's package. Registries are compared in pnpm's form, lower-case host with a trailing slash.
|
||||
|
||||
**pnpm's own registry is public only once read as such.** `null` names whatever the machine's `.npmrc` chain says, which may be a company registry. Before each plan the manager reads it through `pnpm config get registry` in the profile directory, and `null` joins the configured set only while that is npm's own registry or one of the configured fallbacks; otherwise it is asked alone, whether requested as `null` or by its URL, and a public registry asked instead never falls back into it. A registry pnpm already names is asked once rather than twice. Reading fails closed: an unreadable answer leaves `null` alone. `registries()` returns the reading as `resolved`, so the dialog labels the option by the host pnpm names when that is not npm's own.
|
||||
|
||||
**Only a failure another registry can change sends the operation on.** `attributeFailure` lays `network` and `timeout`, and `not-found` and `no-matching-version`, at the registry, because a mirror lags the registry it copies and a package published minutes ago is not there yet; every other kind stops at once. A git spec and a tarball URL carry the `host` pnpm fetches them from, which no registry stands in for: a failure whose error line names that host is laid at the spec's host and stops, while a failure naming the registry, from which the spec's dependencies come, moves on; a warning line that merely links to the host does not count. The attribution reaches the caller as `ChangeResult.failedAt`, so the dialog words the failure and offers another registry from the Host's reading rather than its own guess. The classifier gains npm's `FETCH_ERROR` as a network failure; pnpm's `ERR_PNPM_NO_OFFLINE_META` is an offline cache miss no registry changes and stays unclassified.
|
||||
|
||||
**The check decides which registry is alive; the install starts there.** `inspect` runs `pnpm view` with `--registry` and `--config.fetch-retries=0`, so a registry that does not answer is reported within `inspectTimeoutMs` — pnpm's own schedule waits ten seconds and then a minute between retries — and the next one is asked; a lookup killed at that bound is refused as a `network` problem like a refused connection. pnpm prints a `--json` refusal as `{ error: { code, message } }` on stdout with nothing on stderr, so the classifier reads both. The accepted answer names the `registry` that answered; the client passes it to `installBundle`, which starts there rather than at a registry the check already found dead. The install keeps pnpm's retry settings: it downloads tarballs, which a shorter timeout would cut on a slow but working network, and it moves on to the next registry only for a failure mid-run. `pnpm-workspace.yaml` is not written for this: `fetchRetries` and `fetchTimeout` there would bound the install's downloads as well as the check, and `--config.fetch-timeout` passed on the command line crashes pnpm 11.8, which reads it as a string.
|
||||
|
||||
**Every attempt is visible.** `installBundle` announces `installing` once per registry with the attempt's registry, position, and the plan's length, restores the profile files between attempts and stops before starting a run on a signal the person already aborted, streams each run under its own job id with `--registry` on the command line, and reports the `registries` asked beside the last run's result. A refused check names the `registries` it asked too. The dialog offers the configured set with pnpm's own registry, titled with the host it names, and a typed address, starts from the registry the Host asks first until a choice is remembered in the browser's `localStorage`, says which registry is asked now while the Host moves on, badges each run's registry, names every registry asked when none could be reached, and names the spec's own host when the Host laid the failure there. It does not describe the plan: the order asked is the Host's, shown as it happens. A typed address is checked against the same `REGISTRY_URL` the Host validates with, published through the package's `./registry` entry for the browser.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Write the chosen registry into the profile's `.npmrc` or `pnpm-workspace.yaml`.** Rejected: the choice belongs to one install, not to every later pnpm command in the profile, and a file pnpm reads on every run would have to be rewritten and restored around each attempt.
|
||||
|
||||
**Keep the last choice in Host settings.** Rejected for now: a settings namespace, its controller write path, and its catalog entry buy a choice shared across browsers and with the CLI, which `dsh plugin add --registry` already takes on its command line; the dialog's choice is passed to each call explicitly, so the Host holds no state for it and the browser remembers it.
|
||||
|
||||
**Retry through every registry on any failure.** Rejected: a blocked build script, a full disk, or a package that declares no bundle fails the same way everywhere, and a git host that cannot be reached is not reached through any registry; retrying those costs the person a wait and hides the cause.
|
||||
|
||||
**Fall back to the public registries after a private one.** Rejected: dependency confusion. A name that a private registry serves and a public one also holds would install the public package when the private registry is down. This holds for a private registry the machine's `.npmrc` names as much as for one typed into the dialog, which is why pnpm's own registry is read before it may join the plan.
|
||||
|
||||
**Treat pnpm's own registry as public without reading it.** Rejected: the shipped configuration would then send every `.npmrc`-configured company registry's failures on to npmmirror. Reading it costs one `pnpm config get registry` per plan, about 170 ms.
|
||||
|
||||
**Let the dialog describe the plan.** Rejected: a copy of the membership rule drifted at once — a typed mirror URL without its trailing slash read as a lone registry in the dialog while the Host normalized it into the set — and a sentence about fallbacks under the options was noise to the person choosing one. The dialog shows the order only as the Host asks it.
|
||||
|
||||
**Bound the install's fetches like the check's.** Rejected: the check makes one small request and its liveness answer is what the plan needs; the install's tarballs on a slow but working connection need pnpm's defaults, and the registry the check found alive is the one the install starts at.
|
||||
|
||||
## Consequences
|
||||
|
||||
`Config` gains `registry` and `fallbackRegistries`; `inspect` takes `InspectOptions` before its signal and answers the registry that answered, the spec's own host, and the registries a refusal asked; `InstallBundleOptions`, `ChangeResult` (with `registries` and `failedAt`), `PluginInstallProgress`, and the `plugin_manager` tool carry the registry; `registries()` joins the Remote and answers `resolved`. `viewProfilePackage` takes a `registry` and never lets pnpm retry; `readProfileRegistry` asks pnpm what its configuration names. The package publishes `./registry` — `registryPlan`, `normalizeRegistry`, `REGISTRY_URL` — as a browser-safe entry the client bundle inlines; the dialog uses `REGISTRY_URL`. Every plan spends one pnpm process on the reading; a machine whose pnpm cannot be read gets no fallback.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/boot/plugin-manager/tests/registry.spec.ts` pins the plan, including a private and an unknown pnpm registry, and the attribution; `install-spec.spec.ts` pins the hosts the spec forms carry and the classifier's patterns; `manager.spec.ts` asks the registries in turn from the check and the install, reads a refusal printed on stdout, restores the files between attempts, stops at a git host and at a registry outside the configured set, reports a stop between attempts as cancelled, and refuses a registry that is not an http(s) URL at load; `operations.spec.ts` pins the lookup's arguments and the reading of pnpm's registry. `packages/client/ui-plugin-manager/tests` cover the store's choice, its memory across controllers, the attempts the Host announces, the return to the spec with the options open, and the page's picker, attempt copy, run badges, and failure sentences.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: 插件安装的注册表
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-18-plugin-install-registries.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
安装对话框把 spec 交给 `pnpm add` 时不指定注册表,pnpm 就从它自身配置指定的地方解析包;对于从未写过 `.npmrc` 的人,那就是 `registry.npmjs.org`。在连不上它的网络里,每次安装都在 pnpm 自身重试之后以网络错误告终——对不应答的注册表,这要一分多钟;安装前的检查也以同样方式失败,而没有任何地方提供镜像。镜像是中国大陆的常规答案,但 pnpm 的 `--registry` 是对话框传不了的命令行参数,而 `.npmrc` 不是对话框的受众会去编辑的东西。
|
||||
|
||||
## Decision
|
||||
|
||||
**管理器询问的是一个注册表计划,不是一个注册表。** `PluginManager.Config` 新增 `registry`——首先询问的注册表(缺省为 `null`,即 pnpm 自身配置指定的那个)和 `fallbackRegistries`——随后依次询问的注册表,默认为 npmmirror。`registryPlan` 解出一次操作的计划:请求的注册表,否则配置的第一个,然后是配置集合中的其余。请求的注册表不在这个集合里时只问它自己:私有源永远不会落到公共源,否则两边都有的名字会装上别人的包。注册表按 pnpm 的形式比较:小写主机名加尾部斜杠。
|
||||
|
||||
**pnpm 自身的注册表只有读出来是公共的才算公共。** `null` 指向本机 `.npmrc` 链说的任何地方,可能是公司注册表。每次做计划前管理器在 profile 目录里用 `pnpm config get registry` 读出它,只有它是 npm 官方源或某个配置的备选源时 `null` 才进入配置集合;否则不论以 `null` 还是以它的 URL 请求都只问它自己,改问公共源时也绝不回落到它。pnpm 已经指向的注册表只问一次而不是两次。读取失败时保守处理:读不出来就让 `null` 只问自己。`registries()` 把读出的结果作为 `resolved` 返回,所以 pnpm 指向的不是 npm 官方源时,对话框按其主机名标注该选项。
|
||||
|
||||
**只有换一个注册表能改变的失败才让操作继续。** `attributeFailure` 把 `network`、`timeout` 归于注册表,把 `not-found`、`no-matching-version` 也归于注册表,因为镜像落后于它复制的注册表,几分钟前刚发布的包还没到那里;其他种类立即停止。git spec 和 tarball URL 携带 pnpm 拉取它们的 `host`,没有注册表能替代它:错误行点名那台主机的失败归于 spec 自身主机并停止,而点名注册表(spec 的依赖来自那里)的失败则继续;仅仅链接到该主机的警告行不算。归因以 `ChangeResult.failedAt` 交给调用方,所以对话框按 Host 的判断而不是自己的猜测来措辞并决定是否提供换源。分类器新增 npm 的 `FETCH_ERROR` 作为网络失败;pnpm 的 `ERR_PNPM_NO_OFFLINE_META` 是离线缓存缺失,换注册表改变不了,保持未分类。
|
||||
|
||||
**检查决定哪个注册表活着;安装从那里开始。** `inspect` 以 `--registry` 和 `--config.fetch-retries=0` 运行 `pnpm view`,所以不应答的注册表会在 `inspectTimeoutMs` 内报告——pnpm 自身的节奏是重试之间先等十秒再等一分钟——然后询问下一个;在这个上限被杀掉的查询与连接被拒一样作为 `network` 问题拒绝。pnpm 把 `--json` 的拒绝以 `{ error: { code, message } }` 打印在 stdout 而 stderr 为空,所以分类器两者都读。通过的答复带上作答的 `registry`;客户端把它传给 `installBundle`,安装从那里开始,而不是从检查已经发现死掉的注册表开始。安装保留 pnpm 的重试设置:它要下载 tarball,更短的超时会在慢但可用的网络上掐断下载,且它只在运行中途失败时才转问下一个注册表。为此不写 `pnpm-workspace.yaml`:那里的 `fetchRetries` 与 `fetchTimeout` 会同时约束安装的下载和检查,而命令行传入的 `--config.fetch-timeout` 会让 pnpm 11.8 崩溃,因为它被当作字符串读取。
|
||||
|
||||
**每次尝试都可见。** `installBundle` 每问一个注册表通告一次 `installing`,带上这次的注册表、序号和计划长度,两次尝试之间恢复 profile 文件,并在用户已经取消时不再以失效的信号启动下一次运行,每次运行以自己的 job id 流式输出且命令行带 `--registry`,并在最后一次运行的结果旁报告问过的 `registries`。被拒绝的检查同样列出它问过的 `registries`。对话框提供配置集合、pnpm 自身的注册表(标题带上它指向的主机名)和手动输入的地址,在浏览器 `localStorage` 记住选择之前从 Host 首先询问的注册表开始,在 Host 转问时说明现在问的是哪个注册表,给每次运行标上注册表,所有注册表都连不上时逐一列出,而 Host 把失败归于 spec 自身主机时则点名那台主机。它不描述计划:询问顺序是 Host 的,发生时才显示。手动输入的地址用 Host 校验所用的同一个 `REGISTRY_URL` 检查,该常量经包的 `./registry` 入口发布给浏览器。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**把所选注册表写进 profile 的 `.npmrc` 或 `pnpm-workspace.yaml`。** 否决:这个选择属于一次安装,不属于 profile 里之后的每条 pnpm 命令,而 pnpm 每次运行都读的文件得在每次尝试前后改写再恢复。
|
||||
|
||||
**把上次选择存进 Host 设置。** 暂时否决:一个设置命名空间、它的控制器写入路径和目录条目换来的是跨浏览器并与 CLI 共享的选择,而 `dsh plugin add --registry` 已经在命令行接受它;对话框的选择显式传给每次调用,Host 不为它保存状态,由浏览器记住。
|
||||
|
||||
**任何失败都换遍所有注册表。** 否决:被拦下的构建脚本、写满的磁盘、不声明组合包的包在哪里都同样失败,连不上的 git 主机经由任何注册表也连不上;重试这些只让人白等,还掩盖原因。
|
||||
|
||||
**私有源之后落到公共源。** 否决:依赖混淆。私有源提供而公共源也有的名字,会在私有源宕机时装上公共源的包。这对本机 `.npmrc` 指定的私有源和对话框里手填的私有源同样成立,所以 pnpm 自身的注册表要先读出来才可能进入计划。
|
||||
|
||||
**不读取就把 pnpm 自身的注册表当作公共源。** 否决:那样出厂配置会把每个 `.npmrc` 配置了公司注册表的用户的失败转到 npmmirror。读取的代价是每次计划一个 `pnpm config get registry` 进程,约 170 ms。
|
||||
|
||||
**让对话框描述计划。** 否决:成员规则的副本立刻就漂移了——手填没有尾斜杠的镜像 URL 在对话框里读作单独的注册表,而 Host 归一化后把它并入集合——而且选项下方一句关于备选的说明对正在选源的人只是噪音。对话框只在 Host 询问时按实际顺序显示。
|
||||
|
||||
**像检查一样限制安装的请求。** 否决:检查只发一个小请求,它的存活答复正是计划所需;安装在慢但可用的连接上下载 tarball 需要 pnpm 的默认值,而安装从检查发现活着的注册表开始。
|
||||
|
||||
## Consequences
|
||||
|
||||
`Config` 新增 `registry` 与 `fallbackRegistries`;`inspect` 在 signal 之前接受 `InspectOptions`,并答复作答的注册表、spec 自身的主机和拒绝时问过的注册表;`InstallBundleOptions`、`ChangeResult`(含 `registries` 与 `failedAt`)、`PluginInstallProgress` 和 `plugin_manager` 工具携带注册表;`registries()` 加入 Remote 并答复 `resolved`。`viewProfilePackage` 接受 `registry` 且从不让 pnpm 重试;`readProfileRegistry` 向 pnpm 询问其配置指向哪里。包把 `./registry`——`registryPlan`、`normalizeRegistry`、`REGISTRY_URL`——作为浏览器安全的入口发布,客户端 bundle 内联它;对话框使用 `REGISTRY_URL`。每次计划花费一个 pnpm 进程做读取;pnpm 读不出来的机器没有备选。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/boot/plugin-manager/tests/registry.spec.ts` 钉住计划(含私有与未知的 pnpm 注册表)与归因;`install-spec.spec.ts` 钉住各 spec 形式携带的主机和分类器的模式;`manager.spec.ts` 从检查和安装依次询问注册表、读取打印在 stdout 的拒绝、在尝试之间恢复文件、在 git 主机和配置集合之外的注册表处停下、把两次尝试之间的取消报告为已取消,并在加载时拒绝不是 http(s) URL 的注册表;`operations.spec.ts` 钉住查询的参数和 pnpm 注册表的读取。`packages/client/ui-plugin-manager/tests` 覆盖 store 的选择、跨控制器的记忆、Host 通告的尝试、带着展开选项回到 spec,以及页面的选择器、尝试文案、运行标记和失败句子。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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-19-profile-resolution-lookup-order.md
|
||||
2026-09-19-profile-resolution-lookup-order.md: da9c501cff6c902863def6d39e4052097c75bcb3
|
||||
2026-09-19-profile-resolution-lookup-order.zh.md: 72a9e876a49770ed2bdc544e2ee9104619eedc80
|
||||
@@ -0,0 +1,141 @@
|
||||
# Agent Note: Resolve profile modules along the ancestor chain with an interception layer
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-profile-resolution-lookup-order.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A profile loads plugins from its own package project. The dsh installation packages and the dependencies embedded in bundles are not on the profile's dependency tree, so Node's default `node_modules` lookup starting from the profile cannot find them.
|
||||
|
||||
A developer must be able to restate the resolution rule in one sentence: which part is ordinary Node behavior, which part is dsh's intervention, and where that intervention ends. The previous runtime implementation hid the whole `$DSH_HOME/profiles/node_modules` layer for every package name and kept dedicated recognition code for historical directory layouts, so the rule could not be stated in one sentence and nobody could judge whether a given resolution result was expected.
|
||||
|
||||
## Decision
|
||||
|
||||
Module resolution inside a profile is an ordinary Node ancestor `node_modules` chain. dsh does exactly one thing: every package name in the runtime resolution computed at startup occupies the package directory position `$DSH_HOME/profiles/node_modules/<package>`. When the chain reaches this layer, a package name with an entry is answered by the runtime resolution's package, and a package name without an entry sees the physical directory at this layer. Every other resolution behavior matches Node. [Construction, publication, Worker inheritance, and runtime carriers of immutable generations](2026-09-09-profile-resolution-generations.md) are recorded by the existing Note and are not repeated here.
|
||||
|
||||
### Part 1: Resolution rules
|
||||
|
||||
#### 1. The ancestor chain: Node's ancestor `node_modules` lookup
|
||||
|
||||
The importer is the module file that issues the `import` or `require`. Node starts from the importer's directory, tries the `node_modules` under each directory level upward until the filesystem root, and then tries `NODE_PATH` and the global directories. The nearest layer wins; within one layer, matching is by package name only.
|
||||
|
||||
Symlinks are followed to their real path per Node's default behavior. Once a module is loaded by its real path, the ancestor chain for its subsequent imports is the real path's ancestor chain, regardless of where it was linked from.
|
||||
|
||||
The chain below defines the numbering that every table in this Note references. The importer is `$DSH_HOME/profiles/web/node_modules/my-plugin/index.js`:
|
||||
|
||||
```text
|
||||
① $DSH_HOME/profiles/web/node_modules/my-plugin/node_modules
|
||||
② $DSH_HOME/profiles/web/node_modules
|
||||
③ $DSH_HOME/profiles/node_modules
|
||||
④ $DSH_HOME/node_modules
|
||||
⑤ /node_modules
|
||||
```
|
||||
|
||||
① holds the plugin's private dependencies, ② the packages the profile installed, ③ the interception layer where the runtime resolution occupies the package directories it has entries for, ④ the Harness home, and ⑤ the filesystem root followed by `NODE_PATH` and the global directories.
|
||||
|
||||
#### 2. The interception layer: the runtime resolution occupies `$DSH_HOME/profiles/node_modules/<package>`
|
||||
|
||||
The interception layer is the first ancestor `node_modules` above the importer's profile directory (`$DSH_HOME/profiles/<name>`). For every profile inside the tree this layer is `$DSH_HOME/profiles/node_modules`.
|
||||
|
||||
Every package name in the runtime resolution occupies the package directory position `<interception layer>/<package>` at this layer. For an installation package name such as `@deepseek-ai/dsh-tools`, the package directory at ③ is the copy of the running installation; an old link or old directory of the same name on disk is no longer a member of the chain. For a package name absent from the runtime resolution, the package directory at ③ is the physical content on disk.
|
||||
|
||||
The order for one bare package name request: the ancestor chain first walks the layers inside the profile directory (① and ②), and when a candidate exists Node resolves inside that candidate and returns. On reaching ③, if the runtime resolution has an entry for that package name, Node resolves inside the package directory the entry records; a hit returns, and a missing file inside the package continues per Node's own handling of a "found package": CommonJS keeps looking for the subpath layer by layer from ④, ESM fails immediately. If the runtime resolution has no entry, Node looks at the physical directory at ③ and then moves on to ④.
|
||||
|
||||
Node remains responsible for `exports`, `imports`, conditions, `main`, subpaths, extensions, caching, and error codes. When the selected package rejects a subpath through `exports`, the error is final; Node does not switch to another package of the same name.
|
||||
|
||||
The interception layer reads, writes, and deletes no disk links. Leftover historical symlinks are treated as ordinary filesystem content: the positions of installation package names at ③ are already occupied by the runtime resolution, so old links are never read; all other package names see the contents of ③ normally along the ancestor chain. Profile load removes, once, the projections the link backend of the dsh 0.1.5 releases wrote into a profile: symlinks under the profile's `node_modules` whose target lies inside `<profile>/.dsh-module-fallback/node_modules`, followed by that directory. pnpm-installed packages and every other symlink stay.
|
||||
|
||||
#### 3. What the interception layer holds: the runtime resolution's scan contents
|
||||
|
||||
The runtime resolution is computed once at profile startup and consists of three parts.
|
||||
|
||||
- Installation closure: starting from the `package.json` of the currently running dsh package, a breadth-first traversal follows `dependencies` and `peerDependencies`; each edge resolves from the manifest that declares it per Node rules, and the first installed package found owns a package name. The closure holds several hundred entries, roughly half in the `@deepseek-ai/` scope and half third-party libraries. These entries apply to every profile.
|
||||
- Bundle-only entries: for a bundle selected by the profile that is not part of the closure, the same traversal starts from its manifest, and package names the closure already owns are not overridden. These entries apply only to profiles that select that bundle; they let the Loader import the bundle's embedded plugins by bare name from the profile root.
|
||||
- Local package names: package names among the profile's direct dependencies that are already installed in `$DSH_HOME/profiles/<name>/node_modules`. They already sit at ② on the ancestor chain; recording them only saves one directory probe.
|
||||
|
||||
The CLI derives the installation anchor from `import.meta.url`, which Node has already resolved to its real path; the Desktop Host builds it from its runtime directory, which is not a symlink. Both leave the installation root equal to its real directory, so bundle discovery and dependency traversal use the same location. During the traversal, every dependency level uses the real directory of its owning package as the lookup anchor for the next level and records the location of the manifest that declares it. After a hit, Node resolves from that declaring location and gets the same result the package's own internal imports get. Every entry records the package name, package directory, version, declaring location, and scope. Declared but uninstalled dependencies are skipped; the bundle package root itself does not become an entry.
|
||||
|
||||
#### 4. Hook coverage
|
||||
|
||||
The hook participates only when the importer is under `$DSH_HOME/profiles/**`. Builtin, relative-path, absolute-path, and URL requests, and every request whose importer is outside the tree, go straight to Node.
|
||||
|
||||
The ESM and CommonJS adapters call the same routing function, and the main thread and Harness-owned Workers install the same runtime resolution. The implementation lives in `packages/boot/app-boot/src/profile-resolution/resolver.ts`; runtime resolution construction lives in `packages/boot/app-boot/src/profile.ts`.
|
||||
|
||||
### Part 2: Walkthroughs
|
||||
|
||||
#### Table 1: Ancestor chain and interception for ESM and CommonJS across request forms
|
||||
|
||||
The table uses the installation package `@deepseek-ai/dsh-tools` (has a runtime resolution entry) and the third-party library `left-pad` (no runtime resolution entry) as examples; `pkg` stands for either.
|
||||
|
||||
| Request form | ESM | CommonJS | Position of the package name at ③ | When a file is missing inside the package |
|
||||
|---|---|---|---|---|
|
||||
| Bare name `pkg`, package has `exports` | If ① or ② has a `pkg` directory, Node takes the entry point from its `exports` | Same as ESM; a candidate directory at ① or ② whose entry file is missing does not count as a candidate | With entry: the runtime resolution's package; without entry: the physical directory | A missing entry point under `exports` is an `exports` error, final |
|
||||
| Bare name `pkg`, package has no `exports` | As above, entry point from `main` or `index` | As above | As above | ESM fails, final; CommonJS continues looking for an entry point at the next layer |
|
||||
| Subpath `pkg/sub`, package has `exports` | The first `pkg` found decides; if `./sub` is not exported, `ERR_PACKAGE_PATH_NOT_EXPORTED` is final | Same as ESM | As above | Final; no other copy is searched |
|
||||
| Subpath `pkg/sub`, package has no `exports` | If the first `pkg` found has no `sub`, `ERR_MODULE_NOT_FOUND` is final | Tries `<layer>/pkg/sub` layer by layer: ① and ②, then the position of the package name at ③ (the runtime resolution's package directory when an entry exists), then ④ | As above | ESM final; CommonJS continues to the next layer, that is ④ |
|
||||
| `#alias` package-internal alias | After the owning manifest's `imports` maps it to a bare name, handled per the rows above; a mapping to a relative path goes straight to Node | Same as ESM, with conditions from the caller or the defaults | Same as the matching row | Same as the matching row |
|
||||
| `require.resolve(pkg, { paths })` | No such form | Applies the same rule to each item in `paths` independently: an item inside the profile tree walks ① and ②, ③, ④ from that item; an item outside the tree goes to Node; in the caller's order | As above | The item continues to its next layer, then the next item takes its turn |
|
||||
| Package self-reference (the importer's package imports by its own `name`) | Goes to Node with the original importer kept | Same as ESM | Not involved | Not applicable |
|
||||
| Relative / absolute / URL / builtin | Goes to Node | Goes to Node | Not involved | Not applicable |
|
||||
|
||||
After a runtime resolution hit, the `ERR_MODULE_NOT_FOUND` and `ERR_PACKAGE_PATH_NOT_EXPORTED` errors Node reports replace the internal declaring location with the original importer; the CommonJS require stack also drops the internal anchor.
|
||||
|
||||
#### Table 2: Interception position for the Web and Desktop profiles
|
||||
|
||||
| profile | Directory | Interception layer | Difference from web |
|
||||
|---|---|---|---|
|
||||
| web | `$DSH_HOME/profiles/web` | `$DSH_HOME/profiles/node_modules` | Baseline |
|
||||
| desktop | `$DSH_HOME/profiles/desktop` | `$DSH_HOME/profiles/node_modules` | None. Same tree, same layer; the Desktop Host runs the same resolver in Node mode |
|
||||
| Out-of-tree profile | Any directory `loadProfileDirectory` accepts | The first ancestor `node_modules` above that directory | Same rule; no product currently uses an out-of-tree profile, only unit tests cover it |
|
||||
|
||||
#### Table 3: Ancestor chain when a plugin is linked outside the tree
|
||||
|
||||
| Import source → target | Hook participates | Result |
|
||||
|---|---|---|
|
||||
| profile → linked plugin | Participates up to ② | Node follows the symlink at ②; the plugin loads by its real path |
|
||||
| Linked plugin → its own third-party dependencies | Does not participate | The real path's ancestor chain; uses the version in the developer's own `node_modules` |
|
||||
| Linked plugin → dsh installation packages | Does not participate | The copy in the developer's own `node_modules`; `ERR_MODULE_NOT_FOUND` when absent. Part 3 describes how to make it the same copy as the running dsh |
|
||||
|
||||
### Part 3: Developer integration guide
|
||||
|
||||
#### 1. Normal installation
|
||||
|
||||
`dsh plugin --profile <name> add <package | git spec | file:../local-checkout>` forwards its argument to pnpm, which installs inside the profile directory with a hoisted layout. The profile's `pnpm-workspace.yaml` sets `autoInstallPeers: false`, so dsh packages a plugin declares as peers are not installed into the profile; the interception layer supplies the copy from the running installation, and the plugin and dsh share one module instance.
|
||||
|
||||
The plugin's own third-party dependencies are hoisted to `$DSH_HOME/profiles/<name>/node_modules` and found at ② on the ancestor chain. When a name collides with the installation closure, the nearest wins and the plugin uses the version it declared. The `file:` form copies the local checkout into the profile; resolution afterwards is identical to a registry installation.
|
||||
|
||||
#### 2. Development mode: the plugin repository is outside the profile tree
|
||||
|
||||
`npm link`, or a bare directory path such as `dsh plugin add ../my-plugin` (which pnpm treats as `link:`), makes the entry in the profile a symlink to the plugin repository. The plugin loads by its real path and the hook takes no part in its imports. The plugin repository's own dependency tree must resolve dsh installation packages to the same copy as the running dsh; both layouts below satisfy this.
|
||||
|
||||
- Layout A: the plugin repository installs `@deepseek-ai/dsh` and the `@deepseek-ai/dsh-*` peers the plugin uses as devDependencies and starts with the dsh in that repository, for example `pnpm exec dsh --profile <name>`. The running installation is the copy on the plugin's ancestor chain, so the runtime resolution and the real paths agree.
|
||||
- Layout B: the plugin repository links the dsh packages it uses to the local dsh source repository (`link:../deepseek-harness/packages/core/tools` or `pnpm link`) and starts `pnpm dsh --profile <name>` from the source repository. Both sides load the same copy from the source repository.
|
||||
|
||||
Not provided and not recommended: having the hook proxy installation packages for real paths linked out of the tree, which requires tracking every real root entered from the profile, something Node itself does not do for developers either; and starting with a dsh installed elsewhere while the plugin repository installs its own dsh devDependencies, which produces two module instances and splits both `Symbol()` keys and `instanceof` checks.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Let the runtime resolution act only as a fallback after the whole ancestor chain is exhausted.** The ancestor chain would first read a leftover historical installation link at ③ and use it, which conflicts with "installation packages come from the running installation". The runtime resolution must occupy the positions of installation package names at ③.
|
||||
|
||||
**Treat the runtime resolution as a whole layer inserted into the ancestor chain instead of occupying package directories at ③.** The two differ in exactly one place: when CommonJS hits a runtime resolution package and the subpath is missing, the former would go back and read the old copy of the same name at ③, while the latter goes straight to ④ per Node's handling of a "found package". Occupying the package directory matches Node semantics and needs no extra check for ③.
|
||||
|
||||
**Give installation closure entries absolute priority over nearer copies.** The closure contains 281 third-party libraries. A plugin's private versions of `zod`, `yaml`, and others would be overridden by the versions in the installation closure, breaking the nearest-wins rule shared with Node. The supported installation flow no longer installs dsh peers into the profile, so overriding is not needed to guarantee a single instance.
|
||||
|
||||
**Recognize and bypass symlinks in the `.dsh-module-fallback` layout at resolve time.** This keeps dedicated logic in the lookup path for directories that are no longer produced, and every resolution pays for it. Removing those projections once at profile load reaches the same result: after a bundle is deselected while it stays installed, its projected plugin no longer shadows the same-named plugin the runtime resolution selects from another bundle.
|
||||
|
||||
**Hide the whole physical directory that holds the interception layer.** This conflicts with "all other resolution matches Node": non-installation packages placed in `$DSH_HOME/profiles/node_modules` would become invisible to every profile.
|
||||
|
||||
**Extend the hook to the real directories of plugins linked outside the tree.** This requires tracking every real root entered from the profile during resolution and rewriting results for some package names under those directories; Node does this for no linked-out package, and the two layouts in Part 3 do not need it.
|
||||
|
||||
## Verification
|
||||
|
||||
- The table-driven matrix in [profile-resolution.spec.ts](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts): importers are the profile root and a plugin inside the profile; package names are an installation entry, a bundle-only entry, and a name outside the table; every presence combination of the four layers ①②③④, with ② and ③ each in two forms, a real directory and a symlink pointing elsewhere; every cell asserts that ESM import, CommonJS require, `require.resolve`, and the `packageDir` metadata land in the same directory.
|
||||
- Dedicated cases in the same file cover each row of Table 1: bare names and subpaths with and without `exports`, `#alias`, explicit `paths`, package self-reference, CommonJS skipping ③ and going straight to ④ when a subpath is missing after a runtime resolution hit, and the interception position of an out-of-tree profile.
|
||||
- The [real CLI launch test](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts), under src and lib launches and under plain-directory and npm-link layouts, places an old `@deepseek-ai/dsh-tools` link at ③ and an external package with its embedded dependency at ③, and asserts that Tools and AgentLoop share one module instance, that the external package resolves along its real path, and that every file and link target stays byte-identical.
|
||||
|
||||
## Consequences
|
||||
|
||||
Gained: startup writes nothing to disk; installation packages always come from the running installation; all other resolution matches Node, so developers can predict results with their Node knowledge; no recognition code exists for any historical directory layout.
|
||||
|
||||
Paid: third-party libraries in the installation closure still occupy package directories at ③, so a profile plugin that declares a third-party library as a peer or omits the declaration gets the version dsh uses internally, as in the disk-link era; plugins should declare third-party libraries as dependencies. Plugins linked outside the tree cannot obtain installation packages through the hook and must align themselves using the layouts in Part 3. Consumers that bypass the hook, namely child processes a plugin spawns itself, third-party Workers, and external tools run inside the profile directory, can no longer find installation packages through disk links in `$DSH_HOME/profiles/node_modules`. The resolver continues to depend on supported Node Internal interfaces.
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
# Agent Note: profile 解析的主线祖先链与拦截层
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-profile-resolution-lookup-order.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
profile 从自己的包项目加载插件。dsh 本体包和 bundle 内嵌的依赖不在 profile 的依赖树上,Node 默认的 `node_modules` 查找从 profile 出发找不到它们。
|
||||
|
||||
解析规则需要能被开发者一句话复述:哪一段是普通 Node 行为,哪一段是 dsh 的干预,干预的范围到哪里为止。此前的运行时实现把 `$DSH_HOME/profiles/node_modules` 整层对所有包名隐藏,又为历史目录形状保留了专门的识别代码,规则无法用一句话说清,也无法判断某次解析结果是否符合预期。
|
||||
|
||||
## Decision
|
||||
|
||||
profile 内模块的解析是一条普通的 Node 祖先 `node_modules` 链。dsh 只做一件事:启动时算出的 runtime resolution 中的每个包名,占据 `$DSH_HOME/profiles/node_modules/<包名>` 这个包目录的位置。链走到这一层时,有条目的包名由 runtime resolution 的包回答,没有条目的包名看这一层的物理目录。除此之外的一切解析行为与 Node 一致。[不可变 generation 的构造、发布、Worker 继承与运行时载体](2026-09-09-profile-resolution-generations.zh.md)由既有 Note 记录,本篇不复述。
|
||||
|
||||
### 一、解析规则
|
||||
|
||||
#### 1. 主线:Node 的祖先 `node_modules` 链
|
||||
|
||||
importer 指发起 `import` 或 `require` 的那个模块文件。Node 从 importer 所在目录开始,逐层向上尝试每一级目录下的 `node_modules`,直到文件系统根,再尝试 `NODE_PATH` 与全局目录。最近的一层优先;同一层内只按包名匹配。
|
||||
|
||||
软链接按 Node 默认行为跟随到真实路径。一个模块一旦以真实路径加载,它后续 import 的主线就是真实路径的祖先链,与它被链接到哪里无关。
|
||||
|
||||
下面这条链是全文表格引用的编号。importer 为 `$DSH_HOME/profiles/web/node_modules/my-plugin/index.js`:
|
||||
|
||||
```text
|
||||
① $DSH_HOME/profiles/web/node_modules/my-plugin/node_modules
|
||||
② $DSH_HOME/profiles/web/node_modules
|
||||
③ $DSH_HOME/profiles/node_modules
|
||||
④ $DSH_HOME/node_modules
|
||||
⑤ /node_modules
|
||||
```
|
||||
|
||||
① 是插件私有依赖,② 是 profile 安装的包,③ 是拦截层,runtime resolution 占据其中有条目的包目录,④ 是 Harness home,⑤ 是文件系统根,随后是 `NODE_PATH` 与全局目录。
|
||||
|
||||
#### 2. 拦截层:runtime resolution 占据 `$DSH_HOME/profiles/node_modules/<包名>`
|
||||
|
||||
拦截层是 importer 所属 profile 目录(`$DSH_HOME/profiles/<name>`)之上的第一个祖先 `node_modules`。树内所有 profile 的这一层都是 `$DSH_HOME/profiles/node_modules`。
|
||||
|
||||
runtime resolution 中的每个包名,在这一层上占据 `<拦截层>/<包名>` 这个包目录的位置。对 `@deepseek-ai/dsh-tools` 这样的本体包名,③ 上的包目录就是运行中安装的那一份;磁盘上同名的旧链接或旧目录不再是链上的成员。对 runtime resolution 没有的包名,③ 上的包目录就是磁盘上的物理内容。
|
||||
|
||||
一次裸包名请求的顺序:主线先走完 profile 目录内部的各层(①②),有候选就交给 Node 在候选内解析并返回。走到 ③ 时,runtime resolution 有该包名的条目,Node 就在条目记录的包目录内解析;找到就返回,包内缺文件则按 Node 自身对"已找到包"的处理继续,CommonJS 从 ④ 继续逐层找子路径,ESM 直接报错。runtime resolution 没有条目,Node 就看 ③ 的物理目录,再到 ④。
|
||||
|
||||
Node 仍然负责 `exports`、`imports`、conditions、`main`、子路径、扩展名、缓存与错误码。被选中的包因 `exports` 拒绝某个子路径时报错终局,不会换另一个同名包。
|
||||
|
||||
拦截层不读、不写、不删任何磁盘链接。历史残留的软链接一律按普通文件系统内容处理:本体包名在 ③ 上的位置已被 runtime resolution 占据,旧链接永远不会被读到;其余包名按主线正常看到 ③ 里的内容。profile 加载时一次性删除 dsh 0.1.5 系列发布版的 Link 后端写进 profile 的投影:profile `node_modules` 下目标位于 `<profile>/.dsh-module-fallback/node_modules` 内的软链接,以及该目录本身。pnpm 安装的包和其他软链接都保留。
|
||||
|
||||
#### 3. 拦截层里有什么:runtime resolution 的扫描内容
|
||||
|
||||
runtime resolution 在 profile 启动时一次算出,由三部分组成。
|
||||
|
||||
- installation 闭包:从当前运行的 dsh 包的 `package.json` 出发,沿 `dependencies` 与 `peerDependencies` 做广度优先遍历,每条边从声明它的 manifest 按 Node 规则解析,同一个包名由第一次找到的已安装包占有。闭包有数百条,约一半在 `@deepseek-ai/` 作用域,一半是第三方库。这些条目对所有 profile 生效。
|
||||
- bundle-only 条目:profile 选中的、不属于闭包的 bundle,从它的 manifest 出发做同样的遍历,闭包已占有的包名不覆盖。这些条目只对选中该 bundle 的 profile 生效,用于让 Loader 从 profile 根按裸名导入 bundle 内嵌的插件。
|
||||
- 本地包名:profile 直接依赖中已经安装在 `$DSH_HOME/profiles/<name>/node_modules` 的包名。它们本来就在主线 ② 上,记录下来只为免去一次目录探测。
|
||||
|
||||
CLI 的安装锚点来自 `import.meta.url`,Node 已对它做过 realpath;Desktop Host 从其运行时目录拼出锚点,该目录不是软链接。两者的安装根目录都等于真实目录,bundle 发现与依赖遍历使用同一个位置。遍历中每一层依赖都以所属包的真实目录作为下一层的查找锚点,并记录声明它的 manifest 位置。命中后 Node 从这个声明位置解析,得到与该包自己内部 import 相同的结果。每个条目记录包名、包目录、版本、声明位置与作用域。已声明但未安装的依赖跳过;bundle 包根自身不成为条目。
|
||||
|
||||
#### 4. hook 覆盖范围
|
||||
|
||||
hook 只在 importer 位于 `$DSH_HOME/profiles/**` 时参与。builtin、相对路径、绝对路径、URL 请求,以及 importer 在树外的一切请求,直接交给 Node。
|
||||
|
||||
ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harness 自有 Worker 都安装同一份 runtime resolution。实现位于 `packages/boot/app-boot/src/profile-resolution/resolver.ts`,runtime resolution 的构造位于 `packages/boot/app-boot/src/profile.ts`。
|
||||
|
||||
### 二、预演
|
||||
|
||||
#### 表 1:ESM 与 CommonJS 在各类请求下的主线与拦截
|
||||
|
||||
下表以本体包 `@deepseek-ai/dsh-tools`(runtime resolution 有条目)和第三方库 `left-pad`(runtime resolution 无条目)为例;`pkg` 泛指两者。
|
||||
|
||||
| 请求形态 | ESM | CommonJS | ③ 上该包名的位置 | 包内缺文件时 |
|
||||
|---|---|---|---|---|
|
||||
| 裸包名 `pkg`,包有 `exports` | ①② 有 `pkg` 目录则 Node 在其中按 `exports` 取入口 | 同 ESM;①② 的候选目录缺入口文件时不算候选 | 有条目:runtime resolution 的包;无条目:物理目录 | 有 `exports` 的入口缺失是 `exports` 错误,终局 |
|
||||
| 裸包名 `pkg`,包无 `exports` | 同上,入口取 `main` 或 `index` | 同上 | 同上 | ESM 终局报错;CommonJS 从下一层继续找入口 |
|
||||
| 子路径 `pkg/sub`,包有 `exports` | 第一个找到的 `pkg` 决定;`./sub` 未导出则 `ERR_PACKAGE_PATH_NOT_EXPORTED` 终局 | 同 ESM | 同上 | 终局,不找别的副本 |
|
||||
| 子路径 `pkg/sub`,包无 `exports` | 第一个找到的 `pkg` 内没有 `sub` 则 `ERR_MODULE_NOT_FOUND` 终局 | 逐层尝试 `<层>/pkg/sub`:①②,然后 ③ 上该包名的位置(有条目即 runtime resolution 的包目录),然后 ④ | 同上 | ESM 终局;CommonJS 继续下一层,即 ④ |
|
||||
| `#alias` 包内别名 | 所属 manifest 的 `imports` 映射到裸包名后,按上面各行处理;映射到相对路径则直接交 Node | 同 ESM,conditions 取调用方传入或默认值 | 同对应行 | 同对应行 |
|
||||
| `require.resolve(pkg, { paths })` | 无此形式 | 对 `paths` 中每一项独立套用同一规则:项在 profile 树内则从该项出发走 ①②、③、④;项在树外交 Node;按调用方给出的顺序 | 同上 | 该项继续下一层,再轮到下一项 |
|
||||
| 包自引用(importer 所属包按自己的 `name` import) | 交 Node,保留原 importer | 同 ESM | 不参与 | 不适用 |
|
||||
| 相对 / 绝对 / URL / builtin | 交 Node | 交 Node | 不参与 | 不适用 |
|
||||
|
||||
命中 runtime resolution 后,Node 报出的 `ERR_MODULE_NOT_FOUND` 与 `ERR_PACKAGE_PATH_NOT_EXPORTED` 会把内部的声明位置替换回原 importer;CommonJS 的 require stack 也去掉内部锚点。
|
||||
|
||||
#### 表 2:Web 与 Desktop 两种 profile 的拦截位置
|
||||
|
||||
| profile | 目录 | 拦截层 | 与 web 的差异 |
|
||||
|---|---|---|---|
|
||||
| web | `$DSH_HOME/profiles/web` | `$DSH_HOME/profiles/node_modules` | 基准 |
|
||||
| desktop | `$DSH_HOME/profiles/desktop` | `$DSH_HOME/profiles/node_modules` | 无。同一棵树、同一层;Desktop 的 Host 以 Node 模式运行同一套 resolver |
|
||||
| 树外 profile | `loadProfileDirectory` 接受的任意目录 | 该目录之上的第一个祖先 `node_modules` | 规则相同;当前没有产品使用树外 profile,只有单元测试覆盖 |
|
||||
|
||||
#### 表 3:插件被 link 到树外时的主线
|
||||
|
||||
| import 来源 → 目标 | hook 是否参与 | 结果 |
|
||||
|---|---|---|
|
||||
| profile → 链接的插件 | 参与到 ② 为止 | ② 的软链接被 Node 跟随,插件以真实路径加载 |
|
||||
| 链接插件 → 它自己的第三方依赖 | 不参与 | 真实路径的祖先链,使用开发者自己 `node_modules` 里的版本 |
|
||||
| 链接插件 → dsh 本体包 | 不参与 | 开发者自己 `node_modules` 里的副本;没有则 `ERR_MODULE_NOT_FOUND`。让它与运行中的 dsh 是同一份的做法见第三章 |
|
||||
|
||||
### 三、开发者接入指引
|
||||
|
||||
#### 1. 正常安装
|
||||
|
||||
`dsh plugin --profile <name> add <包名 | git spec | file:../本地检出>` 把参数转发给 pnpm,在 profile 目录内以 hoisted 布局安装。profile 的 `pnpm-workspace.yaml` 设置 `autoInstallPeers: false`,插件声明为 peer 的 dsh 包不会被装进 profile,由拦截层提供当前运行安装的那一份,插件与 dsh 共用同一个模块实例。
|
||||
|
||||
插件自己的第三方依赖被 hoist 到 `$DSH_HOME/profiles/<name>/node_modules`,按主线 ② 找到。与 installation 闭包同名时最近者优先,插件使用自己声明的版本。`file:` 形式把本地检出复制进 profile,随后的解析与注册表安装完全相同。
|
||||
|
||||
#### 2. 开发模式:插件仓库在 profile 树外
|
||||
|
||||
`npm link`,或 `dsh plugin add ../my-plugin` 这类裸目录路径(pnpm 按 `link:` 处理),会让 profile 里的条目成为指向插件仓库的软链接。插件以真实路径加载,hook 不参与它的 import。dsh 本体包必须由插件仓库自己的依赖树解析到与运行中 dsh 同一份,两种布局都满足这一点。
|
||||
|
||||
- 布局 A:插件仓库把 `@deepseek-ai/dsh` 以及插件用到的 `@deepseek-ai/dsh-*` peer 装成 devDependency,并用该仓库里的 dsh 启动,例如 `pnpm exec dsh --profile <name>`。运行中的安装就是插件祖先链上的那一份,两者的 runtime resolution 与真实路径一致。
|
||||
- 布局 B:插件仓库把用到的 dsh 包 link 到本机的 dsh 源码仓(`link:../deepseek-harness/packages/core/tools` 或 `pnpm link`),并从源码仓启动 `pnpm dsh --profile <name>`。两边加载的都是源码仓里的同一份。
|
||||
|
||||
不提供、不建议的做法:由 hook 为 link 出去的真实路径代理本体包,这需要追踪从 profile 进入的每个真实根目录,Node 自身也不替开发者做这件事;用别处安装的 dsh 启动,同时插件仓库另装一套 dsh devDependency,这会产生两个模块实例,`Symbol()` 键与 `instanceof` 判断都会分裂。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**让 runtime resolution 只在主线全部走完后兜底。** 主线会先在 ③ 读到历史残留的本体链接并使用它,与"本体以当前运行的安装为准"冲突。runtime resolution 必须占据 ③ 上本体包名的位置。
|
||||
|
||||
**把 runtime resolution 当作插入主线的一整层,而不是占据 ③ 上的包目录。** 两者只在一处不同:CommonJS 命中 runtime resolution 的包后子路径缺失时,前者会再回头读 ③ 上同名的旧副本,后者按 Node 对"已找到的包"的处理直接到 ④。占据包目录与 Node 语义一致,也不需要为 ③ 保留任何额外判断。
|
||||
|
||||
**让 installation 闭包条目绝对优先于更近的副本。** 闭包含 281 个第三方库。插件私有的 `zod`、`yaml` 等版本会被安装闭包里的版本覆盖,破坏与 Node 一致的最近者优先。受支持的安装流程已经不把 dsh 的 peer 装进 profile,不需要再用覆盖来保证单实例。
|
||||
|
||||
**在解析时识别 `.dsh-module-fallback` 形状的软链接并绕过。** 这会为不再产生的目录在查找路径里保留专门逻辑,每次解析都要付出判断。在 profile 加载时一次性删除这些投影能得到同样的结果:一个 bundle 被停用但仍装着时,它投影出来的插件不再遮住 runtime resolution 从另一个 bundle 选出的同名插件。
|
||||
|
||||
**把拦截层所在的物理目录整层隐藏。** 与"其余解析与 Node 一致"冲突:放在 `$DSH_HOME/profiles/node_modules` 的非本体包会对所有 profile 不可见。
|
||||
|
||||
**让 hook 覆盖 link 到树外的插件真实目录。** 需要在解析时追踪每个从 profile 进入的真实根目录,并对这些目录下的部分包名改写结果;Node 不为任何 link 出去的包做这种事,第三章的两种布局不需要它。
|
||||
|
||||
## Verification
|
||||
|
||||
- [profile-resolution.spec.ts](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts) 中的表驱动矩阵:importer 取 profile 根与 profile 内插件,包名取 installation 条目、bundle-only 条目与表外包名,①②③④ 四层的每一种有无组合,②③ 各含真目录与指向别处的软链接两种形态;每格断言 ESM import、CommonJS require、`require.resolve` 与 `packageDir` 元数据落在同一个目录。
|
||||
- 同一文件内的专项用例覆盖表 1 各行:有无 `exports` 的裸包名与子路径、`#alias`、显式 `paths`、包自引用、命中 runtime resolution 后 CommonJS 子路径缺失时跳过 ③ 直接到 ④,以及树外 profile 的拦截位置。
|
||||
- [CLI 真启动测试](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts)在 src 与 lib 两种启动、普通目录与 npm-link 两种布局下,放置 ③ 的旧 `@deepseek-ai/dsh-tools` 链接、③ 的外部包及其内嵌依赖,断言 Tools 与 AgentLoop 共用同一模块实例、外部包沿真实路径解析、所有文件与链接目标逐字节不变。
|
||||
|
||||
## Consequences
|
||||
|
||||
买到的:启动不写盘;本体包永远来自当前运行的安装;其余解析与 Node 一致,开发者可以用 Node 的知识预测结果;没有针对任何历史目录形状的识别代码。
|
||||
|
||||
付出的:installation 闭包里的第三方库仍占据 ③ 上的包目录,profile 插件把第三方库声明成 peer 或漏声明时会拿到 dsh 内部使用的版本,与磁盘链接时代相同,插件应把第三方库声明成 dependency;link 到树外的插件不能通过 hook 拿到本体包,必须按第三章的布局自行对齐;不经过 hook 的消费者,即插件自己 spawn 的子进程、第三方 Worker、在 profile 目录里运行的外部工具,不再能通过 `$DSH_HOME/profiles/node_modules` 的磁盘链接找到本体包;resolver 继续依赖受支持的 Node Internal 接口。
|
||||
@@ -1,27 +0,0 @@
|
||||
# Agent Note: Clean application-owned packages before Desktop production boot
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-15-desktop-profile-core-cleanup.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Old Desktop profiles contain installed core packages and local tarball dependency declarations. Local package precedence can combine an old Web frontend with new plugins even when the application carries a consistent release. Development fallback links also remain when users switch to an installed application.
|
||||
|
||||
## Decision
|
||||
|
||||
Production Desktop cleans profile copies of packages named in the verified runtime descriptor or the old Desktop package-set record before starting the Host, under the existing profile lock. Cleanup removes matching dependency declarations and pnpm overrides, invalidates the lockfile when package state changes, and unlinks fallback links without deleting their targets. Other plugins, bundle selections, configuration, and session data remain. Development skips cleanup.
|
||||
|
||||
The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [bundled runtime decision](../architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md); direct writes and failure recovery remain unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Installer-only cleanup** misses other user profiles and packages recreated after installation; both platforms use startup cleanup.
|
||||
|
||||
**Deleting every organization-prefixed package** can remove optional official plugins. Explicit current and historical package inventories determine ownership.
|
||||
|
||||
**Deleting package directories alone** lets pnpm reinstall the same old packages from retained declarations and overrides.
|
||||
|
||||
## Consequences
|
||||
|
||||
Production loses profile-local overrides of application-owned packages. A changed profile loses its lockfile and the next pnpm operation resolves remaining plugin dependencies again. Cleanup does not run pnpm or create rollback state; failures stop preparation and can be retried. Redirected package-parent directories fail before deletion. Focused tests cover retained plugins, declarations, repeated cleanup, development exclusion, retired packages, invalid records, and link targets.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Agent Note: 在 Desktop 生产启动前清理应用管理的包
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-15-desktop-profile-core-cleanup.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
旧 Desktop profile 包含已安装的核心包及本地 tarball 依赖声明。即使应用携带一致的发布产物,本地包优先规则仍可能把旧 Web 前端与新插件组合起来。用户切换到已安装应用时,开发模式的回退链接也会残留。
|
||||
|
||||
## Decision
|
||||
|
||||
生产版 Desktop 在启动 Host 前,持有现有 profile 锁,清理已验证运行时描述符或旧 Desktop 包清单记录列出的包副本。清理删除对应依赖声明和 pnpm overrides,在包状态变化时使锁文件失效,并解除回退链接而不删除其目标。其他插件、bundle 选择、配置和会话数据保留。开发模式跳过清理。
|
||||
|
||||
实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[内置运行时决策](../architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的包保留范围;直接写入和失败恢复保持不变。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**仅在安装器中清理**会遗漏其他用户的 profile 和安装后重新产生的包;两个平台都在启动时清理。
|
||||
|
||||
**删除组织名前缀下的所有包**可能误删可选官方插件。明确的当前及历史包清单决定归属。
|
||||
|
||||
**仅删除包目录**会让 pnpm 根据保留的声明和 overrides 重新安装相同旧包。
|
||||
|
||||
## Consequences
|
||||
|
||||
生产版不再使用 profile 对应用管理包的本地覆盖。发生变化的 profile 丢弃锁文件,下次 pnpm 操作会重新解析其余插件依赖。清理不运行 pnpm,也不创建回滚状态;失败会停止准备流程,之后可以重试。重定向的包父目录会在删除前报错。定向测试覆盖插件保留、声明、重复清理、开发模式排除、退役包、无效记录及链接目标。
|
||||
+2
-2
@@ -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/feature/2026-09-07-session-prose-local-media-display.md
|
||||
2026-09-07-session-prose-local-media-display.md: ed740acd0d0d6eaf7f8834dc8d6280a33305aecd
|
||||
2026-09-07-session-prose-local-media-display.zh.md: de8a7f99d7aabc4474f525f9f37f50a465a80840
|
||||
2026-09-07-session-prose-local-media-display.md: fe769d1cbe0b54eed355a7e699bf853a497820cb
|
||||
2026-09-07-session-prose-local-media-display.zh.md: 2cfff4016f42a13d94a1097602ff2dadbd28d2a7
|
||||
@@ -14,7 +14,7 @@ Local media paths in Session prose render through a same-origin file route. This
|
||||
|
||||
`ui-primitives` owns the `MarkdownPathImages` vocabulary on `MarkdownText`. Like `fileMentions`, it applies only after a message settles so frozen streaming blocks cannot cache a vocabulary handler. The settled pass rewrites image destinations outside the remote-URL allowlist and emits only absolute `http(s)`, `blob`, or `data` results. Without a vocabulary, local destinations retain inert alt text. Failed loads replace the image with authored alt text, or its original destination when alt is empty; a different source can load again.
|
||||
|
||||
`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `/api/file?path=…` on the page's origin. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert.
|
||||
`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `api/file?path=…` resolved against `document.baseURI`, so the request stays under whatever mount the served document came from. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert.
|
||||
|
||||
`session-controller` owns the `SessionMediaReferences` contribution beside `SessionFileReferences`. It registers through `connection.fetch`, which applies the same browser authentication and trust checks as `/api` RPC. The fixed same-origin endpoint gives the synchronous renderer a stable URL without an asynchronous capability negotiation.
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Session 正文中的本地媒体路径通过同源文件路由渲染。本记录
|
||||
|
||||
`ui-primitives` 拥有 `MarkdownText` 上的 `MarkdownPathImages` 词表。与 `fileMentions` 一样,它只在消息稳定后生效,使冻结的流式块无法缓存词表处理函数。稳定渲染过程重写远程 URL 白名单之外的图片目标,并只输出绝对 `http(s)`、`blob` 或 `data` 结果。没有词表时,本地目标保留静态 alt 文本。加载失败会把图片替换为作者提供的 alt 文本;alt 为空时显示原始目标路径;不同来源仍可重新加载。
|
||||
|
||||
`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到页面同源的 `/api/file?path=…`。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。
|
||||
`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到相对 `document.baseURI` 解析的 `api/file?path=…`,因此请求始终停留在所服务文档来源的那个挂载之下。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。
|
||||
|
||||
`session-controller` 在 `SessionFileReferences` 旁拥有 `SessionMediaReferences` 贡献。它通过 `connection.fetch` 注册;该通道执行与 `/api` RPC 相同的浏览器鉴权和信任检查。固定同源端点让同步渲染器获得稳定 URL,无需异步能力协商。
|
||||
|
||||
|
||||
+2
-2
@@ -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/feature/2026-09-08-feedback-dialog-and-categories.md
|
||||
2026-09-08-feedback-dialog-and-categories.md: ee656946a9e5d2921a02a1b642041e5965661f31
|
||||
2026-09-08-feedback-dialog-and-categories.zh.md: ef2b2d4670aad12398125925dd1e1a7e7c10a5f0
|
||||
2026-09-08-feedback-dialog-and-categories.md: 1d544e6ef978c10008ac62768b6faaf9eaaf8b73
|
||||
2026-09-08-feedback-dialog-and-categories.zh.md: 6e58820bf308a3a9f7d15b64027f90e7e6771bd4
|
||||
@@ -14,10 +14,14 @@ The Web client had two disconnected feedback paths with no visible outcome. `/fe
|
||||
|
||||
`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts its selected judgment with the dialog's category and note through the message controller, while the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback <text>` still reaches the Host; it uses the `action` kind in `CommandUiSpec`, which consumes the trigger token and runs a client callback without submitting anything. The later [symmetric message feedback submission](2026-09-10-symmetric-message-feedback-submission.md) decision owns the rating entry rule: either unrecorded rating opens the dialog, while clicking the recorded rating retracts it. The note popover, `clearNote`, and `clear` remain absent because the dialog is the only note editor.
|
||||
|
||||
The Session Header's more-actions menu also opens the Session dialog through the `feedbackUi.openSession` Client service. The menu remains owned by `session-log-export`, while the existing per-session feedback controller owns the draft and submission. An observable tracks the optional feedback plugin's availability, so unloading feedback removes only its menu row and leaves export usable. Opening or dismissing the form writes no feedback event; Submit records one Session remark.
|
||||
|
||||
The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with its draft and a warning toast presents the localized failure.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A child slot for Header menu actions.** A slot would let each plugin own its menu row, but requires a shared menu-item API beyond the feedback entry requested in #4445. The optional `feedbackUi` service keeps this change local while leaving dialog state and submission in the feedback plugin.
|
||||
|
||||
**Encode the category into the note text.** A prefix in free text is not filterable without parsing and would leak into the verbatim note that telemetry uploads; a durable id in the payload is what a consumer can group by.
|
||||
|
||||
**Submit the dialog through the command plane as `/feedback <text>`.** The command rejects empty text, cannot carry a category, and writes an acknowledgement row the design replaces with a toast; the Remote records the same event with neither constraint.
|
||||
|
||||
@@ -14,10 +14,14 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。`
|
||||
|
||||
`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的所选评分,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback <text>` 仍到达宿主;它使用 `CommandUiSpec` 中的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。后续的[对称消息反馈提交](2026-09-10-symmetric-message-feedback-submission.zh.md)决策拥有评分入口规则:任一未记录的评分都会打开弹窗,再次点击已记录的评分则撤回。备注浮层、`clearNote` 与 `clear` 继续保持移除,因为弹窗是唯一的备注编辑器。
|
||||
|
||||
会话标题栏的更多操作菜单也通过 `feedbackUi.openSession` Client 服务打开 Session 弹窗。菜单仍由 `session-log-export` 持有,现有的逐 Session 反馈控制器持有草稿与提交。一个可观察值跟踪可选反馈插件的可用状态,因此卸载反馈插件只移除其菜单行,导出仍然可用。打开或关闭表单不写反馈事件;点击提交才记录一条 Session 反馈。
|
||||
|
||||
弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗保留草稿并通过警告 toast 展示本地化错误。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**为标题栏菜单动作声明子 slot。** slot 可以让每个插件持有自己的菜单行,但需要共享菜单项 API,超出 #4445 所需反馈入口的范围。可选的 `feedbackUi` 服务将改动限制在局部,同时让弹窗状态与提交继续由反馈插件持有。
|
||||
|
||||
**把分类编进备注文本。** 自由文本里的前缀不解析就无法过滤,还会混进遥测上传的原样备注;载荷里的持久 id 才是消费方能分组的东西。
|
||||
|
||||
**让弹窗经命令平面以 `/feedback <text>` 提交。** 命令拒绝空文本、带不了分类,还会写一行设计稿已用 toast 取代的确认;Remote 记录同一个事件且没有这两个约束。
|
||||
|
||||
+2
-2
@@ -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/feature/2026-09-08-present-workspace-source-files.md
|
||||
2026-09-08-present-workspace-source-files.md: e1bc2bbc56d423741ca53550af2879fed3f01ecf
|
||||
2026-09-08-present-workspace-source-files.zh.md: e4f21172b36a5fef50e44a79adca71011ee90a3b
|
||||
2026-09-08-present-workspace-source-files.md: 52cc3e9cdbcd30d378117a327b86062523a2cb3e
|
||||
2026-09-08-present-workspace-source-files.zh.md: 99b7b9df1d9ca2da2175df1ae7e9d8acbb5c5fdd
|
||||
@@ -12,7 +12,7 @@ Users need to open and edit the files produced in their workspace, including she
|
||||
|
||||
The [present tool](../../../../packages/deliverables/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them.
|
||||
|
||||
The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations.
|
||||
The [tool description](../../../../packages/deliverables/tool-present/README.md#model-experience) guides the model's choice of files for explicit delivery. A declaration remains distinct from a prose file reference. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) covers the resulting file, delivery event, and card. New recordings explicitly request a separate file card; replay preserves the recorded request. This scenario verifies explicit delivery, not a requirement to call `present` for every SVG. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations.
|
||||
|
||||
The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
[present 工具](../../../../packages/deliverables/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
|
||||
|
||||
工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。
|
||||
[工具说明](../../../../packages/deliverables/tool-present/README.zh.md#model-experience)指导模型选择需要显式交付的文件。交付声明与正文中的文件引用各自独立。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)覆盖生成文件、交付事件和卡片。新录制明确请求独立文件卡片,回放保留录制时的请求。该场景验证显式交付,不要求所有 SVG 都调用 `present`。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。
|
||||
|
||||
工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Agent Note: Session unarchive Settings page
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-12-session-unarchive-settings-page.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Archiving a Session removed it from every Workspace grouping surface, and nothing brought it back. The archive set is a durable display filter, so a hidden Session kept its log, its Workspace accounting slot, and its position, but the only recovered route was editing the domain state by hand. The archived feature record predicted the restore surface as "one UI surface plus one inverse RPC"; both now exist, and the Client had no place that listed archived Sessions at all.
|
||||
|
||||
## Decision
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession(sessionId)` drops one id from the registry-global `archivedSessionIds` set in a single durable `setState`, running on the same operation chain as archive, create, and delete. The idempotent check-then-write pair sits inside one chain slot, so a concurrent archive cannot interleave and a lost race resolves as a no-op. An id that is not archived resolves without writing.
|
||||
|
||||
Unarchiving runs no session-existence probe. Archive verifies that the Session is live or persisted because it adds a reference that must resolve; unarchive only removes an id, so it cannot introduce an unknown referent and an entry whose Session is gone still restores. `@Remote('unarchiveSession')` on `WorkspaceController` returns the complete `WorkspaceArchiveValue`, matching `archiveSession`, and `IWorkspaces.unarchiveSession` plus `UiWorkspace.unarchiveSession` carry the verb to the browser. Both verbs answer with the complete set, so `ClientWorkspaceModel` installs a reply only while it is still the latest archive-set request: a later request, or a set pushed by the follow stream, supersedes an in-flight answer and keeps the projection on the newer state.
|
||||
|
||||
The new Web Settings page `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` owns the surface. It registers one localized `settings.section` contribution with id `archived-sessions` at nav order 25, joins the archive set from `useWorkspaces` with the loaded Session summaries from `useSessions`, lists the newest archive first with the owning Workspace title or the ungrouped label and a relative last-activity time, filters by title or Workspace name, and offers one Unarchive action per row. A rejected write is logged as a console diagnostic and leaves the row for another attempt. An archive entry whose Session summary is missing produces no row, so the page never renders an action that cannot restore anything.
|
||||
|
||||
Restoring is a page whose subject is archived Sessions, and the archived Sessions themselves are hidden from every grouping surface, so no Session row can host the action. The archived-sessions page joins the Settings navigation beside General, Models, and Plugins, whose rail already carries the archive glyph this page declares in `SettingsRoot`.
|
||||
|
||||
The `{ type: 'archived', archivedSessionIds }` follow increment already carries the complete set, so a restore reuses it: the Remote answer installs locally, and every other Client converges through the same increment. No frame type, persisted field, or `SESSION_FORMAT_VERSION` change accompanies the verb.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**An Unarchive action in the Session row menu.** The row menu owns Archive, but a restored Session has no visible row until it is restored, so the action would have nowhere to live in the state that needs it; a disabled entry on every row would be decoration without a subject.
|
||||
|
||||
**A second frame type carrying a removal delta.** The existing `archived` increment is a complete-set replacement, so a removal delta would add a redundant representation that every consumer would have to merge; the full-snapshot posture is what lets the archive echo be reused unchanged.
|
||||
|
||||
**Rendering an archive entry whose Session is gone as a disabled row.** Such a row could explain the gap but not restore anything, and the registry and the Remote still accept the id, so the page lists what it can act on and leaves the orphan id to a future cleanup surface.
|
||||
|
||||
**A Session-existence probe on unarchive, mirroring archive.** The check exists to keep added references resolvable, and a removal cannot break that invariant; probing would only turn restoring a deleted history into a failure with nothing to repair.
|
||||
|
||||
## Consequences
|
||||
|
||||
The archive set stays the only durable state a restore rewrites; the `workspace` domain version, the Session log, and the Workspace accounting slot are untouched, and a restored Session returns to its recorded position. Archive and unarchive now enforce different Session checks, a deliberate asymmetry recorded in the [Workspace registry limitations](../../../../packages/workspace/workspace/README.md#known-limitations-and-deferred-work).
|
||||
|
||||
The restore surface is bounded by what the page can display: an archived id whose summary is not loaded has no row and no Unarchive action even though the registry method and the Remote accept it, so the page reports an unavailable set instead of an empty archive. Restoring through automation stays available, and a future cleanup surface can address the remaining orphan entries.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/workspace/workspace/tests/workspace.spec.ts` pins the registry method: removal keeps the surviving archive order, the accounting slot stays, a repeat and a never-archived id neither rewrite the medium nor emit a change, an entry whose Session is gone resolves without a persistence listing, and the surviving set reloads across a restart. `packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` pins the verb's idempotent complete-set answer and the unchanged `archived` follow increment, and `packages/api/workspace-controller/tests/model.client.spec.ts` pins the Client model echo and its refusal to install a failed answer, plus the four stale-reply races: overlapping archive or unarchive requests settling out of order, and a follow increment or baseline arriving while a reply is in flight. `packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` pins newest-first ordering, the ungrouped label, the hidden entry whose Session is gone, the reading and empty states, search, and the console diagnostic on rejection, while `tests/browser-plugin.client.spec.tsx` pins the section registration.
|
||||
|
||||
## Related
|
||||
|
||||
- [Session archive (registry-global set)](../../archived/feature/2026-07-31-session-archive-global-set.md) — the frozen record of the archive set, the follow increment, and the predicted restore surface.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Agent Note: Session unarchive Settings page
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-12-session-unarchive-settings-page.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
归档会话会把它从每一个 Workspace 分组界面中移除,而没有任何东西能把它带回来。归档集合是持久的显示过滤器,因此被隐藏的会话保留其日志、Workspace 记账位置与所在位置,但唯一的恢复途径是手工编辑领域状态。归档功能记录把恢复界面预测为「一个 UI 界面加一个反向 RPC」;两者如今都已存在,而 Client 此前根本没有列出已归档会话的地方。
|
||||
|
||||
## 决策
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession(sessionId)` 在一次持久化 `setState` 中把某个 id 从注册表全局的 `archivedSessionIds` 集合里移除,并与 archive、create、delete 跑在同一条操作链上。幂等的「先检查再写入」位于同一个链槽内,因此并发归档无法插入其间,输掉竞态的一方会解析为无操作。未被归档的 id 不写盘即解析完成。
|
||||
|
||||
取消归档不做会话存在性探测。归档会校验会话处于实时或已持久化状态,因为它加入的引用必须可解析;取消归档只是移除一个 id,因此不可能引入未知引用,会话已不存在的条目也仍然能恢复。`WorkspaceController` 上的 `@Remote('unarchiveSession')` 返回完整的 `WorkspaceArchiveValue`,与 `archiveSession` 一致,而 `IWorkspaces.unarchiveSession` 与 `UiWorkspace.unarchiveSession` 把该动词带到浏览器。两个动词都返回完整集合,因此 `ClientWorkspaceModel` 只在该应答仍是最新归档集合请求时安装它:更晚的请求或 follow 流推送的集合都会取代在途应答,让投影停留在更新的状态上。
|
||||
|
||||
新的 Web 设置页 `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` 拥有该界面。它注册一个 id 为 `archived-sessions`、导航顺序为 25 的本地化 `settings.section` 贡献,把来自 `useWorkspaces` 的归档集合与来自 `useSessions` 的已加载 Session 摘要合并,按归档时间由新到旧列出,并显示为其记账的 Workspace 标题或未分组标签,以及相对最近活动时间;它按标题或 Workspace 名称过滤,并为每行提供一个取消归档操作。写入被拒绝时会记录一条 console 诊断,并保留该行以便再次尝试。缺失 Session 摘要的归档条目不产生行,因此该页面绝不会渲染无法恢复任何东西的操作。
|
||||
|
||||
恢复操作所在的页面以已归档会话为主题,而已归档会话本身从每一个分组界面中隐藏,因此没有任何 Session 行能承载该操作。「已归档会话」页在设置导航中与「通用」「模型」「插件」并列,而该导航轨道本就带有本页在 `SettingsRoot` 中声明的归档字形。
|
||||
|
||||
`{ type: 'archived', archivedSessionIds }` follow 增量本就携带完整集合,因此恢复复用它:Remote 应答在本地安装,其他每个 Client 都通过同一个增量收敛。该动词不伴随任何帧类型、持久字段或 `SESSION_FORMAT_VERSION` 变更。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在 Session 行菜单放一个取消归档操作。** 行菜单拥有 Archive,但被恢复的会话在恢复之前没有可见的行,因此该操作在真正需要它的状态里无处安放;给每一行加一个禁用条目只是没有主体的装饰。
|
||||
|
||||
**新增一种携带移除增量的帧类型。** 现有的 `archived` 增量是完整集合替换,因此移除增量会增加一种每个消费方都必须合并的冗余表示;正是全快照姿态让归档回声得以原样复用。
|
||||
|
||||
**把会话已不存在的归档条目渲染为禁用行。** 这样的行可以解释缺口,却无法恢复任何东西,而注册表与 Remote 仍然接受该 id,因此该页面只列出它能操作的条目,把遗留 id 留给未来的清理界面。
|
||||
|
||||
**像归档一样,在取消归档时做会话存在性探测。** 该检查的存在是为了让新增的引用保持可解析,而移除不可能破坏这一不变式;探测只会把恢复已删除的历史变成一次无从修复的失败。
|
||||
|
||||
## 后果
|
||||
|
||||
归档集合仍是恢复唯一重写的持久状态;`workspace` 领域版本、会话日志与 Workspace 记账位置都不受影响,被恢复的会话回到其记录的位置。归档与取消归档现在执行不同的会话校验,这是刻意的不对称,并记录在 [Workspace registry 的限制](../../../../packages/workspace/workspace/README.zh.md#known-limitations-and-deferred-work)中。
|
||||
|
||||
恢复界面受该页面能显示的内容限制:摘要未加载的已归档 id 没有行也没有取消归档操作,尽管注册表方法与 Remote 都接受它;因此页面报告没有可恢复条目,而不是把归档集合说成空的。通过自动化恢复仍然可用,未来的清理界面可以处理剩余的遗留条目。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/workspace/workspace/tests/workspace.spec.ts` 固定注册表方法:移除后幸存的归档顺序保持不变、记账位置仍在、重复取消归档与从未归档的 id 既不重写介质也不发出变更、会话已不存在的条目无需持久化列表即可解析、幸存集合在重启后重新加载。`packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` 固定该动词幂等的完整集合应答与不变的 `archived` follow 增量,`packages/api/workspace-controller/tests/model.client.spec.ts` 固定 Client model 的回声及其拒绝安装失败应答的行为,以及四种过期应答竞态:归档或取消归档请求重叠且乱序返回,以及应答在途时到达的 follow 增量或 baseline。`packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` 固定由新到旧的排序、未分组标签、会话已不存在条目的隐藏、读取与空状态、搜索,以及被拒绝时的 console 诊断,`tests/browser-plugin.client.spec.tsx` 则固定分区注册。
|
||||
|
||||
## 相关
|
||||
|
||||
- [会话归档(注册表全局集合)](../../archived/feature/2026-07-31-session-archive-global-set.md)——归档集合、follow 增量与被预测的恢复界面的冻结记录。
|
||||
+2
-2
@@ -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/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
|
||||
2026-09-13-macos-hidden-titlebar-vibrancy.md: 99ac240698fb229f9a0d66a8000251058b52491e
|
||||
2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 26bbb616485e49de2f70a3d142ea84dfd5bd7cb9
|
||||
2026-09-13-macos-hidden-titlebar-vibrancy.md: 807c79d06c468e01b8b9df3f18a32e5fbaba1e90
|
||||
2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: a521aff7624b1f9dbab18e7d0f02f61af0bdc24a
|
||||
@@ -18,9 +18,11 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o
|
||||
|
||||
**Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`.
|
||||
|
||||
**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a single, root-scoped slot `conversation.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The resident header keeps the leading seat mounted on darwin with or without a selected Session so a hidden sidebar always has a reopen control on screen.
|
||||
**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights and carries the collapse toggle; the frame's `.leadingBand` drag band covers the same 52px geometry, so the strip declares no app-region of its own and the toggle subtracts itself through the base.css interactive rule. Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances live in the frame's root-scoped `shell.leading` window-chrome seat, which the AppFrame mounts only while the column is fully hidden; ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. The seat placement, the `--dsh-frame-leading-clearance` variable panels pad by, and the removal of the earlier conversation-header seat are owned by the [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md).
|
||||
|
||||
**Drag regions.** The conversation title row is a drag region on darwin with every interactive descendant opted out. Electron computes drag regions from window geometry in DOM order, not stacking: an overlay covering the title band must subtract itself or the band underneath keeps taking the pointer. The right sidebar's fullscreen panel therefore sets `-webkit-app-region: no-drag` on its whole box and restores drag on its tab strips' blank runs.
|
||||
**Window fullscreen marker.** macOS fullscreen hides the traffic lights, so layouts built around them must relax. The main process relays `isFullScreen()` over `dsh-desktop:window-fullscreen` on `enter-full-screen`, `leave-full-screen`, and each `did-finish-load`; the app preload's `syncWindowFullscreen()` mirrors it onto `html[data-fullscreen]`. Consumers: the AppFrame drops `--dsh-frame-leading-clearance` to 84px and moves the seat to `left: 12px`, the sidebar's `.topStrip` moves the toggle to the strip's left edge, and the right sidebar's fullscreen panel resets its first pane's dockkit strip inset to the ordinary 10px.
|
||||
|
||||
**Drag regions.** Electron computes drag regions from window geometry in DOM order, not stacking, so `-webkit-app-region: drag` is declared only inside the two allowlisted stylesheets: ui-layout `AppFrame.module.css` (the darwin `.leadingBand`, a 52px full-width `pointer-events: none` strip rendered as the frame's first child, and the Windows caption `.frame::before`) and ui-sidebar `SidebarRoot.module.css` (`.logoRow`, which extends the band below 52px). Everything interactive subtracts itself through two global ui-web base.css rules: one `:is(...)` selector opting out every interactive element (buttons, links, inputs, roles, `[tabindex]`), and one `body > :not(#root)` rule covering every overlay portaled beside `#root`. Because the drag bands mount before all content, any later no-drag box wins; content containers never declare drag, which would override earlier-mounted overlays.
|
||||
|
||||
**Fullscreen traffic-light clearance.** ui-dockkit publishes the tab strip's start inset as `--dsh-dockkit-strip-inline-start` (fallback the design's own 10px). The right sidebar's fullscreen presentation on darwin sets 88px on the panel body and resets 10px for every non-first split cell's subtree, so exactly the pane touching the window's top-left corner clears the lights at any split depth.
|
||||
|
||||
@@ -40,10 +42,10 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o
|
||||
|
||||
- The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets.
|
||||
- The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering.
|
||||
- `conversation.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches.
|
||||
- Drag-region geometry is a window-global invariant: any future overlay that covers the title band on darwin must subtract itself with `-webkit-app-region: no-drag` or its controls become unclickable.
|
||||
- The reopen controls occupy the frame's `shell.leading` seat, a public slot in the client catalog; the earlier `conversation.session.header.leading` seat is removed ([shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md)).
|
||||
- Drag-region ownership is a window-global invariant: `-webkit-app-region: drag` appears only inside the two allowlisted stylesheets (ui-layout `AppFrame.module.css`: darwin `.leadingBand` and Windows caption `.frame::before`; ui-sidebar `SidebarRoot.module.css`: `.logoRow`); other packages rely on the base.css opt-outs or add a scoped `no-drag`, never a new drag surface. The ui-theme app-region stylesheet gate enforces the allowlist over every package stylesheet.
|
||||
- The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash.
|
||||
|
||||
## Testing
|
||||
|
||||
ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call with and without a selected Session. The ui-theme corner-shape and full-round style gates cover the new stylesheet.
|
||||
ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on teardown; the ui-layout app-frame spec pins the seat's darwin mount. The desktop main-startup spec pins the fullscreen relay (transitions, reloads, destroyed-window guard, darwin-only registration) and the preload-platform spec pins the `html[data-fullscreen]` mirror. The ui-theme corner-shape and full-round style gates cover the new stylesheet, and its app-region style gate pins drag-region ownership to the two allowlisted stylesheets across every package stylesheet.
|
||||
@@ -18,9 +18,11 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig
|
||||
|
||||
**原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。
|
||||
|
||||
**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:single、root 作用域的 slot `conversation.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上常驻头部在选中或未选中 Session 时均保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。
|
||||
**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯并承载收起按钮;框架的 `.leadingBand` 拖拽带覆盖同一 52px 几何,顶部条自身不声明 app-region,按钮经 base.css 交互规则自行减除。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口位于框架 root 作用域的 `shell.leading` 窗口 chrome 座,由 AppFrame 仅在整列隐藏时挂载;ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。座的位置、面板用于避让的 `--dsh-frame-leading-clearance` 变量,以及早先会话头部座的移除,归 [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md) 所有。
|
||||
|
||||
**拖拽区。** darwin 上会话标题行是拖拽区,所有可交互后代退出。Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠:覆盖标题带的浮层必须自行减除,否则下层区域仍会截获指针。因此右侧边栏的全屏面板对整个盒子设 `-webkit-app-region: no-drag`,再在其 tab 条空白处恢复拖拽。
|
||||
**窗口全屏标记。** macOS 全屏隐藏红绿灯,围绕它们构建的布局必须放松。主进程在 `enter-full-screen`、`leave-full-screen` 与每次 `did-finish-load` 时经 `dsh-desktop:window-fullscreen` 转发 `isFullScreen()`;应用 preload 的 `syncWindowFullscreen()` 把它镜像到 `html[data-fullscreen]`。消费者:AppFrame 把 `--dsh-frame-leading-clearance` 降到 84px 并把座移到 `left: 12px`,侧边栏 `.topStrip` 把收起按钮移到条带左端,右侧边栏全屏面板把首格 pane 的 dockkit strip 内边距重置回常规 10px。
|
||||
|
||||
**拖拽区。** Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠,因此 `-webkit-app-region: drag` 只在两个允许清单样式表内声明:ui-layout `AppFrame.module.css`(darwin 的 `.leadingBand`——52px 全宽、`pointer-events: none`、作为框架第一个子元素渲染的条带——与 Windows 顶栏 `.frame::before`)和 ui-sidebar `SidebarRoot.module.css`(`.logoRow`,把拖拽带向 52px 以下延伸)。所有可交互内容通过 ui-web base.css 的两条全局规则自行减除:一条 `:is(...)` 选择器让每个交互元素(按钮、链接、输入框、role、`[tabindex]`)退出拖拽,一条 `body > :not(#root)` 规则覆盖所有 portal 到 `#root` 之外的浮层。拖拽带先于全部内容挂载,任何靠后的 no-drag 盒子必然生效;内容容器永不声明 drag——那会覆盖更早挂载的浮层。
|
||||
|
||||
**全屏避开红绿灯。** ui-dockkit 把 tab 条起始内边距发布为 `--dsh-dockkit-strip-inline-start`(回退为设计自身的 10px)。右侧边栏全屏形态在 darwin 上对面板主体设 88px,并对每个非首格 split 单元的子树重置为 10px,使得任意分屏深度下恰好只有触及窗口左上角的 pane 避开红绿灯。
|
||||
|
||||
@@ -40,10 +42,10 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig
|
||||
|
||||
- macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。
|
||||
- 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。
|
||||
- `conversation.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。
|
||||
- 拖拽区几何是窗口级全局不变量:今后任何在 darwin 上覆盖标题带的浮层必须用 `-webkit-app-region: no-drag` 自行减除,否则其控件不可点击。
|
||||
- 重新打开控件占据框架的 `shell.leading` 座,是客户端 catalog 中的公开 slot;早先的 `conversation.session.header.leading` 座已移除([shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md))。
|
||||
- 拖拽区归属是窗口级全局不变量:`-webkit-app-region: drag` 只出现在两个允许清单样式表内(ui-layout `AppFrame.module.css`:darwin `.leadingBand` 与 Windows 顶栏 `.frame::before`;ui-sidebar `SidebarRoot.module.css`:`.logoRow`);其他包依赖 base.css 的退出规则或添加限定范围的 `no-drag`,永不新增拖拽面。ui-theme 的 app-region 样式门禁对全部包内样式表强制该允许清单。
|
||||
- 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。
|
||||
|
||||
## Testing
|
||||
|
||||
ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 选中或未选中 Session 时对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。
|
||||
ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 teardown 移除;ui-layout 的 app-frame 测试钉住 darwin 下座的挂载。desktop main-startup 测试钉住全屏转发(状态切换、重载、已销毁窗口守卫、仅 darwin 注册),preload-platform 测试钉住 `html[data-fullscreen]` 镜像。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表,其 app-region 样式门在全部包内样式表上把拖拽区归属钉在两个允许清单样式表内。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md
|
||||
2026-09-17-sidebar-file-and-directory-auto-refresh.md: f33be6a8d45756d3bac83d0a6e21a4110543e3b2
|
||||
2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: 88fe6e7abb4027ec8b5c4fab4f1ad07477aef6fa
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
# Agent Note: On-demand watching and automatic refresh for Sidebar previews and file trees
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Sidebar's Document Preview and Files panels need to reflect current disk state. Changes come from Harness file tools, shell commands, user editors, and other processes; observing only Harness file operations is insufficient.
|
||||
|
||||
The starting implementation has both read paths but no complete automatic-refresh path.
|
||||
|
||||
| Object | Starting behavior | Gap |
|
||||
|---|---|---|
|
||||
| File Resource | `ResourceProvider.open()` supplies initial metadata and later changes; `ResourceRegistry` owns subscriptions and retention; components read through `useResource` | File changes do not come from an OS watcher |
|
||||
| Document Preview | `TextPreview` compares Resource and content versions and provides reload for three loading modes | A change only prompts the user to reload |
|
||||
| Workspace file events | `WorkspaceFiles.changes()` forwards the current Session's `fs/observed`; the Client filters by file | The Host does not know which paths are followed; external editors and shell writes produce no such events |
|
||||
| Files panel | `filesFace` lists directories; `createFilesStore` retains loaded levels and expansion state | Creation, deletion, and renaming require manual refresh; reopening an expanded directory can show old cached entries |
|
||||
|
||||
`Resource` holds file metadata, not preview contents. Document Preview or its selected renderer reads the contents. Metadata updates and content reloads therefore remain separate responsibilities: the generic Resource acquires no content cache, and renderers do not directly manage filesystem watchers.
|
||||
|
||||
## Decision
|
||||
|
||||
On-demand observation connects the existing FS, Workspace file stream, Resource, and Sidebar components. Files and directories share OS watching and the existing Remote stream transport, but consume them differently.
|
||||
|
||||
- Document Preview follows the current file. Its Resource publishes new metadata, and the preview invokes its existing reload. The HTML root and referenced CSS/JS files join one `ResourceGroup`; any member change invalidates the whole HTML preview.
|
||||
- Files manages reads and watchers through a directory-node tree. Each open node loads and watches only its direct entries. The Client loads and subscribes to exactly the subtree levels it opens, without traversing unopened descendants.
|
||||
- Collapsing a directory releases that directory's watch and those of its hidden descendants. Reopening subscribes and reads again; the old cache is not treated as current.
|
||||
- Watches carry only metadata or invalidation. Contents still use the existing file readers; directory contents still use `list()`.
|
||||
- Local Chokidar watches use OS events, without polling the whole Workspace or recursively scanning all descendants by default.
|
||||
- Both panels retain manual refresh and automatic-refresh functionality, but temporarily hide the automatic-refresh icon button. State and toggle logic remain; new Tabs default to enabled. Hiding the control does not stop observation or automatic refresh.
|
||||
|
||||
Notifications invoke existing reload/list operations; a failed reload does not retain previous preview content. The following sections describe the implementation's responsibilities, data flow, and verification.
|
||||
|
||||
## Implementation and responsibilities
|
||||
|
||||
### FS and Host
|
||||
|
||||
| Class or type | File | Responsibility |
|
||||
|---|---|---|
|
||||
| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | Declare abstract single-target `watch(target, changed, signal)`: a file observes itself; a directory observes its direct entries. Resolve with an async close function once ready. Unsupported providers explicitly throw, without a new FS error code |
|
||||
| `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | Reuse the existing target type, without another FS watch-event system or upper-layer parsing of `targetKey` |
|
||||
| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | Watches a file's parent with target-only filtering, or the directory itself for directory targets; awaits Chokidar readiness and closure; owns the Chokidar dependency |
|
||||
| `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | Inherit local read-only observation without duplicating the watcher; retain its write and edit policy checks |
|
||||
| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` explicitly throws an ordinary exception; the Host converts it to a `workspace-file/watch-unsupported` `RemoteError`, without importing or checking an `FsError` runtime class across packages or passing remote `processPath()` values to local Chokidar |
|
||||
| `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | Use `changes(scope, path, signal)` with only the target path. Host stat determines whether directory containment applies, including after type changes; ordinary files retain file-read authority |
|
||||
| `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Establish the target watch in the existing follow path and emit readiness and changes. Retain the queue and operation-observation input; filter by target before emission without rewriting dispatch |
|
||||
| `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Receive operation observations and OS notifications; handle watch errors, close independently of generator pulls, and await watcher closure |
|
||||
| `WorkspaceFileWatchFrame` | [workspace-files/src/types.ts](../../../../packages/api/workspace-files/src/types.ts) | Reuse `ready`/`change` frames for a path-only request, without a request kind or separate directory frame format |
|
||||
|
||||
### Client and Sidebar
|
||||
|
||||
| Class, function, or type | File | Responsibility |
|
||||
|---|---|---|
|
||||
| `ChangeFeed`, `SessionFeed`, `Follower` | [workspace-files/src/client/change-feed.ts](../../../../packages/api/workspace-files/src/client/change-feed.ts) | Key streams by Session and target path; reuse readiness, path binding, cancellation, and reconnect logic without reorganizing classes just to rename them |
|
||||
| `createFileResourceProvider` | [workspace-files/src/client/provider.ts](../../../../packages/api/workspace-files/src/client/provider.ts) | Pass the address's Session and path to the target stream; publish complete metadata initially and after notifications, rather than replacing `version` while retaining stale `bytes` |
|
||||
| `ResourceRegistry`, `ResourceProvider`, `UseResource` | [Resource definitions](../../../../packages/client/resources/src/client/contract.ts), [ResourceRegistry](../../../../packages/client/resources/src/client/resources.ts) | Keep existing streaming, retention counts, and subscriptions; add neither a second `onChange` nor a generic content-reload API |
|
||||
| `TextPreview` | [TextPreview.tsx](../../../../packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx) | Invoke existing reload when a Resource or group changes and automatic refresh is enabled; retain errors and manual refresh, with the separate automatic-refresh button temporarily hidden |
|
||||
| `textFace`, `TabReads`, `createTextStore` | [Preview face](../../../../packages/client/ui-sidebar-documentpreview/src/client/face.ts), [Preview store](../../../../packages/client/ui-sidebar-documentpreview/src/client/store.ts) | Own one group per Tab; add default-enabled `autoRefresh` and pending-refresh flag `resourcesDirty`; reuse content-read generations and `loadRevision` |
|
||||
| Package-private `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | Observes members through existing `resources.source(address)`; `add` joins a resource, `set` reconciles full membership, and `close` releases all members without changing the generic Resource service |
|
||||
| `DocumentBodyOwner`, `HtmlBody`, `createReadHtmlRelative` | [Renderer inputs](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts), [HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx), [Related-file reader](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | Renderers declare read members through `addResource` and publish the current dependency list through `setResources`; HTML adds CSS/JS resources from Host-reported paths after each read returns and releases unreferenced members after parsing |
|
||||
| Document renderers | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) and renderer directories in the same package | Do not watch directly; keep consuming text, bytes, or renderer revisions; `failed()` ends renderer loading without recording a successful version. Check cancellation of previous work, release of old objects, and retention of existing view preferences during refresh |
|
||||
| `filesFace`, `FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | Remain the existing business entry point, directly owning each Tab's root node; bind directory reads and change streams and forward expand, collapse, refresh, and automatic-refresh operations without a new `FilesController` |
|
||||
| Package-private `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | Represents one open directory, owning its stream, read state, and open children; handles local refresh, expansion, recursive closure, and propagation of automatic-refresh state; `setExpanded` updates pending restoration from the latest expansion preferences |
|
||||
| `createFilesStore`, `FilesTabState`, `LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | Retain expansion, scrolling, and displayed directory data; distinguish initial loading from refresh over existing content to avoid unmounting subtrees on every background read; hold no watcher or AbortController |
|
||||
| `FilesBody`, `Level`, `Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | Delegate expansion, collapse, refresh, and panel unmount to injected callbacks; render node results without scanning the entire tree into a watch set, using existing framework hooks and store reads |
|
||||
| Files registration | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | Add a directory-change callback to the existing `filesFace`; keep async effects in injection, without importing another feature plugin's runtime exports |
|
||||
|
||||
## Watch requests and notifications
|
||||
|
||||
### One stream per explicit target
|
||||
|
||||
Each request names one target path. The Host determines its current type through `stat`; the Client supplies no file/directory kind.
|
||||
|
||||
```text
|
||||
changes(sessionId, path, signal)
|
||||
changes(scope, path, signal)
|
||||
```
|
||||
|
||||
The Host method still receives resolved Session context through `WorkspaceFileScope`. The Client supplies a Session id; it cannot forge the Host's working directory or permissions.
|
||||
|
||||
The Host checks Workspace containment whenever the current target is a directory, including when an absent or ordinary-file target becomes a directory. Ordinary files outside the Workspace remain readable and watchable through the selected FS.
|
||||
|
||||
| Notification | Contents | Consumer action |
|
||||
|---|---|---|
|
||||
| Ready | This generation's target watch is established | Perform initial or reconnect `stat()`/`list()` and consume changes queued during the read |
|
||||
| File present or updated | Existing path and version notification; Client stats again | Update Resource path, version, and size from the complete result; reread contents when automatic refresh is enabled |
|
||||
| File absent | Target path and absence state | Resource reports unavailability; preview retains loaded content and shows the error |
|
||||
| Directory invalidated | `change` frame in that directory's target stream | Call only that directory's `list()` again, without deduplicating by directory version or refreshing the entire tree |
|
||||
| Watch failure | Initialization failure reports `workspace-file/watch-unsupported`; runtime errors end the watch | The Client handles failure where it consumes the watch; ordinary reads and manual refresh remain available, without reporting file deletion |
|
||||
|
||||
Notifications need neither file contents, complete directory listings, nor per-change patches. Paths identify filenames; current stat supplies size. Modification time used only for freshness remains part of the backend version token, without UI token parsing or another timestamp comparison.
|
||||
|
||||
Resource path, version, and size come from the same stat. Keep the existing small notification followed by Client stat instead of expanding the event protocol to save one request. Directory invalidation cannot depend only on directory mtime: a direct child's size or type can change `list()` output, so relevant child events invalidate that level.
|
||||
|
||||
### OS watching and access scope
|
||||
|
||||
- `FileSystem.watch()` accepts a target resolved by its provider; the FS provider owns the meaning of local or remote paths.
|
||||
- File watching retains file-read access rules. Existing previews may read some paths outside the Workspace through the selected FS; directory-tree containment must not be imposed on those files.
|
||||
- Directory watching retains `list()`'s Workspace scope and path checks, including after target type changes, without widening browsing authority.
|
||||
- File watching covers in-place writes, replacement by a temporary file, deletion, and recreation at the same path while the parent directory remains. The local provider watches that parent directly, filters entries and events to the target file, and waits for the parent watcher to be ready before reporting readiness, including when the file does not yet exist.
|
||||
- A directory watch observes itself and direct entries, without recursively opening unexpanded children. Creation, deletion, renaming, type changes, and visible metadata changes of direct entries invalidate that level.
|
||||
- Local watching uses OS events. Deployment controls such as write-stability time or event-coalescing windows, if needed, belong in their owner's configuration rather than scattered component constants.
|
||||
- OS watching drives UI refresh only. It does not fabricate an agent's authoritative `fs/observed` read observation or change the observed-version policy for editing.
|
||||
|
||||
## Complete file-preview data flow
|
||||
|
||||
```text
|
||||
Files.openResource / file.link
|
||||
→ Sidebar.openTab
|
||||
→ TabDomain → Resource.pin
|
||||
→ ResourceRegistry → createFileResourceProvider.open
|
||||
→ changes(sessionId, path)
|
||||
→ WorkspaceFiles → FsTarget
|
||||
→ WorkspaceChangeFeed → FileSystem.watch
|
||||
→ LocalFileSystem → Chokidar
|
||||
→ ready → Client.stat → ResourceSnapshot
|
||||
|
||||
Editor / Shell / file tool → disk
|
||||
→ Chokidar → LocalFileSystem
|
||||
→ WorkspaceChangeFeed → FileSystem.stat
|
||||
→ Remote.change
|
||||
→ Client.stat → ResourceSnapshot
|
||||
→ ResourceGroup → TextPreview
|
||||
→ reloadPages / reloadAll / prepareRenderer
|
||||
→ DocumentContent / renderer revision
|
||||
→ renderer
|
||||
```
|
||||
|
||||
Preview content reads may run in parallel with metadata initialization. ResourceGroup uses each member's first metadata as a baseline and forwards only later changes, without first-read version reconciliation.
|
||||
|
||||
| Loading mode | Existing refresh entry | Result |
|
||||
|---|---|---|
|
||||
| `text-pages` | `reloadPages()` | Retire the old content generation and reread text pages without mixing versions |
|
||||
| `bytes-complete` | `reloadAll()` | Reread complete bytes for PDF, HTML, image, and other byte consumers |
|
||||
| `renderer` | `prepareRenderer(..., reload = true)` | Increment `loadRevision`; the renderer cancels its previous load and requests again. Office failures end loading through `failed()`, allowing later file changes to retry |
|
||||
|
||||
Ordinary renderers need neither `watch()` nor a new reload interface. Changes to existing `DocumentContent` text, bytes, and revisions already drive reloading.
|
||||
|
||||
## HTML ResourceGroup
|
||||
|
||||
An HTML preview's group contains the HTML root and CSS/JS files actually read by the existing packer. Every member is an ordinary file Resource, with no separate dependency watcher or file type. The group is package-private and reuses Resource subscriptions and retention counts.
|
||||
|
||||
```text
|
||||
index.html Resource ─┐
|
||||
theme.css Resource ──┼→ ResourceGroup → resourcesDirty → autoRefresh → HTML reload
|
||||
page.js Resource ───┘
|
||||
```
|
||||
|
||||
- `addResource(address)` joins a dependency after `readRelated` returns its Host-resolved `absolutePath`. Existing members do not resubscribe, and no content-read version is passed or retained.
|
||||
- `setResources(addresses)` commits the parsed dependency list. The preview owner always retains the root and releases other members absent from the new list.
|
||||
- CSS/JS references use the HTML's Session and Host path resolution. A failed read also retains a string `error.details.path` supplied by the Host; without that path, the Client does not guess one.
|
||||
- A CSS or JS change reloads the complete HTML and creates new iframe content even when the HTML source itself is unchanged.
|
||||
- Missing dependencies with a Host-reported path remain members. Parsing failures retain already discovered dependencies so their later changes can trigger another load.
|
||||
- A new member's first metadata establishes a baseline without invalidating the preview. Only subsequent metadata changes notify; initial reads have no additional version reconciliation.
|
||||
- Group changes set only `resourcesDirty` to `true`. Refresh clears it; another change during reading sets it again. No notification counter or separate read generation is added.
|
||||
- Closing the preview Tab releases the whole group. Disabling automatic refresh only stops automatic rereads; the group continues observing and recording stale state.
|
||||
|
||||
This version covers direct stylesheet links and classic JS scripts already supported by the interactive HTML packer. It does not add CSS `@import`, ES module graph, or runtime network dependency discovery. A newly opened static safe preview reads no related files and observes only the root. Switching interactive HTML to static preview releases dependency watches and retains the root Resource.
|
||||
|
||||
## Automatic-refresh controls
|
||||
|
||||
Document Preview and Files each retain their existing reload button. The separate automatic-refresh icon control is temporarily hidden; its state, toggle logic, copy, and styles remain. New Tabs still default to `autoRefresh: true`. Hiding the control does not change its state or stop observation.
|
||||
|
||||
| State or action | Behavior |
|
||||
|---|---|
|
||||
| Automatic refresh enabled | The control is highlighted, uses the existing pause icon, and offers “Disable auto refresh”; changes refresh the corresponding preview or directory node |
|
||||
| Disable | The control shows the play icon and offers “Enable auto refresh”; observation continues without automatically replacing displayed content |
|
||||
| Reenable | Refresh once immediately if changes occurred while paused, then resume automatic refresh |
|
||||
| Original reload button | Read again regardless of automatic-refresh state, without changing that state |
|
||||
| Expand a new directory while paused | The new directory still loads initially and subscribes; the switch controls only subsequent automatic rereads |
|
||||
|
||||
Pause and play use existing icons; manual reload retains its circular arrow. No new icon system is added. The button exposes state through `aria-pressed`, with its name and tooltip in the existing locale dictionaries.
|
||||
|
||||
## Complete Files-panel data flow
|
||||
|
||||
```text
|
||||
Files.open
|
||||
→ FilesBody → Session.cwd + Tab
|
||||
→ filesFace.start → DirectoryNode.open
|
||||
→ changes(sessionId, path)
|
||||
→ Host.watch(depth: 0) → ready
|
||||
→ DirectoryNode → list(sessionId, path)
|
||||
→ createFilesStore.levels[path]
|
||||
→ Level / Entry
|
||||
|
||||
Entry.expand
|
||||
→ filesFace.toggle
|
||||
→ DirectoryNode.expand → child.open
|
||||
→ child.watch → ready → child.list
|
||||
→ createFilesStore.expanded + levels[path]
|
||||
|
||||
Process → create / remove / rename
|
||||
→ Host.change(directory)
|
||||
→ DirectoryNode.refresh → list(path)
|
||||
→ createFilesStore.levels[path]
|
||||
→ DirectoryNode.children ↔ list.entries
|
||||
→ DirectoryNode.collapse → child.close
|
||||
```
|
||||
|
||||
File previews and the directory tree are independent consumers. A preview can refresh without an open Files panel; Files can update entries without any file preview. Neither a Resource for the whole tree nor a new directory Resource protocol is needed.
|
||||
|
||||
The directory panel obtains invalidations and listings through injected Remote callbacks and writes displayed values through its own store. Components retain their existing read interfaces and do not directly hold Chokidar, Remote iterators, or low-level observation objects.
|
||||
|
||||
## Directory-node tree and lifecycle
|
||||
|
||||
The Files runtime is itself an on-demand directory tree. It does not first construct the whole Workspace tree or separately derive a flat watcher set. Existing `filesFace` owns each Tab's root; `DirectoryNode` owns reads, observation, and release recursively, without another controller class.
|
||||
|
||||
| Node responsibility | Behavior |
|
||||
|---|---|
|
||||
| Directory identity | Hold this directory's path and Session/Tab ownership; filesystem roots and Windows drive roots are valid roots, and listings contain only direct entries |
|
||||
| Directory watch | One open node holds one target stream; notifications invalidate only this node's listing |
|
||||
| Directory read | Retain the active list generation and pending rereads through read completion; publish results through store actions |
|
||||
| Open children | Hold child `DirectoryNode` objects by direct-child path; unexpanded directories remain listing entries without active nodes |
|
||||
| Expand | A directory-row action creates or reuses its child, which subscribes and reads; restoration opens only directories still present in the listing |
|
||||
| Collapse | Remove and close the active child; recursively close its descendants and release its watch and requests |
|
||||
| Listing update | Retain open children still present; close deleted, renamed, or file-replaced branches without reopening every node |
|
||||
| Close tree | Closing the root recursively releases the active tree, without traversing another global watcher registry |
|
||||
|
||||
`createFilesStore.expanded` retains user expansion preferences, but is not another watcher registry. Closing a parent cannot retain active descendant nodes merely because their preferences remain. Reopening first reads the parent, then progressively restores still-present children. Expand and collapse actions update the store and pending restoration even when the parent node is not yet active; restored descendants follow the latest preferences instead of reopening a collapsed branch.
|
||||
|
||||
```text
|
||||
workspace/ watch
|
||||
├── src/ open watch
|
||||
│ ├── components/ open watch
|
||||
│ └── internal/ closed -
|
||||
└── assets/ closed -
|
||||
|
||||
collapse(src/):
|
||||
workspace/ watch
|
||||
├── src/ close(src/, components/)
|
||||
└── assets/ -
|
||||
```
|
||||
|
||||
The parent's watch still detects deletion, renaming, or replacement of a collapsed child, but not changes deep inside it. Listing again on expansion discovers changes made while it was not watched.
|
||||
|
||||
| User action or lifecycle event | File preview | Files panel |
|
||||
|---|---|---|
|
||||
| First open | Subscribe to the file Resource and read contents | Open the root node, which subscribes and lists |
|
||||
| Expand child | No effect | The parent opens the child; the child subscribes and reads direct entries, progressively restoring still-present descendants from preferences |
|
||||
| Collapse child | Open file previews keep their watches | Release that directory and its hidden descendants; expansion preferences and displayed cache may remain |
|
||||
| Switch Tab or unmount panel | The Tab pin retains file metadata; an unmounted body does not reread, and refreshes on return according to pending Resource changes and toggle state | Retain expanded nodes and watchers for the Tab lifetime; do not introduce another panel-visibility lifetime |
|
||||
| Manual refresh | Reuse the current preview reload without rebuilding the Resource system | Recursively refresh open children from the root, without entering collapsed directories or rebuilding all watchers |
|
||||
| Last holder closes | Resource abort ends the file stream; the Host awaits watcher closure | Tab closure ends every directory stream, cancels reads, and forgets Tab state |
|
||||
| Connection recovers | Stat again after the new stream's ready frame, discovering changes even without event replay | List every still-watched directory after its new stream's ready frame |
|
||||
|
||||
Files watches follow Tab lifetimes. Collapse closes the corresponding subtree; Tab closure releases the root and its entire active tree. Temporarily hiding the panel does not rebuild nodes, keeping implementation within the existing entry point and directory objects. The store retains expansion preferences; nodes use them for progressive restoration only when reopened, without a second global expansion set.
|
||||
|
||||
`ResourceRegistry` continues sharing identical complete file Resource addresses. Parent-child ownership prevents duplicate active branches inside one Tab. This version adds no global watcher deduplication across Sessions, windows, or path aliases. Ordinary file rows do not each subscribe to file watches; only files opened as preview Resources acquire independent file observation.
|
||||
|
||||
## User flows
|
||||
|
||||
### Edit a previewed file
|
||||
|
||||
1. The user opens Markdown, code, an image, PDF, HTML, or Office file from Files.
|
||||
2. The user saves it in an external editor or rewrites it through a shell command.
|
||||
3. Automatic refresh is enabled by default, so Document Preview displays new content. Disabling it retains current content until manual reload or reenabling.
|
||||
4. Renderer selection and wrapping preferences remain. Text scrolling uses existing restoration; shorter content limits the available position.
|
||||
5. Exact position restoration for PDF, HTML, and images is not an additional promise. Existing renderer behavior remains, without a cross-format location system for automatic refresh.
|
||||
|
||||
### Browse changing directories
|
||||
|
||||
1. The user opens Files; the root lists and begins observation.
|
||||
2. The user expands `src` and `src/components`; both start their own watchers.
|
||||
3. An external tool creates a file in `src/components`; only that level refreshes, without collapsing or reloading other directories.
|
||||
4. The user collapses `src`; its own watch and the `components` watch close, while the root remains watched.
|
||||
5. External changes continue while collapsed. Reopening `src` rereads it and progressively restores valid descendant expansion, rather than remaining on old cached entries.
|
||||
|
||||
### Deletion, renaming, and failure
|
||||
|
||||
- Deleting a file retains existing preview content with an unavailable notice; recreating its original path while its parent remains resumes reading. Renaming means disappearance at the old path and appearance at a new one, without rewriting the preview address from inode identity.
|
||||
- Deleting or renaming a directory refreshes its parent, removes the old row, and releases the old subtree. A new directory appears under its new name without transferring old-path expansion preferences.
|
||||
- Automatic and manual refresh invoke the same existing reload, retaining its clearing, loading, and error presentation. No old-byte or old-iframe retention branch is added. Directory refresh retains the existing listing cache to avoid rebuilding active subtrees.
|
||||
- When a backend cannot watch, the Host converts initialization failure into a `workspace-file/watch-unsupported` `RemoteError`. The Client ends observation while continuing ordinary reads and manual refresh, without polling. External SSH filesystem changes do not automatically update previews.
|
||||
|
||||
## Readiness, races, and refresh coalescing
|
||||
|
||||
Observation, reads, and presentation need a few explicit ordering rules, not disk transactions or an operation log.
|
||||
|
||||
- Establish the watch before sending ready, then obtain initial stat/list. Do not discard changes arriving during a read; reread when necessary to converge on current state.
|
||||
- Events mean that a target may have changed, not exact replay of a user operation. Duplicate Chokidar events and consecutive writes may coalesce; files deduplicate versions, and directories coalesce pending refreshes.
|
||||
- Each directory has at most one active list. Further invalidation during that request or its completion marks a pending reread, which runs afterwards rather than building an arbitrary queue of refresh jobs.
|
||||
- File refresh reuses read generations and renderer revisions. Consecutive notifications must not let old requests overwrite new content or endlessly reload an already handled version.
|
||||
- Background directory refresh retains the displayed listing without a display-unused `refreshing` flag. Only a directory without displayable entries enters `loading`, avoiding subtree flicker and repeated watcher reconstruction.
|
||||
- After collapse, deletion, or Tab closure, late cancelled reads cannot recreate state or restore watchers. Cancellation closes the Host watcher even while the generator is suspended at a yielded frame; stream and plugin teardown await closure.
|
||||
- Temporary file disappearance while its parent remains does not end observation. Ordinary file changes and watcher failures remain distinct; same-path file watches cover atomic saves and delete/recreate.
|
||||
- Existing file reads are not transaction snapshots: writes can occur between stat and content reading. Later notifications trigger refresh; automatic refresh does not promise an atomic snapshot for every read.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Another Resource `onChange` and content reload.** Existing `open()`, `source()`, and `useResource` already express change, while the generic Resource does not own contents. Duplicate interfaces would make metadata, contents, and renderers each manage refresh state.
|
||||
|
||||
**Recursive watching of the whole Workspace.** Opening one file or root directory should not watch every deep directory. Watching the current file and expanded directories ties resource use to what the user has actually opened.
|
||||
|
||||
**Keep Session broadcasts and add path registration/unregistration.** The Host needs explicit targets, but not another path set maintained through additional commands. One target per stream directly reuses cancellation, reconnect, and ending semantics.
|
||||
|
||||
**Push contents or complete listings.** This mixes observation with reads and duplicates pagination, byte limits, formats, and entry caps. Small notifications followed by existing `read()`/`list()` calls are more direct.
|
||||
|
||||
**Apply directory-row additions and removals from Client events.** Filesystem events can coalesce or repeat, while listings own sorting, types, and entry limits. Relisting the affected level retains existing semantics without a directory-patch merge algorithm.
|
||||
|
||||
**Call Chokidar directly in the Workspace API.** The API does not own the execution world; SSH paths may exist only remotely. The actual FS provider must implement watching.
|
||||
|
||||
## Verification
|
||||
|
||||
The owning FS, Workspace API, preview, and Files tests exercise watch lifetimes, metadata updates, and automatic refresh. The [Web document-preview test](../../../../apps/web/tests/document-preview.e2e.ts) exercises previews through the shipped Web composition. Session event logs, persistence formats, and model input are unchanged.
|
||||
|
||||
| Scenario | Verified behavior |
|
||||
|---|---|
|
||||
| External in-place writes, atomic replacement, shell writes | Open previews refresh without depending on `fs/observed` |
|
||||
| Text, complete bytes, renderer-owned loading | All three modes refresh; old requests cannot overwrite new results, and Office failures allow a later file change to retry |
|
||||
| First member metadata | Establish a baseline without an extra reload or first-read version reconciliation |
|
||||
| Only CSS/JS changes, with unchanged HTML root | The group reloads the complete HTML |
|
||||
| HTML removes a dependency reference | Updating membership releases the unreferenced Resource |
|
||||
| Disable automatic refresh, modify, then reload or reenable | Content remains while paused and catches up on manual action or reenabling; the controls remain independent |
|
||||
| Root's direct entries are created, deleted, or renamed | The Files root level updates automatically, including when the workspace is a filesystem or Windows drive root |
|
||||
| An expanded child changes | Refresh only its level while preserving other levels and expansion preferences |
|
||||
| Unexpanded deep directories | No deep watcher or recursive traversal |
|
||||
| Collapse a parent | Release its watch and hidden descendants; an independently open file preview keeps watching |
|
||||
| Collapse or reexpand a deep descendant during ancestor restoration | Record the latest preference immediately; restoration opens only branches still expanded |
|
||||
| Reopen, return to Files, or reconnect | Obtain current listings without depending on replay of missed events |
|
||||
| Delete/recreate a file, or replace a child directory with a file | File watches recover at the same path while the parent remains; directory listings release invalid subtrees |
|
||||
| Rapid writes, duplicate notifications, changes during reads or their completion | Converge on current state with finite rereads, without endless refresh |
|
||||
| Close Tab, unload plugin, cancel initialization | Close all owned watches even when no further frame is requested; late notifications and reads no longer write state |
|
||||
| Unsupported providers or out-of-workspace directories | Report unavailable observation or access errors explicitly, without watching the wrong execution world or widening directory authority |
|
||||
|
||||
Controlled streams and deferred reads verify readiness, cancellation, and expansion during restoration. Real local-watch tests use temporary directories, wait for readiness before acting, and finish on observable state rather than fixed sleeps. The Web preview test and owner-local UI tests cover user-visible behavior; executed command results belong in the PR.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Automatic watch recovery after a parent directory is deleted and recreated on Linux is deferred. Same-path file recreation while the parent remains stays supported.
|
||||
- OS file events are not durable messages. Disconnects and process restarts may lose notifications, so every stream generation obtains current state after readiness.
|
||||
- Open files and expanded directories increase watcher and stream counts. The first version controls this through on-demand lifetimes, without an advance cross-consumer deduplication service.
|
||||
- Automatic refresh may interrupt reading, especially HTML interactions and Office conversion. View preferences and coalescing remain, but arbitrary embedded-document runtime state is not preserved.
|
||||
- HTML CSS/JS members come from the existing parser. Deeper dependencies, image dependencies, and runtime loading are not additional parsing work in this change.
|
||||
- SSH OS watching needs its own remote implementation strategy. This interface declares unsupported operation honestly, without local watchers or implicit polling masquerading as remote support.
|
||||
|
||||
## Relationship to existing Agent Notes
|
||||
|
||||
Automatic refresh and OS watching retain the Resource model, file-read authorization, and renderer ownership. The following records retain independent rationale; this note owns target-scoped observation, automatic reload, and directory-watch lifetimes.
|
||||
|
||||
- [Client Resource model](../../implemented/architecture/2026-09-05-client-resource-model.md): retain addresses, providers, subscriptions, and lifetimes; existing streams carry the new behavior.
|
||||
- [Workspace file service](../../implemented/architecture/2026-09-05-workspace-files-service.md): read and listing responsibilities remain; target-scoped OS observation replaces the Session-scoped operation-only stream.
|
||||
- [Document preview operations](../../implemented/architecture/2026-09-08-document-preview-operations.md): content-loading ownership and all three modes remain; automatic refresh uses their existing reload paths.
|
||||
- [Workspace file-read authority](../../implemented/architecture/2026-09-09-workspace-file-read-authority.md): retain distinct access scopes for file reading and directory browsing; target watches follow their respective rules.
|
||||
- [Sidebar text preview and file tree](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md): per-Tab expansion, navigation, and scrolling remain; open nodes own automatic directory invalidation and watching.
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
# Agent Note: Sidebar 文件预览与目录树按需监听并自动刷新
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-sidebar-file-and-directory-auto-refresh.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新状态。变化来源包括 Harness 文件工具、Shell 命令、用户编辑器,以及其他进程;只监听 Harness 自己的文件操作不够。
|
||||
|
||||
起始实现已有两条读取路径,但缺少完整的自动刷新链路。
|
||||
|
||||
| 对象 | 起点行为 | 缺口 |
|
||||
|---|---|---|
|
||||
| 文件 Resource | `ResourceProvider.open()` 提供初始元数据和后续变化,`ResourceRegistry` 管理订阅及持有期,组件通过 `useResource` 读取 | 文件变化源不是系统级 watch |
|
||||
| Document Preview | `TextPreview` 已比较 Resource 版本与正文版本,并提供三种加载模式的 reload | 检测到变化后仅提示用户点击重新加载 |
|
||||
| Workspace 文件事件 | `WorkspaceFiles.changes()` 转发当前 Session 的 `fs/observed`,客户端按文件过滤 | Host 不知道具体关注哪些路径;外部编辑器和 Shell 修改不会产生这类事件 |
|
||||
| Files 面板 | `filesFace` 读取目录,`createFilesStore` 保存已加载层级及展开状态 | 新增、删除、重命名后需要手动刷新;展开过的目录再次打开可能直接展示旧缓存 |
|
||||
|
||||
`Resource` 当前承载文件元数据,不持有预览正文。正文由 Document Preview 或它选择的渲染器读取。因此,“元数据更新”和“重新加载正文”应保持各自的职责,不把正文缓存搬进通用 Resource,也不要求每个渲染器直接接触文件系统监听。
|
||||
|
||||
## 决策
|
||||
|
||||
按需监听连接现有 FS、Workspace 文件流、Resource 和 Sidebar 组件。文件与目录共用系统监听能力和现有 Remote 流传输,但消费方式不同。
|
||||
|
||||
- Document Preview:监听当前文件,Resource 发布新元数据,预览自动调用已有 reload。HTML 的根文件和引用的 CSS/JS 都加入同一个 `ResourceGroup`,任一成员变化都使整个 HTML 预览失效。
|
||||
- Files 面板:以目录节点树管理读取和监听。每个已打开的目录节点只加载并监听自己的直接子项;Client 打开多少层子树,就逐层加载并订阅多少层,不提前遍历未打开的后代。
|
||||
- 折叠目录时释放该目录及其不可见后代的监听;再次展开时重新订阅并读取,不能把折叠前缓存当作最新结果。
|
||||
- 监听只传元数据或失效通知;正文仍走现有文件读取接口,目录内容仍走 `list()`。
|
||||
- 使用本地 Chokidar 系统监听,不定时轮询整个 Workspace,不默认递归扫描所有后代。
|
||||
- 保留两个面板的手动刷新入口和自动刷新开关功能,但暂时隐藏开关的 icon 按钮。开关状态及切换逻辑不变,新 Tab 默认开启;隐藏入口不关闭监控或自动刷新。
|
||||
|
||||
变化通知触发现有 reload/list;刷新失败后不保留旧预览内容。下面说明实现对象的职责、数据流及验证。
|
||||
|
||||
## 实现对象与职责
|
||||
|
||||
### FS 与 Host
|
||||
|
||||
| 类或类型 | 所在文件 | 职责 |
|
||||
|---|---|---|
|
||||
| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | 声明抽象的单目标 `watch(target, changed, signal)` 方法;文件观察自身,目录观察直接子项;就绪后返回异步关闭函数,不支持的提供方显式抛异常,不新增 FS 错误码 |
|
||||
| `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | 沿用现有目标类型,不单独增加 FS 监听事件体系,也不让上层解析 `targetKey` |
|
||||
| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | 文件监听其父目录并过滤到目标,目录监听自身;等待 Chokidar 就绪和关闭;依赖声明在 `fs-local` |
|
||||
| `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | 继承本地只读监听能力,不复制 watcher 实现;保留自身对写入、编辑的策略检查 |
|
||||
| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` 显式抛出普通异常;Host 转成 `workspace-file/watch-unsupported` 的 `RemoteError`,不跨包导入或判断 `FsError` 实体,也不把远端 `processPath()` 传给本机 Chokidar |
|
||||
| `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | 使用仅携带目标路径的 `changes(scope, path, signal)`;Host stat 决定是否执行目录包含检查,目标类型变化后也检查,普通文件沿用文件读取权限 |
|
||||
| `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 在现有 follow 中建立目标 watch,发送就绪和变化;保留原队列与操作观察入口,出流前过滤目标,不重写整套分发逻辑 |
|
||||
| `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 接收操作观察与 OS 通知,处理监听错误;关闭独立于生成器拉取进度,并等待 watcher 关闭完成 |
|
||||
| `WorkspaceFileWatchFrame` | [workspace-files/src/types.ts](../../../../packages/api/workspace-files/src/types.ts) | 仅传路径的请求沿用 `ready`/`change` 帧,不增加请求 kind 或独立目录帧格式 |
|
||||
|
||||
### Client 与 Sidebar
|
||||
|
||||
| 类、函数或类型 | 所在文件 | 职责 |
|
||||
|---|---|---|
|
||||
| `ChangeFeed`、`SessionFeed`、`Follower` | [workspace-files/src/client/change-feed.ts](../../../../packages/api/workspace-files/src/client/change-feed.ts) | 将流的键改为 Session 加目标路径,继续复用既有就绪、路径绑定、取消和重连逻辑;此版不为了命名重排类结构 |
|
||||
| `createFileResourceProvider` | [workspace-files/src/client/provider.ts](../../../../packages/api/workspace-files/src/client/provider.ts) | 将文件地址中的 Session 与路径传给目标流;首次读取及后续通知都发布完整元数据,不能只换 `version` 而继续沿用旧 `bytes` |
|
||||
| `ResourceRegistry`、`ResourceProvider`、`UseResource` | [Resource 定义](../../../../packages/client/resources/src/client/contract.ts)、[ResourceRegistry](../../../../packages/client/resources/src/client/resources.ts) | 保持现有流、持有计数和订阅模型;不新增第二套 `onChange`,不为此增加通用正文 reload API |
|
||||
| `TextPreview` | [TextPreview.tsx](../../../../packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx) | Resource 或 group 变化且开关开启时自动触发现有 reload;保留错误状态与手动刷新,独立自动刷新按钮暂时隐藏 |
|
||||
| `textFace`、`TabReads`、`createTextStore` | [预览 face](../../../../packages/client/ui-sidebar-documentpreview/src/client/face.ts)、[预览存储](../../../../packages/client/ui-sidebar-documentpreview/src/client/store.ts) | 每个 Tab 持有一个 group,补充默认开启的 `autoRefresh` 和表示待刷新的 `resourcesDirty`;复用正文读取代次与 `loadRevision` |
|
||||
| 包内 `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | 通过现有 `resources.source(address)` 观察成员;`add` 加入资源,`set` 更新完整成员列表,`close` 释放全部成员,不改变通用 Resource 服务 |
|
||||
| `DocumentBodyOwner`、`HtmlBody`、`createReadHtmlRelative` | [渲染器输入](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts)、[HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx)、[关联文件读取](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | 渲染器通过 `addResource` 声明读取到的成员,通过 `setResources` 提交本次依赖列表;HTML 在每次读取返回后按 Host 报告的路径加入 CSS/JS 资源,解析结束后释放不再引用的成员 |
|
||||
| 各文档渲染器 | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 及同包各渲染器目录 | 不接 watch;继续消费文本、字节或 renderer revision;`failed()` 结束渲染器加载但不记录成功版本。检查刷新时取消旧任务、释放旧对象及保留现有视图偏好 |
|
||||
| `filesFace`、`FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | 继续作为已有业务入口,直接持有各 Tab 根节点;绑定目录读取和变化流,转发展开、折叠、刷新及自动刷新开关,不新增 `FilesController` |
|
||||
| 包内 `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | 表示一个已打开的目录,拥有本目录变化流、读取状态和已打开子节点;负责局部刷新、展开、递归关闭,以及向子节点传递自动刷新状态;`setExpanded` 按最新展开偏好更新待恢复节点 |
|
||||
| `createFilesStore`、`FilesTabState`、`LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | 保留展开、滚动和目录显示数据;区分首次加载与已有内容上的刷新,避免每次后台重读都把子树卸载;不保存 watcher 或 AbortController |
|
||||
| `FilesBody`、`Level`、`Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | 将展开、折叠、刷新和面板卸载交给注入回调;按节点展示结果渲染,不扫描整棵树生成监听集合,展示仍只读已有框架钩子与存储 |
|
||||
| Files 注册入口 | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | 给现有 `filesFace` 增加目录变化流回调;异步副作用留在注入实现,不引入另一个功能插件的运行时导出 |
|
||||
|
||||
## 监听接口与事件内容
|
||||
|
||||
### 一条流对应一个明确目标
|
||||
|
||||
每个请求指定一个目标路径;Host 通过 `stat` 确定当前实际类型,Client 不传文件/目录 kind。
|
||||
|
||||
```text
|
||||
changes(sessionId, path, signal)
|
||||
changes(scope, path, signal)
|
||||
```
|
||||
|
||||
Host 方法仍通过 `WorkspaceFileScope` 接收解析后的 Session 上下文。Client 传 Session id,不能自行伪造 Host 的工作目录或权限。
|
||||
|
||||
当前目标是目录时,Host 就检查 Workspace 包含关系;原本缺失或为普通文件的目标变成目录后也执行检查。Workspace 外的普通文件仍可通过所选 FS 读取与监听。
|
||||
|
||||
| 通知 | 内容 | 消费方动作 |
|
||||
|---|---|---|
|
||||
| 就绪 | 当前一代目标监听已经建立 | 执行首次或重连后的 `stat()`/`list()`,接收读取期间排队的变化 |
|
||||
| 文件存在或更新 | 沿用路径和版本通知;Client 收到后重新 stat | 用完整结果更新 Resource 的路径、版本和大小;自动刷新开启时重新读取正文 |
|
||||
| 文件缺失 | 目标路径和缺失状态 | Resource 报告不可用,预览保留已加载内容并显示错误 |
|
||||
| 目录失效 | 目录目标流中的 `change` 帧 | 只重新调用这个目录的 `list()`,不按目录版本去重,也不刷新整棵树 |
|
||||
| 监听失败 | 初始化失败报告 `workspace-file/watch-unsupported`;运行期错误结束监听 | 客户端在监听接入处处理失败,普通读取与手动刷新仍可用;不伪装成文件删除 |
|
||||
|
||||
不用把文件正文、完整目录列表或每个变化的补丁塞进通知。文件名由路径表达;文件大小来自当前 stat;修改时间若只为判断变化,继续由后端版本 token 表达,不要求 UI 解析 token 或另外引入时间戳比较。
|
||||
|
||||
Resource 发布的路径、版本和大小来自同一次 stat。先沿用现有小通知,再由 Client stat,避免为了减少一次请求扩大事件协议。目录变化不应只比较目录自身 mtime:直接子文件的大小或类型变化也可能影响 `list()` 返回值,应按相关子项事件使目录列表失效。
|
||||
|
||||
### 系统监听与访问范围
|
||||
|
||||
- `FileSystem.watch()` 接受本提供方解析出的目标;本机或远端路径的含义由 FS 提供方负责。
|
||||
- 文件监听保持文件读取的访问规则。现有预览允许通过所选 FS 读取部分 Workspace 外的路径,不能把目录树的范围限制顺手强加给这些文件。
|
||||
- 目录监听保持 `list()` 的 Workspace 范围及路径检查,目标类型变化后也执行检查,不因新增 watch 扩大目录浏览权限。
|
||||
- 父目录保持存在时,文件监听覆盖原地写入、临时文件替换、删除和同一路径重建。本地提供方直接监听父目录,将条目和事件过滤到目标文件,等待父目录 watcher 就绪后才报告就绪,目标文件尚不存在时也适用。
|
||||
- 目录监听只关注该目录自身和直接子项,不递归打开尚未展开的子目录;直接子项的创建、删除、重命名、类型或可见元数据变化都使这一层失效。
|
||||
- 本地实现采用系统事件模式;若需要调整写入稳定时间、事件合并窗口等部署参数,应由拥有它们的配置声明,不散落在组件里。
|
||||
- 系统 watch 只驱动 UI 刷新,不把外部磁盘变化伪装成某个 agent 已读取文件的 `fs/observed`,不改变文件编辑的已观察版本策略。
|
||||
|
||||
## 文件预览的完整数据流
|
||||
|
||||
```text
|
||||
Files.openResource / file.link
|
||||
→ Sidebar.openTab
|
||||
→ TabDomain → Resource.pin
|
||||
→ ResourceRegistry → createFileResourceProvider.open
|
||||
→ changes(sessionId, path)
|
||||
→ WorkspaceFiles → FsTarget
|
||||
→ WorkspaceChangeFeed → FileSystem.watch
|
||||
→ LocalFileSystem → Chokidar
|
||||
→ ready → Client.stat → ResourceSnapshot
|
||||
|
||||
Editor / Shell / file tool → disk
|
||||
→ Chokidar → LocalFileSystem
|
||||
→ WorkspaceChangeFeed → FileSystem.stat
|
||||
→ Remote.change
|
||||
→ Client.stat → ResourceSnapshot
|
||||
→ ResourceGroup → TextPreview
|
||||
→ reloadPages / reloadAll / prepareRenderer
|
||||
→ DocumentContent / renderer revision
|
||||
→ renderer
|
||||
```
|
||||
|
||||
预览正文可以与元数据初始化并行读取。ResourceGroup 以各成员首次元数据为基线,只转发后续变化,不做首读版本对账。
|
||||
|
||||
| 加载模式 | 已有刷新入口 | 刷新结果 |
|
||||
|---|---|---|
|
||||
| `text-pages` | `reloadPages()` | 废弃旧正文代次,重新读取文本页;不能混合不同版本的页 |
|
||||
| `bytes-complete` | `reloadAll()` | 重新读取完整字节,PDF、HTML、图片等消费新字节 |
|
||||
| `renderer` | `prepareRenderer(..., reload = true)` | 增加 `loadRevision`,渲染器取消旧加载并重新请求;Office 失败通过 `failed()` 结束加载,后续文件变化可重试 |
|
||||
|
||||
普通渲染器不需要添加 `watch()` 或新的 reload 接口。已有 `DocumentContent` 中的文本、字节和 revision 变化已经能驱动重新加载。
|
||||
|
||||
## HTML 的 ResourceGroup
|
||||
|
||||
一个 HTML 预览的 group 包含 HTML 根文件,以及现有 HTML 打包器实际读取的 CSS/JS 文件。成员都是普通文件 Resource,不为依赖另建 watcher 或额外文件类型。group 是预览包内对象,复用 Resource 已有的订阅和持有计数。
|
||||
|
||||
```text
|
||||
index.html Resource ─┐
|
||||
theme.css Resource ──┼→ ResourceGroup → resourcesDirty → autoRefresh → HTML reload
|
||||
page.js Resource ───┘
|
||||
```
|
||||
|
||||
- `addResource(address)` 在 `readRelated` 返回 Host 解析的 `absolutePath` 后将依赖加入 group;已有成员不重复订阅,不传递或保存正文读取版本。
|
||||
- `setResources(addresses)` 提交本次解析出的依赖列表;预览拥有者始终保留根文件,其余未再出现的成员释放订阅。
|
||||
- CSS/JS 引用使用 HTML 所属 Session,由 Host 解析路径。读取失败时也保留 Host 提供的字符串 `error.details.path`;没有该路径时,Client 不自行猜测。
|
||||
- HTML 本身没有改变,但 CSS 或 JS 改变时,也重新加载整个 HTML,生成新的 iframe 内容。
|
||||
- 缺失依赖有 Host 报告的路径时仍保留为成员;解析失败时保留已经发现的依赖,后续成员变化可以再次触发加载。
|
||||
- 新成员的首次元数据仅建立基线,不使预览失效;只有后续元数据变化才通知,首次读取不另做版本对账。
|
||||
- group 变化只把 `resourcesDirty` 置为 `true`;开始刷新时清除,读取期间再次变化则重新置位。不累计次数,也不增加另一套读取代次。
|
||||
- 关闭预览 Tab 时释放整个 group。关闭自动刷新仅停止自动重读,group 继续观察并记录陈旧状态。
|
||||
|
||||
此版覆盖交互式 HTML 打包器现有支持的直接 stylesheet 链接和普通 JS 脚本,不顺手增加 CSS `@import`、ES module 图或运行时网络请求的依赖发现。新打开的静态安全预览不读取关联文件,只观察根文件。从交互式 HTML 切换到静态预览时释放依赖监听,只保留根 Resource。
|
||||
|
||||
## 自动刷新开关
|
||||
|
||||
Document Preview 和 Files 各自保留原 reload 按钮。独立自动刷新开关的 icon 入口暂时隐藏,状态、切换逻辑、文案和样式保留;新 Tab 仍默认 `autoRefresh: true`。隐藏不改变开关状态,也不停止观察。
|
||||
|
||||
| 状态或操作 | 行为 |
|
||||
|---|---|
|
||||
| 自动刷新开启 | 开关高亮,使用已有暂停图标,提示“关闭自动刷新”;变化自动刷新对应预览或目录节点 |
|
||||
| 点击关闭 | 开关显示播放图标,提示“开启自动刷新”;继续观察变化,但不自动替换当前内容 |
|
||||
| 点击重新开启 | 若暂停期间已有变化,立即补一次刷新;之后继续自动刷新 |
|
||||
| 点击原 reload | 无论开关状态如何,都主动读取一次,不改变开关状态 |
|
||||
| 自动刷新关闭时展开新目录 | 新目录仍正常首次加载并订阅;开关只控制后续变化引起的自动重读 |
|
||||
|
||||
暂停/播放使用现有图标,手动 reload 保留环形箭头,不新增图标系统。按钮通过 `aria-pressed` 表达状态,名称和提示走现有 locale 字典。
|
||||
|
||||
## Files 面板的完整数据流
|
||||
|
||||
```text
|
||||
Files.open
|
||||
→ FilesBody → Session.cwd + Tab
|
||||
→ filesFace.start → DirectoryNode.open
|
||||
→ changes(sessionId, path)
|
||||
→ Host.watch(depth: 0) → ready
|
||||
→ DirectoryNode → list(sessionId, path)
|
||||
→ createFilesStore.levels[path]
|
||||
→ Level / Entry
|
||||
|
||||
Entry.expand
|
||||
→ filesFace.toggle
|
||||
→ DirectoryNode.expand → child.open
|
||||
→ child.watch → ready → child.list
|
||||
→ createFilesStore.expanded + levels[path]
|
||||
|
||||
Process → create / remove / rename
|
||||
→ Host.change(directory)
|
||||
→ DirectoryNode.refresh → list(path)
|
||||
→ createFilesStore.levels[path]
|
||||
→ DirectoryNode.children ↔ list.entries
|
||||
→ DirectoryNode.collapse → child.close
|
||||
```
|
||||
|
||||
文件预览和目录树是两条独立的消费路径。预览文件可以在 Files 面板没有打开时自动刷新;Files 也可以在没有任何文件预览时自动更新目录项。无需把整棵目录树包装为一个 Resource,也不为此新增目录 Resource 协议。
|
||||
|
||||
目录面板直接通过注入的 Remote 业务回调取得失效通知和列表结果,显示值统一写回自己的存储;不绕过组件现有的读取方式,不让组件直接持有 Chokidar、Remote 迭代器或底层观察对象。
|
||||
|
||||
## 目录节点树与生命周期
|
||||
|
||||
Files 的运行时对象本身就是一棵按需打开的目录树,不先建立整棵 Workspace 树,也不另算一个扁平的 watcher 集合。现有 `filesFace` 持有各 Tab 根节点,读取、监听和释放逐层交给 `DirectoryNode`,不另抽控制器类。
|
||||
|
||||
| 节点职责 | 具体行为 |
|
||||
|---|---|
|
||||
| 本目录身份 | 持有该目录路径和所属 Session/Tab,文件系统根与 Windows 盘根均可作为树根,列表只包含直接子项 |
|
||||
| 本目录监听 | 一个打开的节点持有一个目标变化流;收到通知只使本节点列表失效 |
|
||||
| 本目录读取 | 持有有效 list 请求的代次,待补读状态持续到读取收尾后,结果通过存储 actions 发布 |
|
||||
| 已打开子目录 | 按直接子目录路径持有子 `DirectoryNode`;未展开目录只是列表中的目录项,不创建活动子节点 |
|
||||
| 展开 | 目录行触发展开时创建或复用对应子节点,再由子节点建立自己的监听与读取;恢复展开偏好时只打开列表中仍存在的目录 |
|
||||
| 折叠 | 关闭并移除对应活动子节点;子节点递归关闭后代,然后释放自己的监听和请求 |
|
||||
| 列表更新 | 保留仍存在的已打开子节点,关闭已删除、重命名或变为文件的分支;不重新打开所有节点 |
|
||||
| 整树关闭 | 对根节点执行关闭,自然递归释放整棵活动子树,不另遍历全局监听注册表 |
|
||||
|
||||
`createFilesStore.expanded` 保存用户的展开偏好,但不是另一份监听注册表。父目录关闭后,即使存储保留了后代展开偏好,也不能继续持有这些后代的活动节点。重新打开时先读取父目录,再对仍存在且需要恢复的子目录逐层建立节点。即使父节点尚未创建,展开和折叠也会更新存储与待恢复状态;后代恢复时遵循最新偏好,不会重新打开已折叠的分支。
|
||||
|
||||
```text
|
||||
workspace/ watch
|
||||
├── src/ open watch
|
||||
│ ├── components/ open watch
|
||||
│ └── internal/ closed -
|
||||
└── assets/ closed -
|
||||
|
||||
collapse(src/):
|
||||
workspace/ watch
|
||||
├── src/ close(src/, components/)
|
||||
└── assets/ -
|
||||
```
|
||||
|
||||
父目录的监听仍能发现折叠目录自身被删除、重命名或替换,但不负责发现其深层内容变化。再次展开时重新 list,补上未监听期间的变化。
|
||||
|
||||
| 用户操作或生命周期事件 | 文件预览 | Files 面板 |
|
||||
|---|---|---|
|
||||
| 首次打开 | 打开文件 Resource 监听并读取正文 | 打开根节点,由根节点建立监听并读取列表 |
|
||||
| 展开子目录 | 无影响 | 父节点打开对应子节点;子节点监听并读取直接子项,按偏好逐层恢复仍存在的后代 |
|
||||
| 折叠子目录 | 已打开的文件预览继续监听 | 释放该目录及其当前不可见后代的监听;可保留展开偏好和显示缓存 |
|
||||
| 切走 Tab 或面板卸载 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按待处理 Resource 变化与开关状态补刷新 | 此版沿用 Tab 生命周期,保留已展开目录节点和监听;不另外引入面板显隐生命周期 |
|
||||
| 手动刷新 | 重用当前预览 reload,不重建整套 Resource | 从根节点递归刷新已打开子节点,不进入折叠目录,也不重建全部 watcher |
|
||||
| 关闭最后一个持有者 | Resource abort,文件流结束,Host 等待 watcher 关闭 | Tab 关闭时终止全部目录流、取消读取并删除 Tab 状态 |
|
||||
| 连接恢复 | 新流 ready 后重新 stat,即使断线期间没有可回放事件也能发现变化 | 新流 ready 后重新 list 所有仍需观察的目录 |
|
||||
|
||||
Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则释放根节点与整棵活动树。暂时隐藏面板不额外拆除和重建节点,这使改动集中在现有入口和目录对象。存储保留展开偏好,节点只在重新展开时用这份偏好逐层恢复,不维护第二份全局展开集合。
|
||||
|
||||
同一个完整文件 Resource 地址的共享继续由 `ResourceRegistry` 完成。目录节点的父子所有权保证一个 Tab 内同一分支不会重复打开;第一版不增加跨 Session、跨窗口或路径别名的全局 watcher 去重系统。目录下的普通文件项不各自订阅文件 watch,只有真正打开为预览 Resource 的文件才取得独立文件监听。
|
||||
|
||||
## 用户可见动线
|
||||
|
||||
### 编辑已预览的文件
|
||||
|
||||
1. 用户从 Files 打开 Markdown、代码、图片、PDF、HTML 或 Office 文件。
|
||||
2. 用户在外部编辑器保存,或 Shell 命令改写该文件。
|
||||
3. 自动刷新默认开启,Document Preview 自动显示新内容;用户关闭开关后可保持当前内容,手动 reload 或重新开启时再读取。
|
||||
4. 当前渲染器选择和换行偏好保留;文本滚动位置按现有机制恢复,内容缩短时只能落在新内容允许的位置。
|
||||
5. PDF、HTML 和图片的精确阅读位置恢复不是本方案额外承诺;沿用各渲染器现有能力,避免为自动刷新加入一套跨格式定位系统。
|
||||
|
||||
### 浏览正在变化的目录
|
||||
|
||||
1. 用户打开 Files,根目录列表加载并开始监听。
|
||||
2. 用户展开 `src` 和 `src/components`,两个子目录分别开始监听。
|
||||
3. 外部工具在 `src/components` 创建文件,只有这一层列表刷新,其他目录不折叠、不重新加载。
|
||||
4. 用户折叠 `src`,其自身和 `components` 的监听关闭,根目录继续监听。
|
||||
5. 折叠期间外部工具继续修改内容;用户再次展开 `src` 时重新读取,依次恢复仍有效的子目录展开状态,不停留在旧缓存。
|
||||
|
||||
### 删除、重命名和失败
|
||||
|
||||
- 文件删除后,已有预览内容保留并显示不可用提示;父目录保持存在时,原路径重建后自动恢复读取。重命名视为旧路径消失和新路径出现,不根据 inode 自动改写预览地址。
|
||||
- 目录删除或重命名后,父目录刷新移除旧行,并释放旧子树监听。新目录按新名称显示,不擅自转移旧路径的展开偏好。
|
||||
- 自动刷新和手动刷新调用同一套已有 reload,沿用清空旧结果、加载及错误展示行为;不增加保留旧字节或旧 iframe 的分支。目录刷新保留现有列表缓存,避免重建活动子树。
|
||||
- 后端不支持 watch 时,Host 把初始化异常转成 `workspace-file/watch-unsupported` 的 `RemoteError`;客户端结束监听,继续普通读取与手动刷新,不增加轮询。SSH 的外部磁盘变化不会自动更新预览。
|
||||
|
||||
## 就绪、竞态与刷新合并
|
||||
|
||||
监听、读取与展示只需要处理少数明确的顺序问题,不扩展成磁盘事务或操作日志。
|
||||
|
||||
- 先建立 watch,再发送 ready,然后取得初始 stat/list。读取过程中到达的变化不能丢弃;必要时再读一次,使最终结果收敛到最新状态。
|
||||
- 事件表示“目标可能变化”,不是一次用户操作的精确回放。Chokidar 的重复通知和连续写入可以合并;文件按版本去重,目录按待刷新状态合并。
|
||||
- 同一目录最多有一次有效 list 在途;在途或读取收尾期间再次失效,只记录需要补读,随后读取最新状态,不累计任意长度的刷新任务队列。
|
||||
- 文件自动刷新复用读取代次和 renderer revision。连续通知不能让旧请求覆盖新内容,也不能因同一已处理版本而无限 reload。
|
||||
- 目录后台刷新保留当前列表,不另存没有显示用途的 `refreshing` 标记;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。
|
||||
- 目录折叠、删除或 Tab 关闭后,已取消读取的晚到结果不得重新创建状态或恢复监听。即使生成器停在已产出的帧上,取消也会关闭 Host watcher;流与插件拆除等待关闭完成。
|
||||
- 父目录保持存在时,文件短暂消失不结束监听;正常文件变化与监听自身失败分别处理。同路径文件监听覆盖原子保存与删除重建。
|
||||
- 原有文件读取并非事务快照,stat 与正文读取之间仍可能发生写入;后续通知触发刷新,自动刷新不保证每次读取都获得原子快照。
|
||||
|
||||
## 不采用的替代方案
|
||||
|
||||
**在 Resource 上再加一套 `onChange` 和正文 reload。** 现有 `open()`、`source()`、`useResource` 已经表达变化,正文又不归通用 Resource 所有。重复接口会使元数据、正文和渲染器分别维护刷新状态。
|
||||
|
||||
**整个 Workspace 递归 watch。** 打开一个文件或一个根目录不应导致监听所有深层目录。按当前文件和展开目录取得监听,资源使用量与用户实际打开的内容相关。
|
||||
|
||||
**保留 Session 广播流,再增加路径注册/退订协议。** Host 需要知道具体目标,但不必再维护一个由额外命令修改的路径集合。一目标一流可以直接复用现有取消、重连和结束语义。
|
||||
|
||||
**直接下发正文或完整目录列表。** 这会混合监听与内容读取,还要重复分页、字节限制、文件格式和目录条目限制。小通知配合现有 `read()`/`list()` 更直接。
|
||||
|
||||
**根据变化事件在客户端增删目录行。** 文件系统通知可能合并或重复,目录列表还有排序、类型和条目上限。重新 list 受影响的一层即可保持现有语义,不需要维护目录增量合并算法。
|
||||
|
||||
**在 Workspace API 中直接调用 Chokidar。** API 不拥有执行世界,SSH 路径可能只在远端存在。watch 必须由实际 FS 提供方实现。
|
||||
|
||||
## 验证
|
||||
|
||||
FS、Workspace API、预览和 Files 各自的测试覆盖监听生命周期、元数据更新和自动刷新。[Web 文档预览测试](../../../../apps/web/tests/document-preview.e2e.ts) 从正式 Web 组合验证文件预览。会话事件日志、持久化格式和模型输入均不变。
|
||||
|
||||
| 场景 | 已验证行为 |
|
||||
|---|---|
|
||||
| 外部原地写入、原子替换、Shell 写入 | 打开的文件预览自动更新,不依赖 `fs/observed` |
|
||||
| 文本、完整字节、自主加载渲染器 | 三种模式都刷新,旧请求不能覆盖新结果,Office 失败后允许后续文件变化重试 |
|
||||
| 成员首次元数据 | 仅建立基线,不额外 reload,也不做首读版本对账 |
|
||||
| HTML 根文件不变,仅 CSS/JS 改变 | group 触发整个 HTML 重新加载 |
|
||||
| HTML 删除一条依赖引用 | 本次 group 更新后释放不再引用的成员 |
|
||||
| 关闭自动刷新后修改,再手动刷新或重新开启 | 暂停期间保持内容,手动操作或重新开启后补刷新;按钮职责独立 |
|
||||
| 根目录直接子项新增、删除、重命名 | Files 根层自动更新,工作区为文件系统根或 Windows 盘根时也适用 |
|
||||
| 已展开子目录变化 | 只刷新对应目录层,保留其他层与展开偏好 |
|
||||
| 未展开的深层目录 | 不创建深层 watcher,不递归遍历 |
|
||||
| 折叠父目录 | 释放它和不可见后代的监听;文件预览若仍打开则独立继续监听 |
|
||||
| 祖先恢复期间折叠或重新展开深层后代 | 立即记录最新偏好,恢复时只打开仍需展开的分支 |
|
||||
| 重新展开、切回 Files、连接恢复 | 重新读取当前列表,不依赖错过事件的回放 |
|
||||
| 文件删除后重建,或子目录替换为文件 | 父目录保持存在时,文件监听在同一路径恢复;目录列表释放失效子树 |
|
||||
| 快速写入、重复通知、读取中或收尾时再修改 | 有限次补读并收敛到最新状态,不无限刷新 |
|
||||
| 关闭 Tab、卸载插件、取消初始化 | 即使不再拉取下一帧,所属监听也完全关闭;晚到通知和读取不再写入状态 |
|
||||
| 不支持监听的提供方、越界目录请求 | 明确报告支持状态或访问错误,不观察错误执行世界或扩大目录权限 |
|
||||
|
||||
可控事件源和延迟读取验证就绪、取消及恢复期间的展开操作。真实本地 watch 测试使用临时目录,等待 watcher 就绪后再操作,通过观察到的状态结束,不靠固定 sleep 猜测事件时机。Web 预览测试和包内 UI 测试覆盖用户可见行为;命令执行结果记录在 PR 中。
|
||||
|
||||
## 影响
|
||||
|
||||
- Linux 上父目录删除并重建后的自动监听恢复仍延期;父目录保持存在时,同路径文件重建仍受支持。
|
||||
- OS 文件事件不是持久消息,断线或进程重启可能丢通知,因此每一代流就绪后都要重新取得当前状态。
|
||||
- 多个已打开文件和展开目录会增加 watcher 与流数量;第一版用按需生命周期控制数量,不提前加入复杂的跨消费者去重服务。
|
||||
- 自动刷新可能打断阅读,尤其是 HTML 内部交互和 Office 转换。保留视图偏好、合并连续变化,但不承诺保存任意嵌入文档的运行状态。
|
||||
- HTML 的 CSS/JS 通过现有解析器进入 group;更深依赖、图片依赖和运行时动态加载不作为这次修改的额外解析工程。
|
||||
- SSH 远端系统监听需要独立的远端实现策略;这里要求接口如实表达不支持,不使用本机 watcher 或隐式轮询冒充远端能力。
|
||||
|
||||
## 与已有 Agent Note 的关系
|
||||
|
||||
自动刷新和系统监听保留已有 Resource 模型、文件读取授权和渲染器职责。以下记录仍有独立的设计理由;本文负责目标级监听、自动 reload 和目录监听生命周期。
|
||||
|
||||
- [Client Resource 模型](../../implemented/architecture/2026-09-05-client-resource-model.zh.md):保留地址、提供方、订阅和持有期;新增行为由已有流承载。
|
||||
- [Workspace 文件服务](../../implemented/architecture/2026-09-05-workspace-files-service.zh.md):读取与列表接口职责不变;目标级系统监听替代仅转发操作观察的 Session 流。
|
||||
- [文档预览操作](../../implemented/architecture/2026-09-08-document-preview-operations.zh.md):正文加载所有权和三种加载方式不变;自动刷新使用其现有 reload 路径。
|
||||
- [Workspace 文件读取授权](../../implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md):保留文件读取与目录浏览的不同访问范围;目标 watch 分别遵循对应规则。
|
||||
- [Sidebar 文本预览与文件树](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md):按 Tab 的展开、导航和滚动状态不变;已打开节点负责目录自动失效与监听。
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/feature/2026-09-12-session-unarchive-settings-page.md
|
||||
2026-09-12-session-unarchive-settings-page.md: 3b1e364e3abc6cea0a8b29ad78abb0ff7c274dbe
|
||||
2026-09-12-session-unarchive-settings-page.zh.md: 61e108dd50e61b9e6e9f72098973c067e3257407
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md
|
||||
2026-09-18-session-pin-and-sidebar-archive.md: eb0c82ba84b1ebc94b23bff4d613ec6de9565d71
|
||||
2026-09-18-session-pin-and-sidebar-archive.zh.md: d971c7b4ff2b6de6a0f49c41974b7654843b29ea
|
||||
@@ -0,0 +1,79 @@
|
||||
# Agent Note: Session pinning and the sidebar archived filter
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-18-session-pin-and-sidebar-archive.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The sidebar session list had no way to keep important sessions at the top, and archived sessions could only be reached through a settings page (`ui-settings-unarchive-sessions`) far from the list they came from. Users archived a session and then lost sight of it.
|
||||
|
||||
A visibility filter can hide a Session without removing it from its Workspace. Saving filtered rows as the account's complete order loses hidden positions. Independent pin ordering also makes dragging depend on a second sequence instead of the Session order the user edits.
|
||||
|
||||
## Decision
|
||||
|
||||
The `dsh-workspace` registry stores registry-global pin and archive sets as Session id arrays (`pinnedSessionIds` and `archivedSessionIds`), with durable `pinSession`/`unpinSession` operations. The pin array keeps the most recently pinned id first. Pinning and archival are mutually exclusive: archiving drops the session's pin in the same durable write, and pinning an archived session fails with `WorkspaceArchivedSessionPinError`.
|
||||
|
||||
### Complete Session order
|
||||
|
||||
Each browser-local account has one complete Session sequence, including pinned and archived members. Workspace membership bounds each account; registry-global pin and archive sets never add a Session to an unrelated Workspace. Grouped, Ungrouped, and flat views use the same rules.
|
||||
|
||||
- Existing members retain their positions. Missing pinned members prepend in the pin array's order. Other missing members append by descending Session `updatedAt`, with missing archived members last. Supplementation never interleaves existing positions or duplicates an id.
|
||||
- Supplementation is an in-memory projection, not an automatic storage migration. A Session-order write records the complete supplemented sequence, including hidden archived rows. Filtering alone never rewrites Session positions.
|
||||
- A successful Pin action marks the Session pinned and moves its position to the front of its complete account sequence. Completion uses current saved positions and the latest Workspace membership, preserving reorders completed while the request was pending. Unpin removes the mark without restoring an earlier position. Both pinned and ordinary drags edit the same complete sequence; display indices never replace its hidden members.
|
||||
- The current blank New Session precedes both partitions; other pinned Sessions lead unpinned Sessions. Manual preserves the complete sequence's relative order within both partitions. Last updated sorts both partitions strictly by descending `updatedAt`, with Session id as the tie-break. Pin records carry only Session identities, not timestamps.
|
||||
- Archive takes precedence if both sets contain a Session. Archiving clears the Host pin but retains the Session's account position. The archive filter changes visibility and disabled-row presentation, not membership or ordering.
|
||||
|
||||
The existing [mode-switch and provisional-New-Session rules](../simplification/2026-09-10-derived-workspace-recency.md) remain: a drag selects Manual, Last updated discards the prior manual layout, entering Manual captures the current chronological order, and the selected blank Session retains its provisional first slot. An explicit Pin updates saved positions without changing the selected mode; chronological display still ignores those positions.
|
||||
|
||||
### Archive restoration
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession` removes one id from the global archive set on the same serialized operation chain as archive and other registry writes. An absent id is an idempotent no-op with no durable write or notification. Restoration preserves Workspace membership and Session logs, and it neither restores a pin nor changes saved ordering.
|
||||
|
||||
Unarchive performs no Session-existence probe: removing an id cannot introduce an unknown referent, so even an entry whose Session is gone can be removed without listing persistence. The Remote returns the complete archive set, and the existing `archived` increment carries the same state to other Clients. A Client installs a unary reply only while it remains the latest archive-set request; a newer request, follow increment, or replacement baseline supersedes it. Failed writes leave the set unchanged, and the sidebar logs the rejection while retaining the row.
|
||||
|
||||
### Archived visibility and row motion
|
||||
|
||||
The sidebar derives its visible rows in `ui-workspace`:
|
||||
|
||||
- Archived rows keep their accounting slots, render grayed, and are not openable. The view-options menu owns an `ArchivedFilter` (`default` hide / `show` / `only`) applied to lists and search alike; the settings-page archived list is deleted with its package.
|
||||
- Archiving raises a toast with undo and filter-archived actions. Parent rerenders do not extend its lifetime; the fully faded banner cannot retain clickable actions.
|
||||
- Row motion uses a private React component with native position and opacity animations. It captures row positions around structural commits without observers or polling; removed rows leave inert fading copies outside the scrolling list. Initial loading, drag commits, overflow expansion/collapse, and grouping/ordering/filter switches settle immediately.
|
||||
|
||||
### Persistence
|
||||
|
||||
Workspace KV, Remote values, and Client snapshots store archive and pin membership as Session id arrays, without object wrappers or pin timestamps. Missing collections default to empty; object-form pin entries are not accepted. Pinning and restoration do not change the Workspace domain version or Session-log format, and neither operation edits Session logs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the settings-page archived list.** Two homes for one state; the sidebar filter shows archived rows in the browsing context they came from, so the package is removed outright.
|
||||
|
||||
**A restore action without an archived-row filter.** Hidden rows cannot expose actions. The filter makes those rows reachable, so restoration can live with the Session list instead of a separate settings page.
|
||||
|
||||
**A separate restore delta or an existence check on unarchive.** A second frame type duplicates the complete-set response and requires another merge path. An existence probe would turn removal of an orphan archive id into an error even though removal cannot introduce an unresolved id. Restoration therefore shares the archive-set protocol and omits the probe.
|
||||
|
||||
**Render archive ids whose Session summaries are missing.** Such entries could explain orphan state, but they cannot provide the normal conversation row. The sidebar displays loaded summaries; direct restoration remains available through the API, and orphan management remains outside this browser.
|
||||
|
||||
**A separately editable pin sequence.** This duplicates ordering authority and requires another drag write path. The global pin array only seeds missing pinned members; established positions belong to the complete Session sequence.
|
||||
|
||||
**Restore pre-pin or pre-archive positions.** Keeping Pin separate from manual positions would restore pre-pin slots, but Pin is an intentional reorder. A separate restoration record adds a product guarantee that this interaction does not require; hiding an archived row already preserves its existing position.
|
||||
|
||||
**Rank pins by their pin timestamp.** This makes Last updated mean something other than Session activity. A newly pinned old Session need not lead the pinned partition in that mode; Manual shows the Pin action's first position.
|
||||
|
||||
**Persist every derived or filtered list.** Rendering and visibility changes are not ordering commands. An incomplete or filtered projection cannot replace the complete saved sequence.
|
||||
|
||||
**Animate every list pass.** Revealing hidden rows, committing a drag, or switching the filter moves rows wholesale and reads as exaggerated motion; those passes apply instantly while pin jumps, archive fades, and neighboring-row movement retain their animations.
|
||||
|
||||
## Consequences
|
||||
|
||||
The sidebar owns archived visibility end to end and settings loses a package. The pin set is registry-global, so pins survive grouping and ordering switches. Row animation owns no persistent state or dependency.
|
||||
|
||||
Filtering and reordering share one complete membership sequence, so hidden archives survive drags and filter switches. Older saved orders recover omitted members in memory and adopt the supplemented sequence on their next Session-order write. Host pin/archive membership semantics remain unchanged. The browser gives up pin-time promotion in Last updated and automatic restoration of pre-pin positions.
|
||||
|
||||
An archived id without a loaded Session summary has no visible row or sidebar restore action, even though the API can remove it. This UI limit does not change the registry's idempotent restoration behavior.
|
||||
|
||||
Unit suites cover supplementation, complete-sequence dragging, Pin completion after intervening changes, New Session priority, archived navigation guards, filters, toast lifetime, and animation lifecycle. Workspace and Controller tests cover pin/archive exclusivity, identity-only persistence and snapshots, idempotent no-op restoration, missing-Session restoration without persistence probes, restart recovery, and stale unary replies superseded by newer requests or pushed state. The keyless sidebar restore scenario verifies the row action and reload durability.
|
||||
|
||||
## Related
|
||||
|
||||
- [Session archive (registry-global set)](../../archived/feature/2026-07-31-session-archive-global-set.md) — the frozen record of the archive set and its follow increment.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Agent Note: Session pinning and the sidebar archived filter
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-18-session-pin-and-sidebar-archive.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
侧边栏会话列表无法把重要会话固定在顶部,已归档会话只能通过远离列表的设置页(`ui-settings-unarchive-sessions`)访问。用户归档一个会话之后就再也看不到它了。
|
||||
|
||||
显隐筛选可以隐藏会话,但不改变它的 Workspace 归属。把筛选后的行保存成所属列表的完整顺序,会丢失隐藏项的位置。单独维护可编辑的置顶顺序,也会让拖拽依赖另一份序列,而不是用户正在编辑的会话顺序。
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-workspace` registry 将全局置顶与归档集合存为会话 id 数组(`pinnedSessionIds` 和 `archivedSessionIds`),并提供持久化的 `pinSession`/`unpinSession` 操作。置顶数组把最近置顶的 id 放在前面。置顶与归档互斥:归档会在同一次持久化写入中移除该会话的置顶,置顶已归档会话则以 `WorkspaceArchivedSessionPinError` 失败。
|
||||
|
||||
### 完整会话顺序
|
||||
|
||||
每份浏览器本地排序记录包含一条完整会话序列,包括置顶和归档成员。每份序列受 Workspace 成员关系约束,全局置顶与归档集合不会把会话补进无关的 Workspace。分组、Ungrouped 和平铺视图使用同一套规则。
|
||||
|
||||
- 已有成员保留位置。缺失的置顶成员按置顶数组中的顺序补到头部。其他缺失成员按会话 `updatedAt` 降序追加,缺失的归档成员放在最后。补齐不穿插已有位置,也不重复加入同一个 id。
|
||||
- 补齐是内存派生,不自动迁移存储。一次会话顺序写入保存补齐后的完整序列,包括隐藏的归档行。单纯切换筛选不重写会话位置。
|
||||
- 置顶成功后,会话被标记为置顶,同时移到所属完整序列的首位。完成时使用当前保存的位置与最新 Workspace 成员关系,保留请求等待期间完成的重排序。取消置顶只移除标记,不恢复更早的位置。置顶行与普通行的拖拽都修改同一条完整序列,显示下标不会覆盖其中的隐藏成员。
|
||||
- 当前空白新会话排在两个分区之前,其余会话再按置顶区、非置顶区排列。手动模式下,两区分别保持完整序列中的相对顺序。最近更新模式下,两区都严格按 `updatedAt` 降序排序,以会话 id 打破平局。置顶记录只包含会话身份,不包含时间戳。
|
||||
- 会话同时命中两个集合时,归档优先。归档会清除 Host 的置顶标记,但保留会话在所属序列中的位置。归档筛选只改变可见性和禁用行的展示,不改变成员关系或排序。
|
||||
|
||||
既有的[模式切换与临时新会话规则](../simplification/2026-09-10-derived-workspace-recency.zh.md)保持不变:拖拽选择手动模式,最近更新丢弃先前的手动布局,进入手动模式记录当前时间顺序,当前选中的空白会话保留临时首位。显式置顶更新保存的位置,但不切换所选模式;时间视图仍忽略这些位置。
|
||||
|
||||
### 取消归档
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession` 与归档及其他注册表写入共用串行操作链,从全局归档集合移除一个 id。id 不在集合中时是幂等空操作,不产生持久写入或通知。恢复保留 Workspace 成员关系与 Session 日志,不恢复置顶,也不改变保存的顺序。
|
||||
|
||||
取消归档不探测 Session 是否存在:移除 id 不会引入未知引用,因此即使 Session 已不存在,也能在不列举持久化记录的情况下移除归档项。Remote 返回完整归档集合,既有 `archived` 增量把同一状态传给其他 Client。Client 只在一元响应仍属于最新归档集合请求时安装它;更新的请求、follow 增量或替换基线会覆盖旧响应的资格。写入失败不改变集合,侧边栏记录拒绝原因并保留原行。
|
||||
|
||||
### 归档显隐与行动画
|
||||
|
||||
侧边栏在 `ui-workspace` 中派生可见行:
|
||||
|
||||
- 已归档行保留其记账位置,置灰渲染且不可打开。视图选项菜单持有 `ArchivedFilter`(`default` 隐藏 / `show` / `only`),同时作用于列表与搜索;设置页的归档列表连同其包一并删除。
|
||||
- 归档动作弹出携带撤销与筛选已归档操作的 toast。父组件重渲染不会延长其生命周期,完全淡出后不会保留可点击操作。
|
||||
- 行动画由私有 React 组件使用浏览器原生位移与透明度动画实现。组件在结构性提交前后记录行位置,不使用观察器或轮询;被移除的行在滚动列表外留下不可交互的淡出副本。初始加载、拖拽提交、展开/收起其余会话,以及分组/排序/筛选切换直接完成。
|
||||
|
||||
### 持久化
|
||||
|
||||
Workspace KV、Remote 值与 Client 快照都用会话 id 数组存储归档和置顶成员,不包含对象包装或置顶时间戳。缺失集合默认为空,不接受对象形式的置顶条目。置顶与恢复不改变 Workspace 领域版本或 Session 日志格式,也不编辑 Session 日志。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留设置页的归档列表。** 同一状态出现两个入口;侧边栏筛选让已归档行显示在它们原本所在的浏览上下文中,因此该包被整体移除。
|
||||
|
||||
**只提供恢复操作,不提供已归档筛选。** 隐藏行无法提供操作。筛选让这些行可以被访问,因此恢复可以与 Session 列表放在一起,而不需要独立设置页。
|
||||
|
||||
**独立恢复增量,或在取消归档时检查存在性。** 新增帧类型会重复完整集合响应,并增加合并路径。存在性探测会把移除孤立归档 id 变成错误,尽管移除不可能引入未解析 id。因此恢复复用归档集合协议,不执行该探测。
|
||||
|
||||
**显示缺少 Session 摘要的归档 id。** 这些条目可以解释孤立状态,但不能提供普通对话行。侧边栏显示已加载摘要,API 仍支持直接恢复;孤立项管理不属于当前浏览器。
|
||||
|
||||
**单独维护可编辑的置顶序列。** 这会产生两个排序来源,也需要另一条拖拽写入路径。全局置顶数组只为缺失置顶成员提供初始位置,已有位置由完整会话序列管理。
|
||||
|
||||
**恢复置顶或归档之前的位置。** 让置顶独立于手动位置可以恢复置顶前的位置,但置顶本身就是一次有意的重排序。另存恢复记录增加了这套交互不需要的产品保证;隐藏归档行本身已经保留其已有位置。
|
||||
|
||||
**用置顶时间给置顶行排名。** 这会让最近更新不再严格表达会话活动。在该模式下,新置顶的旧会话不必排在置顶区首位;手动模式才显示置顶操作调整后的首位。
|
||||
|
||||
**保存每次派生或筛选后的列表。** 渲染和显隐变化不是排序命令。不完整或经过筛选的投影不能覆盖完整的已保存序列。
|
||||
|
||||
**为每次列表变化播放动画。** 显示隐藏行、提交拖拽或切换筛选会整体移动行,观感夸张;这些变化即时应用,而置顶跳动、归档淡出和相邻行补位仍保留动画。
|
||||
|
||||
## Consequences
|
||||
|
||||
侧边栏端到端拥有已归档可见性,设置页少了一个包。置顶集合是 registry 全局的,因此置顶在分组与排序切换后仍然保留。行动画不持有持久状态,也不引入依赖。
|
||||
|
||||
筛选与重排序共用一条完整成员序列,隐藏的归档项不会因拖拽或切换筛选丢失。旧排序遗漏的成员在内存中补齐,并在下一次会话顺序写入时随完整序列保存。Host 的置顶与归档成员语义保持不变。浏览器不再用置顶时间提升最近更新模式下的位置,也不自动恢复置顶前的位置。
|
||||
|
||||
归档 id 没有已加载 Session 摘要时,不会产生可见行或侧边栏恢复操作,尽管 API 仍能移除它。这个 UI 限制不改变注册表的幂等恢复行为。
|
||||
|
||||
单元测试覆盖补齐、完整序列拖拽、期间发生其他变更后的 Pin 完成、新会话优先级、归档导航保护、筛选、toast 生命周期与行动画生命周期。Workspace 和 Controller 测试覆盖置顶与归档互斥、只含身份的持久化及快照、幂等空操作恢复、缺失 Session 时不探测持久化的恢复、重启恢复,以及被新请求或推送状态取代的旧一元响应。无密钥的侧边栏恢复场景验证行操作与重载后的持久性。
|
||||
|
||||
## 相关
|
||||
|
||||
- [Session 归档(注册表全局集合)](../../archived/feature/2026-07-31-session-archive-global-set.md) — 归档集合及其 follow 增量的冻结记录。
|
||||
+2
-2
@@ -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/simplification/2026-09-10-derived-workspace-recency.md
|
||||
2026-09-10-derived-workspace-recency.md: e8f304110c89bf563164dd24125beeb36e7644a6
|
||||
2026-09-10-derived-workspace-recency.zh.md: 60f2da36c277e77d367f152aa8a3159ff68d909c
|
||||
2026-09-10-derived-workspace-recency.md: b967e332f234d6d120bb82c444e4b05818b85103
|
||||
2026-09-10-derived-workspace-recency.zh.md: 87c2bb9f2648baa613ded32cda5b8673a77c5f56
|
||||
@@ -12,7 +12,7 @@ An editable activity-promoted list can disagree with its displayed timestamps wi
|
||||
|
||||
Last updated is a pure projection of current Session summaries: ordinary rows sort by descending `updatedAt`, with Session id as the tie-break. The selected blank New Session precedes ordinary rows until its first prompt. Grouped, Ungrouped, and flat views use the same policy. Reloads and delayed summaries require no observed-timestamp history or promotion events in the view store.
|
||||
|
||||
Only Manual uses browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. Reconciliation retains saved members that remain in the Workspace account, removes departed members, and appends newly known members at the end, newest first when several arrive together. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation.
|
||||
Manual reads browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. The [Session pinning and archive rules](../feature/2026-09-18-session-pin-and-sidebar-archive.md) own missing-member supplementation and keep archive visibility out of saved membership. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation.
|
||||
|
||||
The selected blank New Session is a display invariant applied after either base order: it remains first and cannot start a drag. Manual persists the observed blank position even while the Workspace stream reconnects, preserving every other saved member until the complete baseline permits reconciliation. Waiting to record that position would lose it if the first prompt arrives before the baseline. Its first prompt removes the blank state. Manual then keeps the existing first slot and allows dragging; Last updated projects it from the prompt timestamp. The ordering subscription and reconciliation remain mounted when the sidebar is collapsed or search replaces the list body.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
最近更新是当前 Session 摘要的纯投影:普通行按 `updatedAt` 降序排列,时间相同时按 Session id 排序。当前选中的空白新会话在首条提示词落地前排在普通行之前。分组、Ungrouped 和单列表视图采用相同策略。重新加载和摘要迟到不需要视图存储中的已观察时间历史或提升事件。
|
||||
|
||||
只有手动排序使用浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。对账会保留仍属于 Workspace 记账的已保存成员、移除已经离开的成员,并把新发现的成员追加到末尾;多条同时到达时按最近更新时间降序排列。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。
|
||||
手动排序读取浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。[会话置顶与归档规则](../feature/2026-09-18-session-pin-and-sidebar-archive.zh.md)说明缺失成员补齐,并让归档显隐不影响保存的成员关系。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。
|
||||
|
||||
当前选中的空白新会话是在两种基础顺序之后应用的显示不变式:它保持首位且无法发起拖拽。即使 Workspace 流正在重连,手动排序也会持久化已观察到的空白行位置,并保留其他所有已保存成员,直到完整基线允许对账。若等待基线后才记录该位置,首条提示词先到达时就会丢失它。首条提示词会移除空白状态;此后,手动排序保留原有首位并允许拖拽,最近更新则按提示词时间戳派生其位置。侧边栏折叠或搜索替代列表主体时,排序订阅和对账仍保持挂载。
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/simplification/2026-09-19-remove-desktop-profile-core-cleanup.md
|
||||
2026-09-19-remove-desktop-profile-core-cleanup.md: a42100e8dc247611e29ddd2b8075b3b98e388177
|
||||
2026-09-19-remove-desktop-profile-core-cleanup.zh.md: 9a6be335a427e3f3083362ffd9e3aaa2f236635d
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Agent Note: Remove the Desktop production profile core-package cleanup
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-remove-desktop-profile-core-cleanup.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Since 2026-09-15, production Desktop cleaned the profile before starting the Host, using the package names listed by the runtime descriptor: it deleted same-named entries under `$DSH_HOME/profiles/desktop/node_modules`, pruned the manifest's dependency declarations and pnpm overrides for those names, discarded the lockfile when package state changed, and removed development-time links once according to `desktop-runtime-state.json`. It targeted two kinds of residue: earlier Desktop builds had installed the core packages into the profile as local tarballs through pnpm, writing declarations, overrides, and a lockfile; and the development mode of the link backend had projected the installation closure into the same profile's `node_modules`. Under nearest-wins resolution those copies shadowed the runtime bundled with the application, combining an old Web frontend with new plugins.
|
||||
|
||||
Both kinds of residue exist only on internal development and test machines; Desktop has not been released. Since the runtime ships inside the application, new profiles no longer contain core packages, and after the [link backend removal](../architecture/2026-09-19-profile-resolution-lookup-order.md) development mode writes no links into the profile. The cleanup had to rerun on every production launch and fought the next pnpm operation, which reinstalled packages from the retained declarations.
|
||||
|
||||
## Decision
|
||||
|
||||
Delete `apps/desktop/src/profile-core-cleanup.ts`, `apps/desktop/src/profile-packages.ts`, and their two specs. `DesktopProjectManager.applyRelease` validates the runtime descriptor, migrates profile settings, creates the profile files, and removes the projections a link-backend launch wrote through the shared `removeLinkProjections`; beyond that, neither production nor development launches modify the profile's `node_modules`, manifest, overrides, lockfile, or `desktop-runtime-state.json`.
|
||||
|
||||
Resolution inside the profile follows the [lookup-order Note](../architecture/2026-09-19-profile-resolution-lookup-order.md): packages in the profile's own `node_modules` win as the nearest layer, and installation package names are occupied by the generation at `$DSH_HOME/profiles/node_modules`. When a package installed into the profile declares `@deepseek-ai/*` packages under `dependencies`, pnpm installs copies into the profile and those copies run at their own versions. Official packages keep only pure-function packages under `dependencies` and declare every package with module-level identity as a peer; a third-party plugin that declares an identity-bearing dsh package as a real dependency makes that packaging choice for itself.
|
||||
|
||||
Capability given up: core-package copies and their declarations that earlier Desktop builds installed into a profile need one manual removal; `desktop-runtime-state.json` is no longer read or deleted. Projections the link backend wrote are removed by the shared profile load, see the [lookup-order Note](../architecture/2026-09-19-profile-resolution-lookup-order.md).
|
||||
|
||||
Reintroduction conditions: a released Desktop wrote core-package copies into external users' profiles, or official packages change their dependency conventions so that profiles gain copies competing with the runtime for identity.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the original cleanup.** The residue it served is no longer produced, yet it deleted directories, pruned declarations, and discarded the lockfile on every production launch, fighting the following pnpm operations.
|
||||
|
||||
**Delete only same-named directories under the profile's `node_modules`, leaving declarations and the lockfile alone.** The 2026-09-15 decision already rejected this: pnpm reinstalls the same old packages from the retained declarations and overrides.
|
||||
|
||||
**Give `@deepseek-ai/*` generation entries absolute precedence over same-named copies inside the profile.** That overrides versions plugins bring along and is a new resolution-rule decision outside the scope of removing the cleanup.
|
||||
|
||||
**Clean only in the installer.** The 2026-09-15 decision already rejected this: it misses other profiles and cannot reach copies recreated after installation.
|
||||
|
||||
## Verification
|
||||
|
||||
- [project-manager.spec.ts](../../../../apps/desktop/tests/project-manager.spec.ts) asserts that after one launch preparation the installed core package directory, the third-party plugin directory, the manifest declarations, `desktop-runtime-state.json`, and the lockfile are byte-for-byte unchanged.
|
||||
- The repository has no remaining references to `cleanProfileCorePackages`, `migrateDesktopProfileLinks`, `CLEAN_PROFILE_CORE_PACKAGES`, or `DESKTOP_PROFILE_STATE`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought: production launches no longer write into the profile, there is no temporary enable switch, and Desktop and the CLI apply one rule to copies inside a profile. Paid: core-package copies earlier Desktop builds installed into a profile are removed by hand; dsh copies that third-party plugins bring into the profile as real dependencies run at their own versions.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Agent Note: 移除 Desktop 生产启动前的核心包清理
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-remove-desktop-profile-core-cleanup.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
2026-09-15 起,生产版 Desktop 在启动 Host 前按运行时描述符列出的包名清理 profile:删除 `$DSH_HOME/profiles/desktop/node_modules` 里的同名条目,剪掉 manifest 对这些名字的依赖声明与 pnpm overrides,包状态变化时丢弃锁文件,并按 `desktop-runtime-state.json` 记录一次性移除开发期链接。它针对两类残留:更早的 Desktop 曾把核心包做成本地 tarball 用 pnpm 装进 profile 并写入声明、overrides 与锁文件;Link 后端的开发模式曾把安装闭包投影进同一个 profile 的 `node_modules`。在最近者优先的解析下,这些副本会压过应用自带的运行时,把旧 Web 前端与新插件组合在一起。
|
||||
|
||||
这两类残留只存在于内部开发与测试机器上,Desktop 没有对外发布过。运行时随应用打包后,新 profile 不再包含核心包;[Link 后端删除](../architecture/2026-09-19-profile-resolution-lookup-order.zh.md)后,开发模式不再向 profile 写任何链接。清理每次生产启动都要重跑,并与下一次 pnpm 操作按保留的依赖重新安装形成拉锯。
|
||||
|
||||
## Decision
|
||||
|
||||
删除 `apps/desktop/src/profile-core-cleanup.ts`、`apps/desktop/src/profile-packages.ts` 及其两个 spec。`DesktopProjectManager.applyRelease` 校验运行时描述符、迁移 profile 设置、创建 profile 文件,并通过共享的 `removeLinkProjections` 删除 Link 后端启动写下的投影;除此之外,生产与开发启动都不改动 profile 的 `node_modules`、manifest、overrides、锁文件与 `desktop-runtime-state.json`。
|
||||
|
||||
profile 内的解析按[查找顺序 Note](../architecture/2026-09-19-profile-resolution-lookup-order.zh.md):profile 自己 `node_modules` 里的包最近者优先,本体包名在 `$DSH_HOME/profiles/node_modules` 的位置由 generation 占据。装进 profile 的包若在 `dependencies` 里声明了 `@deepseek-ai/*`,pnpm 会把副本装进 profile,这些副本按其自身版本运行。官方包只把纯函数包放在 `dependencies`,带模块级身份的包一律声明为 peer;第三方插件把身份敏感的 dsh 包写成实依赖,是该插件自身的打包选择。
|
||||
|
||||
放弃的能力:旧版 Desktop 装进 profile 的核心包副本及其声明需要手工删除一次;`desktop-runtime-state.json` 不再被读取或删除。Link 后端写下的投影由共享的 profile 加载一次性删除,见[查找顺序 Note](../architecture/2026-09-19-profile-resolution-lookup-order.zh.md)。
|
||||
|
||||
重新引入的条件:外部用户的 profile 曾被某个发布版 Desktop 写入核心包副本,或官方包改变依赖声明约定,使 profile 内出现与运行时争抢身份的副本。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留原清理。** 它服务的残留不再产生,却在每次生产启动删除目录、剪声明并丢弃锁文件,与随后的 pnpm 操作反复拉锯。
|
||||
|
||||
**只删除 profile `node_modules` 里的同名目录,不动声明与锁文件。** 2026-09-15 的决策已经否决:pnpm 会按保留的声明与 overrides 重装相同的旧包。
|
||||
|
||||
**让 generation 中的 `@deepseek-ai/*` 条目绝对优先于 profile 内的同名副本。** 这会覆盖插件自带的版本,是解析规则的新决策,不在移除清理的范围内。
|
||||
|
||||
**只在安装器中清理。** 2026-09-15 的决策已经否决:遗漏其他 profile,也管不到安装后重新产生的副本。
|
||||
|
||||
## Verification
|
||||
|
||||
- [project-manager.spec.ts](../../../../apps/desktop/tests/project-manager.spec.ts) 断言一次启动准备后,profile 里已安装的核心包目录、第三方插件目录、manifest 声明、`desktop-runtime-state.json` 与锁文件逐字节不变。
|
||||
- 仓库中不再有 `cleanProfileCorePackages`、`migrateDesktopProfileLinks`、`CLEAN_PROFILE_CORE_PACKAGES` 与 `DESKTOP_PROFILE_STATE` 的引用。
|
||||
|
||||
## Consequences
|
||||
|
||||
买到的:生产启动不再写 profile,没有临时启用开关,Desktop 与 CLI 对 profile 内副本采用同一套规则。付出的:旧版 Desktop 装进 profile 的核心包副本靠手工清理;第三方插件以实依赖带进 profile 的 dsh 副本按其自身版本运行。
|
||||
+2
-2
@@ -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/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.md
|
||||
2026-08-04-artifact-first-npm-baseline-publication.md: ab4171c60dea47dbdf8fbd7b9d32a41abf36407d
|
||||
2026-08-04-artifact-first-npm-baseline-publication.zh.md: 8b1faf75e1ecb616d2895b4f3d123fff1f37fbf6
|
||||
2026-08-04-artifact-first-npm-baseline-publication.md: 1c317176521afd2e11707f954520039a3f229a44
|
||||
2026-08-04-artifact-first-npm-baseline-publication.zh.md: c2822ed0b572b60fe23cab7c5dc1ebfbe02230f7
|
||||
+1
-1
@@ -18,7 +18,7 @@ The publication flow uses an immutable release bundle as its boundary. The pack
|
||||
|
||||
The target set contains only `@deepseek-ai/*` workspace packages discovered from `packages/*/*/package.json` and `apps/*/package.json`. The root project, `website/`, vendor, Python, and native workspaces are outside this NPM baseline. Discovery must reject duplicate names, mixed base versions, an unexpected publication privacy state, and unknown packages in the bundle instead of relying on another hand-maintained package-name list.
|
||||
|
||||
The prerelease version consists of the package stable base version, a second-precision UTC timestamp captured when the command starts, and the target commit's 10-character short SHA: `<base>-<YYYYMMDDHHmmss>-<short-commit>`. The dist-tag is derived as `dev-<base>`. For example, base `0.0.1`, time `2026-08-04T00:32:00Z`, and commit `909292dd7b` produce version `0.0.1-20260804003200-909292dd7b` and tag `dev-0.0.1`. Retrying one release bundle must retain its version and manifest; repacking creates a version from the new command start time.
|
||||
The prerelease version consists of the package stable base version, a second-precision UTC timestamp captured when the command starts, and the target commit's 10-character short SHA: `<base>-<YYYYMMDDHHmmss>-<short-commit>`. The dist-tag is derived as `dev-<base>`. For example, base `0.0.1`, time `2026-08-04T00:32:00Z`, and short SHA `<short-commit>` produce version `0.0.1-20260804003200-<short-commit>` and tag `dev-0.0.1`. Retrying one release bundle must retain its version and manifest; repacking creates a version from the new command start time.
|
||||
|
||||
The pack phase runs in this order:
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ monorepo 中可运行的源码并不能证明发布后的包可运行。workspac
|
||||
|
||||
目标集合只包含 `packages/*/*/package.json` 与 `apps/*/package.json` 中命名为 `@deepseek-ai/*` 的 workspace 包。根项目、`website/`、vendor、Python 与 native workspace 不属于该 NPM 基线。发现机制必须拒绝重复包名、不同基础版本、意外的 `private` 发布状态以及集合中的未知包,而不是维护另一份手工包名列表。
|
||||
|
||||
预发布版本由包的稳定基础版本、命令启动时精确到秒的 UTC 时间戳和目标 commit 的 10 位短 SHA 组成:`<base>-<YYYYMMDDHHmmss>-<short-commit>`。dist-tag 由基础版本派生为 `dev-<base>`。例如,基础版本 `0.0.1`、时间 `2026-08-04T00:32:00Z` 和 commit `909292dd7b` 生成版本 `0.0.1-20260804003200-909292dd7b` 与 tag `dev-0.0.1`。同一 release bundle 的重试必须沿用原版本和 manifest;重新 pack 会按新的命令启动时间生成新版本。
|
||||
预发布版本由包的稳定基础版本、命令启动时精确到秒的 UTC 时间戳和目标 commit 的 10 位短 SHA 组成:`<base>-<YYYYMMDDHHmmss>-<short-commit>`。dist-tag 由基础版本派生为 `dev-<base>`。例如,基础版本 `0.0.1`、时间 `2026-08-04T00:32:00Z` 和短 SHA `<short-commit>` 生成版本 `0.0.1-20260804003200-<short-commit>` 与 tag `dev-0.0.1`。同一 release bundle 的重试必须沿用原版本和 manifest;重新 pack 会按新的命令启动时间生成新版本。
|
||||
|
||||
pack 阶段按以下顺序执行:
|
||||
|
||||
|
||||
@@ -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 apps/cli/reference/README.md
|
||||
README.md: 9ff0dce30f6de58c7b84f54186f7780900b319fc
|
||||
README.zh.md: e1ed9a9165b993cb4ea8a82af6641d5befd77a07
|
||||
README.md: 5bc0b1f12ebce558925ee61bb053bfc1f10d208b
|
||||
README.zh.md: 602d4440652eb1874cb54817d19a3f00bd83fe0d
|
||||
@@ -8,7 +8,7 @@ This reference defines the profile, plugin-management, and config-dump command m
|
||||
|
||||
`dsh <name>` abbreviates `dsh --profile <name>` and boots the profile at `$DSH_HOME/profiles/<name>`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
|
||||
|
||||
Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and installs the resulting immutable generation into Node's runtime resolvers. Startup creates no shared or profile-owned fallback links. Profile-installed packages keep native priority; profile initialization and package-manager writes remain separate from runtime resolution.
|
||||
Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and installs the resulting immutable runtime resolution into Node's resolvers. Startup creates no shared or profile-owned fallback links. Profile-installed packages keep native priority; profile initialization and package-manager writes remain separate from runtime resolution.
|
||||
|
||||
The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loudly with a hint to run `dsh plugin --profile <name> add <package>`.
|
||||
|
||||
@@ -48,7 +48,7 @@ dsh --profile web --dump-default-config
|
||||
dsh --profile web --patch ./extra.yml --dump-config
|
||||
```
|
||||
|
||||
`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump initializes missing profile files but does not prepare the runtime module fallback under `$DSH_HOME/profiles/node_modules`. It never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
|
||||
`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump initializes missing profile files. It never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
|
||||
|
||||
<a id="startup-diagnostics"></a>
|
||||
## Startup diagnostics
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
`dsh <name>` 是 `dsh --profile <name>` 的简写,启动位于 `$DSH_HOME/profiles/<name>` 的 profile。简写中的名称必须紧跟 `dsh`;`plugin` 仍为插件管理命令,因此启动同名 profile 时须使用 `dsh --profile plugin`。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。最终 YAML 组合决定是否由 `dsh-hmr` 监视配置;未启用 HMR 时,更改需要重启。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
|
||||
|
||||
组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并将生成的不可变 generation 安装到 Node 的运行时解析器中。启动不会创建共享或 profile 自有的 fallback 链接。profile 已安装包保留原生优先级;profile 初始化和包管理器写入与运行时解析相互独立。
|
||||
组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并将生成的不可变 runtime resolution 安装到 Node 的解析器中。启动不会创建共享或 profile 自有的 fallback 链接。profile 已安装包保留原生优先级;profile 初始化和包管理器写入与运行时解析相互独立。
|
||||
|
||||
`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
|
||||
@@ -50,7 +50,7 @@ dsh --profile web --dump-default-config
|
||||
dsh --profile web --patch ./extra.yml --dump-config
|
||||
```
|
||||
|
||||
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作会初始化缺失的 profile 文件,但不会准备 `$DSH_HOME/profiles/node_modules` 下的运行时模块 fallback。它不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。
|
||||
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作会初始化缺失的 profile 文件。它不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。
|
||||
|
||||
<a id="startup-diagnostics"></a>
|
||||
## 启动诊断
|
||||
|
||||
@@ -18,9 +18,7 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import {
|
||||
boot,
|
||||
readProfilePatches,
|
||||
createProfileResolutionGeneration,
|
||||
healProfilesModuleFallback,
|
||||
healIsolatedProfileModuleFallback,
|
||||
createRuntimeResolution,
|
||||
initProfile,
|
||||
installFailLoud,
|
||||
loadOverlayPatches,
|
||||
@@ -31,8 +29,7 @@ import {
|
||||
resolveProfileDir,
|
||||
type ProfileContext,
|
||||
type Profile,
|
||||
type ProfileResolutionGeneration,
|
||||
type ProfileResolutionMode,
|
||||
type RuntimeResolution,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
|
||||
@@ -176,8 +173,8 @@ export function prepareProfile(name: string, userLayer = true, fromDefaultProfil
|
||||
/** One profile's patch layers, in application order. */
|
||||
interface ComposedProfile {
|
||||
profile: Profile
|
||||
/** Immutable package fallback selected before any plugin imports. */
|
||||
resolution: ProfileResolutionGeneration
|
||||
/** Immutable runtime resolution computed before any plugin imports. */
|
||||
resolution: RuntimeResolution
|
||||
/** Command-line overlay contents, frozen for this invocation. */
|
||||
overlays: PatchOptions[]
|
||||
}
|
||||
@@ -191,7 +188,6 @@ interface ComposedProfile {
|
||||
* then the telemetry switch.
|
||||
* @param name - the profile name.
|
||||
* @param patchFiles - `--patch` overlay paths, in argv order.
|
||||
* @param resolutionMode - runtime lookup, disk links, or dual verification of both.
|
||||
* @param fromDefaultProfile - shipped template for a missing named profile.
|
||||
* @param resolvedProfile - application-owned profile and installation.
|
||||
* @returns the profile and its patch layers.
|
||||
@@ -199,17 +195,13 @@ interface ComposedProfile {
|
||||
async function composeProfile(
|
||||
name: string,
|
||||
patchFiles: readonly string[],
|
||||
resolutionMode: ProfileResolutionMode,
|
||||
fromDefaultProfile?: string,
|
||||
resolvedProfile?: ResolvedProfileRuntime,
|
||||
): Promise<ComposedProfile> {
|
||||
const profile = resolvedProfile?.profile ?? prepareProfile(name, true, fromDefaultProfile)
|
||||
if (resolvedProfile !== undefined) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
|
||||
const resolutionOptions = { installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR, profile }
|
||||
if (resolvedProfile !== undefined && resolutionMode !== 'runtime') healIsolatedProfileModuleFallback(resolvedProfile)
|
||||
const resolution = resolutionMode === 'runtime' || resolvedProfile !== undefined
|
||||
? await createProfileResolutionGeneration(resolutionOptions)
|
||||
: await healProfilesModuleFallback(resolutionOptions)
|
||||
const resolution = await createRuntimeResolution(resolutionOptions)
|
||||
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
|
||||
return { profile, resolution, overlays }
|
||||
}
|
||||
@@ -238,8 +230,6 @@ export interface RunProfileOptions {
|
||||
args: readonly string[]
|
||||
/** Application-owned package runtime, scoped to plugin package operations. */
|
||||
packageManager?: ProfileContext['packageManager']
|
||||
/** Module fallback backend; defaults to runtime. Plain Node callers may override it; pkg executables always use runtime. */
|
||||
resolutionMode?: ProfileResolutionMode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,8 +249,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
(message) => { process.stderr.write(`${NAME}: ${message}\n`) },
|
||||
)
|
||||
|
||||
const packaged = (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined
|
||||
const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'runtime'
|
||||
const app: { current?: Context } = {}
|
||||
let disposal: Promise<void> | undefined
|
||||
const dispose = (): Promise<void> => disposal ??= (async () => {
|
||||
@@ -273,7 +261,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
})()
|
||||
try {
|
||||
const composed = await composeProfile(
|
||||
options.profile, options.patchFiles, resolutionMode, options.fromDefaultProfile, options.resolvedProfile,
|
||||
options.profile, options.patchFiles, options.fromDefaultProfile, options.resolvedProfile,
|
||||
)
|
||||
const appReady = createAppReady()
|
||||
const shutdown = createProcessShutdown(dispose)
|
||||
@@ -309,9 +297,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
// Before any config-tree entry mounts, so plugins resolve all launch-time
|
||||
// environment values from the same immutable launch snapshot.
|
||||
hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
|
||||
await hostCtx.plugin(PluginPackages, resolutionMode === 'link' ? {} : {
|
||||
generation: composed.resolution,
|
||||
behavior: resolutionMode === 'dual' ? 'verify' : 'enforce',
|
||||
await hostCtx.plugin(PluginPackages, {
|
||||
resolution: composed.resolution,
|
||||
})
|
||||
// The command line and bounded exit request are launcher facts available
|
||||
// to every app plugin that injects the argument snapshot.
|
||||
|
||||
@@ -17,6 +17,7 @@ import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { execa } from 'execa'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { testProfileResolution } from './profiles/headless/tests/profile-resolution.ts'
|
||||
|
||||
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
@@ -341,6 +342,8 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
testProfileResolution('lib')
|
||||
|
||||
it('requires a profile and rejects removed flags', async () => {
|
||||
const bare = await runBuiltBin()
|
||||
expect(bare.code).toBe(1)
|
||||
|
||||
@@ -47,7 +47,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
|
||||
copyFileSync(join(hostDirectory, 'lib', 'index.js'), entry)
|
||||
const pnpm = join(root, 'bundled-pnpm.mjs')
|
||||
const nodeBin = join(root, 'bin')
|
||||
const child = fork(entry, [root, root, root, 'runtime', pnpm, nodeBin], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
|
||||
const child = fork(entry, [root, root, root, pnpm, nodeBin], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
|
||||
let stderr = ''
|
||||
child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
|
||||
const exited = new Promise<number | null>(resolve => child.once('exit', resolve))
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/** Source-profile tool-dispatch evidence from the real Agent and Node module cache. */
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-cmdline'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Observations from one isolated Agent's run through the shipped headless profile. */
|
||||
export interface SourceToolEvidence {
|
||||
execArgv: string[]
|
||||
modules: string[]
|
||||
errors: string[]
|
||||
events: SessionEvent<'tool/call' | 'tool/result' | 'turn/end'>[]
|
||||
}
|
||||
|
||||
/** Services needed to drive the Agent and inspect loaded modules. */
|
||||
export const inject = ['agents', 'loader']
|
||||
|
||||
/**
|
||||
* Drive one mock-model turn after application readiness and dispose it before requesting exit.
|
||||
* @param ctx - context in the source-launched headless Loader tree.
|
||||
* @param config - isolated working directory for the Agent.
|
||||
*/
|
||||
export function apply(ctx: Context, config: { cwd: string }): void {
|
||||
const ready = ctx.get('appReady')
|
||||
const exit = ctx.get('appExit')
|
||||
if (ready === undefined || exit === undefined) throw new Error('Source tool driver requires appReady and appExit')
|
||||
const events: SourceToolEvidence['events'] = []
|
||||
const errors: string[] = []
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'tool/call' || event.type === 'tool/result' || event.type === 'turn/end') events.push(event)
|
||||
}, { global: true })
|
||||
ctx.on('agent/error', ({ error }) => { errors.push(error instanceof Error ? error.message : String(error)) }, { global: true })
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId(randomUUID()),
|
||||
meta: { cwd: config.cwd },
|
||||
agentOptions: { provider: 'cli-mock', model: 'cli-mock' },
|
||||
})
|
||||
try {
|
||||
handle.agent.followup(createUserMessage({
|
||||
source: { kind: 'user' },
|
||||
content: [{ type: 'text', text: 'Run the shell smoke command once.' }],
|
||||
}))
|
||||
await handle.agent.whenIdle()
|
||||
const internal = ctx.loader.internal
|
||||
if (internal === undefined) throw new Error('Source tool driver requires Node module-cache introspection')
|
||||
const evidence: SourceToolEvidence = {
|
||||
execArgv: process.execArgv,
|
||||
modules: [...internal.loadCache.keys()].filter(url => /\/packages\/core\/(?:tools|agent-loop)\//.test(url)),
|
||||
errors,
|
||||
events,
|
||||
}
|
||||
process.stdout.write(`DSH_SOURCE_TOOL_RESULT ${JSON.stringify(evidence)}\n`)
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
exit(errors.length === 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
ctx.effect(() => ready.onReady(() => {
|
||||
void run().catch((error: unknown) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`)
|
||||
exit(1)
|
||||
})
|
||||
}), 'source tool driver')
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/** Real CLI profile imports in source and built launches, including npm-link dependency layouts. */
|
||||
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, readdir, readlink, rm, symlink, unlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { resolveExampleLaunch, type ExampleMode } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { execa } from 'execa'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
|
||||
const processTimeoutMs = 75_000
|
||||
const bundleName = 'profile-resolution-bundle'
|
||||
const bridgeName = 'profile-resolution-bridge'
|
||||
const leafName = 'profile-resolution-leaf'
|
||||
const pluginName = 'profile-resolution-plugin'
|
||||
const externalName = 'profile-resolution-external'
|
||||
const externalLeafName = 'profile-resolution-external-leaf'
|
||||
const marker = 'DSH_PROFILE_RESOLUTION '
|
||||
|
||||
interface ResolutionEvidence {
|
||||
execArgv: string[]
|
||||
esm: { version: string; url: string }
|
||||
cjs: { version: string; filename: string }
|
||||
sameEsmLeaf: boolean
|
||||
sameCjsLeaf: boolean
|
||||
externalEsm: { version: string; url: string; leaf: ResolutionEvidence['esm'] }
|
||||
externalCjs: { version: string; filename: string; leaf: ResolutionEvidence['cjs'] }
|
||||
sameEsmExternal: boolean
|
||||
sameCjsExternal: boolean
|
||||
sameEsmExternalLeaf: boolean
|
||||
sameCjsExternalLeaf: boolean
|
||||
externalManaged: boolean
|
||||
toolsInstance: boolean
|
||||
scheduler: boolean
|
||||
modules: string[]
|
||||
}
|
||||
|
||||
async function writePackage(dir: string, manifest: Record<string, unknown>, files: Record<string, string>): Promise<void> {
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'package.json'), JSON.stringify(manifest) + '\n')
|
||||
for (const [name, content] of Object.entries(files)) await writeFile(join(dir, name), content + '\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the same runtime-resolution assertions in the source-only and built-bin lanes.
|
||||
* @param mode - source uses the CLI's ESM-only tsx hook; lib requires built workspace exports.
|
||||
*/
|
||||
export function testProfileResolution(mode: ExampleMode): void {
|
||||
it.each(['installed', 'npm-link'] as const)(`resolves a %s profile dependency graph in ${mode} mode`, {
|
||||
timeout: processTimeoutMs + 15_000, retry: 0,
|
||||
}, async (layout) => {
|
||||
// The child reports loaded module paths in the form DSH_HOME was given; hand it the native realpath so
|
||||
// Windows 8.3 tmpdir names and macOS /var symlinks match the expectations computed below.
|
||||
const root = realpathSync.native(await mkdtemp(join(tmpdir(), 'dsh-profile-resolution-')))
|
||||
const links: string[] = []
|
||||
try {
|
||||
const home = join(root, 'home')
|
||||
const profileDir = join(home, 'profiles', 'headless')
|
||||
const installedBundle = join(profileDir, 'node_modules', bundleName)
|
||||
const bundleDir = layout === 'npm-link' ? join(root, 'work', bundleName) : installedBundle
|
||||
const installedBridge = join(bundleDir, 'node_modules', bridgeName)
|
||||
const bridgeDir = layout === 'npm-link' ? join(root, 'dependencies', bridgeName) : installedBridge
|
||||
const logicalLeaf = join(bundleDir, 'node_modules', leafName)
|
||||
const realLeaf = join(root, 'dependencies', 'node_modules', leafName)
|
||||
const exports = { import: './index.mjs', require: './index.cjs' }
|
||||
await writePackage(bundleDir, {
|
||||
name: bundleName, version: '1.0.0', dependencies: { [bridgeName]: '*' },
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}, { 'cordis.patch.yml': '[]' })
|
||||
await writePackage(bridgeDir, {
|
||||
name: bridgeName, version: '1.0.0', exports, dependencies: { [leafName]: '*' },
|
||||
}, {
|
||||
'index.mjs': `export { leaf } from '${leafName}'`,
|
||||
'index.cjs': `module.exports = require('${leafName}')`,
|
||||
})
|
||||
for (const [dir, version] of [[logicalLeaf, '1.0.0'], [realLeaf, '2.0.0']] as const) {
|
||||
await writePackage(dir, { name: leafName, version, exports }, {
|
||||
'index.mjs': `export const leaf = { version: '${version}', url: import.meta.url }`,
|
||||
'index.cjs': `exports.leaf = { version: '${version}', filename: __filename }`,
|
||||
})
|
||||
}
|
||||
if (layout === 'npm-link') {
|
||||
const globalBundle = join(root, 'npm-global', 'node_modules', bundleName)
|
||||
for (const [target, link] of [[bundleDir, globalBundle], [globalBundle, installedBundle], [bridgeDir, installedBridge]] as const) {
|
||||
await mkdir(dirname(link), { recursive: true })
|
||||
await symlink(target, link, 'junction')
|
||||
links.push(link)
|
||||
}
|
||||
}
|
||||
|
||||
const sharedModules = join(home, 'profiles', 'node_modules')
|
||||
const pluginDir = join(profileDir, 'node_modules', pluginName)
|
||||
const sharedExternal = join(sharedModules, externalName)
|
||||
const externalDir = layout === 'npm-link' ? join(root, 'external', externalName) : sharedExternal
|
||||
const externalLeaf = join(externalDir, 'node_modules', externalLeafName)
|
||||
const sharedLeaf = join(sharedModules, externalLeafName)
|
||||
const ancestorExternal = join(home, 'node_modules', externalName)
|
||||
const ancestorLeaf = join(home, 'node_modules', externalLeafName)
|
||||
await writePackage(pluginDir, {
|
||||
name: pluginName, version: '1.0.0', exports, dependencies: { [externalName]: '*' },
|
||||
}, {
|
||||
'index.mjs': `export { external } from '${externalName}'\nexport function apply() {}`,
|
||||
'index.cjs': `module.exports = require('${externalName}')`,
|
||||
})
|
||||
for (const [dir, version] of [[externalDir, '3.0.0'], [ancestorExternal, '9.0.0']] as const) {
|
||||
await writePackage(dir, { name: externalName, version, exports, dependencies: { [externalLeafName]: '*' } }, {
|
||||
'index.mjs': `import { leaf } from '${externalLeafName}'\nexport const external = { version: '${version}', url: import.meta.url, leaf }`,
|
||||
'index.cjs': `exports.external = { version: '${version}', filename: __filename, leaf: require('${externalLeafName}').leaf }`,
|
||||
})
|
||||
}
|
||||
for (const [dir, version] of [[externalLeaf, '4.0.0'], [sharedLeaf, '8.0.0'], [ancestorLeaf, '9.0.0']] as const) {
|
||||
await writePackage(dir, { name: externalLeafName, version, exports }, {
|
||||
'index.mjs': `export const leaf = { version: '${version}', url: import.meta.url }`,
|
||||
'index.cjs': `exports.leaf = { version: '${version}', filename: __filename }`,
|
||||
})
|
||||
}
|
||||
const staleTools = join(root, 'stale-tools')
|
||||
const staleToolsLink = join(sharedModules, '@deepseek-ai', 'dsh-tools')
|
||||
await writePackage(staleTools, { name: '@deepseek-ai/dsh-tools', version: '0.0.0', exports }, {
|
||||
'index.mjs': "export default class Tools {}\nexport const TOOL_RUNTIME_SCHEDULER = Symbol()\nthrow new Error('STALE_DSH_TOOLS')",
|
||||
'index.cjs': "throw new Error('STALE_DSH_TOOLS')",
|
||||
})
|
||||
for (const [target, link] of [
|
||||
[staleTools, staleToolsLink],
|
||||
...layout === 'npm-link' ? [[externalDir, sharedExternal]] as const : [],
|
||||
] as const) {
|
||||
await mkdir(dirname(link), { recursive: true })
|
||||
await symlink(target, link, 'junction')
|
||||
links.push(link)
|
||||
}
|
||||
|
||||
// The probe imports from the profile, where the bundle's transitive dependencies need fallback.
|
||||
await writePackage(profileDir, {
|
||||
name: 'resolution-profile', private: true, dependencies: { [bundleName]: '*', [pluginName]: '*' },
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless', bundleName] } },
|
||||
}, {
|
||||
'probe.mjs': [
|
||||
"import { createRequire } from 'node:module'",
|
||||
"import { getEnvironmentData } from 'node:worker_threads'",
|
||||
"import Tools, { TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'",
|
||||
`import { leaf } from '${leafName}'`,
|
||||
`import { leaf as bridgeLeaf } from '${bridgeName}'`,
|
||||
`import { external } from '${externalName}'`,
|
||||
`import { external as pluginExternal } from '${pluginName}'`,
|
||||
`import { leaf as externalLeaf } from ${JSON.stringify(pathToFileURL(join(externalLeaf, 'index.mjs')).href)}`,
|
||||
'const require = createRequire(import.meta.url)',
|
||||
"export const inject = ['tools', 'agentLoop', 'loader']",
|
||||
'export function apply(ctx) {',
|
||||
" const ready = ctx.get('appReady')",
|
||||
" const exit = ctx.get('appExit')",
|
||||
' ctx.effect(() => ready.onReady(() => {',
|
||||
` const cjs = require('${leafName}').leaf`,
|
||||
` const externalCjs = require('${externalName}').external`,
|
||||
" const entries = getEnvironmentData('@deepseek-ai/dsh-app-boot/profile-resolution').resolution.entries",
|
||||
' const evidence = {',
|
||||
' execArgv: process.execArgv, esm: leaf, cjs,',
|
||||
' sameEsmLeaf: leaf === bridgeLeaf,',
|
||||
` sameCjsLeaf: cjs === require('${bridgeName}').leaf,`,
|
||||
' externalEsm: external, externalCjs,',
|
||||
' sameEsmExternal: external === pluginExternal,',
|
||||
` sameCjsExternal: externalCjs === require('${pluginName}').external,`,
|
||||
' sameEsmExternalLeaf: external.leaf === externalLeaf,',
|
||||
` sameCjsExternalLeaf: externalCjs.leaf === require(${JSON.stringify(join(externalLeaf, 'index.cjs'))}).leaf,`,
|
||||
` externalManaged: entries.some(entry => [${JSON.stringify(externalName)}, ${JSON.stringify(externalLeafName)}].includes(entry.name)),`,
|
||||
' toolsInstance: ctx.tools instanceof Tools,',
|
||||
" scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',",
|
||||
' modules: [...ctx.loader.internal.loadCache.keys()]',
|
||||
' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),',
|
||||
' }',
|
||||
` process.stdout.write('${marker}' + JSON.stringify(evidence) + '\\n')`,
|
||||
' exit(0)',
|
||||
" }), 'profile resolution probe')",
|
||||
'}',
|
||||
].join('\n'),
|
||||
'cordis.patch.yml': JSON.stringify([
|
||||
{ id: 'headless-startup', disabled: true },
|
||||
{ id: 'headless-runner', disabled: true },
|
||||
{ id: 'llm-deepseek', disabled: true },
|
||||
{ insert: [
|
||||
{ id: 'external-plugin', name: pluginName },
|
||||
{ id: 'resolution-probe', name: join(profileDir, 'probe.mjs') },
|
||||
] },
|
||||
]),
|
||||
})
|
||||
const packageDirs = [
|
||||
bundleDir, bridgeDir, logicalLeaf, realLeaf, pluginDir, externalDir, externalLeaf,
|
||||
sharedLeaf, ancestorExternal, ancestorLeaf, staleTools, profileDir,
|
||||
]
|
||||
const packageFiles = (await Promise.all(packageDirs.map(async dir =>
|
||||
(await readdir(dir, { withFileTypes: true })).filter(entry => entry.isFile()).map(entry => join(dir, entry.name)),
|
||||
))).flat()
|
||||
const contents = await Promise.all(packageFiles.map(async path => [path, await readFile(path)] as const))
|
||||
const linkTargets = await Promise.all(links.map(async path => [path, await readlink(path)] as const))
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: join(repoRoot, 'apps/cli/src/bin.ts'),
|
||||
mode, sourceImport: 'tsx/esm', tsconfigPath: join(repoRoot, 'tsconfig.json'),
|
||||
configArgs: ['--profile', 'headless'],
|
||||
env: {
|
||||
DSH_HOME: home, DSH_AGENTS_HOME: join(root, 'agents'), DSH_TELEMETRY_DISABLED: '1',
|
||||
NODE_OPTIONS: undefined, TSX_TSCONFIG_PATH: undefined,
|
||||
},
|
||||
})
|
||||
const result = await execa(launch.command, launch.args, {
|
||||
cwd: repoRoot, env: launch.env, input: '', timeout: processTimeoutMs,
|
||||
killSignal: 'SIGKILL', reject: false,
|
||||
})
|
||||
const diagnostic = `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||
expect(result.timedOut, diagnostic).toBe(false)
|
||||
expect(result.signal, diagnostic).toBeUndefined()
|
||||
expect(result.exitCode, diagnostic).toBe(0)
|
||||
const records = result.stdout.split('\n').filter(line => line.startsWith(marker))
|
||||
expect(records, diagnostic).toHaveLength(1)
|
||||
const evidence = JSON.parse(records[0]!.slice(marker.length)) as ResolutionEvidence
|
||||
const selectedLeaf = layout === 'npm-link' ? realLeaf : logicalLeaf
|
||||
const version = layout === 'npm-link' ? '2.0.0' : '1.0.0'
|
||||
expect(evidence.esm).toEqual({ version, url: pathToFileURL(realpathSync.native(join(selectedLeaf, 'index.mjs'))).href })
|
||||
expect(evidence.cjs).toEqual({ version, filename: realpathSync.native(join(selectedLeaf, 'index.cjs')) })
|
||||
expect(evidence.sameEsmLeaf).toBe(true)
|
||||
expect(evidence.sameCjsLeaf).toBe(true)
|
||||
expect(evidence.externalEsm).toEqual({
|
||||
version: '3.0.0', url: pathToFileURL(realpathSync.native(join(externalDir, 'index.mjs'))).href,
|
||||
leaf: { version: '4.0.0', url: pathToFileURL(realpathSync.native(join(externalLeaf, 'index.mjs'))).href },
|
||||
})
|
||||
expect(evidence.externalCjs).toEqual({
|
||||
version: '3.0.0', filename: realpathSync.native(join(externalDir, 'index.cjs')),
|
||||
leaf: { version: '4.0.0', filename: realpathSync.native(join(externalLeaf, 'index.cjs')) },
|
||||
})
|
||||
expect(evidence.sameEsmExternal).toBe(true)
|
||||
expect(evidence.sameCjsExternal).toBe(true)
|
||||
expect(evidence.sameEsmExternalLeaf).toBe(true)
|
||||
expect(evidence.sameCjsExternalLeaf).toBe(true)
|
||||
expect(evidence.externalManaged).toBe(false)
|
||||
expect(evidence.toolsInstance).toBe(true)
|
||||
expect(evidence.scheduler).toBe(true)
|
||||
expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : [])
|
||||
for (const name of ['tools', 'agent-loop']) {
|
||||
const selected = mode === 'src' ? 'src/index.ts' : 'lib/index.js'
|
||||
const other = mode === 'src' ? 'lib' : 'src'
|
||||
expect(evidence.modules.filter(url => url.endsWith(`/packages/core/${name}/${selected}`))).toHaveLength(1)
|
||||
expect(evidence.modules.filter(url => url.includes(`/packages/core/${name}/${other}/`))).toEqual([])
|
||||
}
|
||||
for (const [path, content] of contents) expect(await readFile(path), path).toEqual(content)
|
||||
for (const [path, target] of linkTargets) expect(await readlink(path), path).toBe(target)
|
||||
} finally {
|
||||
try {
|
||||
for (const link of links.reverse()) await unlink(link)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Source CLI tool execution with the native addon and generated typert contributors
|
||||
* prepared by the build-backed smoke lane; core workspace modules must stay in src.
|
||||
*
|
||||
* The assertion accepts a real tool result or an explicit SandboxUnavailableError: the
|
||||
* subject is that a source-profile tool call reaches tool/result through one Tools instance,
|
||||
* not that the host can confine the shell. keyless-smoke.e2e.ts in the same gate owns the
|
||||
* confinement round trip and stays strict; on CI prepare-ci-bubblewrap.sh makes both paths
|
||||
* succeed, so the error branch only runs on hosts whose sandbox probe fails.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { clearedProxyEnv } from '@deepseek-ai/dsh-http-proxy'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SourceToolEvidence } from '../../../fixtures/source-tool-driver.ts'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
|
||||
const dshSourceBin = 'apps/cli/src/bin.ts'
|
||||
const sourceToolTimeoutMs = 90_000
|
||||
|
||||
describe.skipIf(!existsSync(join(repoRoot, 'apps/cli/lib/bin.js')))('dsh SOURCE tools with prepared runtime artifacts', () => {
|
||||
it('dispatches a source-profile shell call to a result, including sandbox-unavailable hosts, without mixing tools src/lib', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-source-tool-'))
|
||||
try {
|
||||
const cwd = join(root, 'workspace')
|
||||
await mkdir(cwd)
|
||||
const patch = join(root, 'source-tool.patch.yml')
|
||||
await writeFile(patch, JSON.stringify([
|
||||
{ id: 'headless-startup', disabled: true },
|
||||
{ id: 'headless-runner', disabled: true },
|
||||
{ id: 'llm-deepseek', disabled: true },
|
||||
{ id: 'agent-default-model', config: { provider: 'cli-mock', model: 'cli-mock' } },
|
||||
{ insert: [
|
||||
{
|
||||
id: 'cli-mock-llm',
|
||||
name: join(repoRoot, 'packages/test-support/loader-smoke/tests/fixtures/cli-mock-llm.ts'),
|
||||
},
|
||||
{
|
||||
id: 'source-tool-driver',
|
||||
name: fileURLToPath(new URL('../../../fixtures/source-tool-driver.ts', import.meta.url)),
|
||||
config: { cwd },
|
||||
},
|
||||
] },
|
||||
]))
|
||||
// Source modules and driver stay unbuilt; native and generated runtime artifacts are required.
|
||||
const result = await execa(process.execPath, [
|
||||
'--import', 'tsx/esm', dshSourceBin, '--profile', 'headless', '--patch', patch,
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...clearedProxyEnv(),
|
||||
DSH_HOME: join(root, 'home'),
|
||||
DSH_AGENTS_HOME: join(root, 'agents'),
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_TOOLS_MODE: 'native',
|
||||
DSH_CLI_MOCK_FAILURE: '0',
|
||||
},
|
||||
input: '',
|
||||
timeout: sourceToolTimeoutMs - 15_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
const diagnostic = `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||
expect(result.timedOut, diagnostic).toBe(false)
|
||||
expect(result.signal, diagnostic).toBeUndefined()
|
||||
expect(result.exitCode, diagnostic).toBe(0)
|
||||
const records = result.stdout.split('\n').filter(line => line.startsWith('DSH_SOURCE_TOOL_RESULT '))
|
||||
expect(records, diagnostic).toHaveLength(1)
|
||||
const evidence = JSON.parse(records[0]!.slice('DSH_SOURCE_TOOL_RESULT '.length)) as SourceToolEvidence
|
||||
expect(evidence.execArgv).toEqual(['--import', 'tsx/esm'])
|
||||
expect(evidence.errors).toEqual([])
|
||||
for (const pkg of ['tools', 'agent-loop']) {
|
||||
expect(evidence.modules.filter(url => url.endsWith(`/packages/core/${pkg}/src/index.ts`))).toHaveLength(1)
|
||||
expect(evidence.modules.filter(url => url.includes(`/packages/core/${pkg}/lib/`))).toEqual([])
|
||||
}
|
||||
const calls = evidence.events.filter(event => event.type === 'tool/call')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.data).toMatchObject({
|
||||
callId: 'cli-smoke-call', name: process.platform === 'win32' ? 'pwsh' : 'bash',
|
||||
})
|
||||
const results = evidence.events.filter(event => event.type === 'tool/result')
|
||||
expect(results).toHaveLength(1)
|
||||
const toolResult = results[0]!.data
|
||||
expect(toolResult.message.content).toHaveLength(1)
|
||||
const block = toolResult.message.content[0]
|
||||
expect(block).toMatchObject({ type: 'tool-result', toolCallId: 'cli-smoke-call' })
|
||||
if (toolResult.error !== undefined) {
|
||||
expect(toolResult.error).toMatchObject({ name: 'SandboxUnavailableError', code: 'SANDBOX_UNAVAILABLE' })
|
||||
expect(block.isError).toBe(true)
|
||||
} else {
|
||||
expect(block.isError).not.toBe(true)
|
||||
expect(block.content.filter(part => part.type === 'text').map(part => part.text).join(''))
|
||||
.toContain('CLI_TOOL_ROUND_TRIP')
|
||||
}
|
||||
expect(evidence.events.filter(event => event.type === 'turn/end').map(event => event.data.reason))
|
||||
.toEqual([{ kind: 'completed' }])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
}, sourceToolTimeoutMs)
|
||||
})
|
||||
@@ -138,7 +138,7 @@ function start(f: ReturnType<typeof fixture>, extra: string[] = []) {
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('__DSH_BOOT__')
|
||||
const bundlePath = /<script src="(\/plugins\/[^"]+)"/u.exec(html)?.[1]?.replaceAll('&', '&')
|
||||
const bundlePath = /<script src="(plugins\/[^"]+)"/u.exec(html)?.[1]?.replaceAll('&', '&')
|
||||
if (!bundlePath) throw new Error('Missing bootstrap bundle URL')
|
||||
const bundle = await fetch(new URL(bundlePath, url), { headers: { cookie }, signal: AbortSignal.timeout(10_000) })
|
||||
expect(bundle.status).toBe(200)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/** Application-owned profiles share the named profile launch lifecycle. */
|
||||
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import {
|
||||
boot, composeEntries, createProfileResolutionGeneration, healIsolatedProfileModuleFallback,
|
||||
boot, composeEntries, createRuntimeResolution,
|
||||
PluginPackages, type Profile,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
|
||||
@@ -18,8 +18,7 @@ vi.mock('@deepseek-ai/dsh-app-boot', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
boot: vi.fn(),
|
||||
createProfileResolutionGeneration: vi.fn(actual.createProfileResolutionGeneration),
|
||||
healIsolatedProfileModuleFallback: vi.fn(actual.healIsolatedProfileModuleFallback),
|
||||
createRuntimeResolution: vi.fn(actual.createRuntimeResolution),
|
||||
installFailLoud: vi.fn(),
|
||||
}
|
||||
})
|
||||
@@ -35,10 +34,8 @@ afterEach(() => {
|
||||
|
||||
describe('runProfile with an application-owned profile', () => {
|
||||
it.each(
|
||||
(['link', 'runtime'] as const).flatMap(resolutionMode =>
|
||||
(['composition', 'boot', 'watch', 'cleanup', 'tree-cleanup', 'both-cleanups'] as const)
|
||||
.map(stage => ({ resolutionMode, stage }))),
|
||||
)('releases startup resources after a $stage failure in $resolutionMode mode', async ({ resolutionMode, stage }) => {
|
||||
['composition', 'boot', 'watch', 'cleanup', 'tree-cleanup', 'both-cleanups'] as const,
|
||||
)('releases startup resources after a %s failure', async (stage) => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-failure-'))
|
||||
homes.push(home)
|
||||
mkdirSync(join(home, 'runtime'))
|
||||
@@ -62,7 +59,7 @@ describe('runProfile with an application-owned profile', () => {
|
||||
await setup?.(ctx)
|
||||
throw failure
|
||||
})
|
||||
if (stage === 'composition') vi.mocked(createProfileResolutionGeneration).mockRejectedValueOnce(failure)
|
||||
if (stage === 'composition') vi.mocked(createRuntimeResolution).mockRejectedValueOnce(failure)
|
||||
const profile: Profile = {
|
||||
name: 'desktop', dir: home, patchPath: join(home, 'cordis.patch.yml'),
|
||||
patches: [], layers: [],
|
||||
@@ -70,7 +67,6 @@ describe('runProfile with an application-owned profile', () => {
|
||||
try {
|
||||
const application = runProfile({
|
||||
environment: createLaunchEnvironmentSnapshot([]), profile: 'desktop', patchFiles: [], args: ['--no-open'],
|
||||
resolutionMode,
|
||||
resolvedProfile: { profile, installAnchor: join(home, 'runtime/package.json') },
|
||||
})
|
||||
if (stage === 'both-cleanups') {
|
||||
@@ -90,12 +86,7 @@ describe('runProfile with an application-owned profile', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ selection: 'default', options: {}, mode: 'runtime' },
|
||||
{ selection: 'link', options: { resolutionMode: 'link' }, mode: 'link' },
|
||||
{ selection: 'dual', options: { resolutionMode: 'dual' }, mode: 'dual' },
|
||||
{ selection: 'runtime', options: { resolutionMode: 'runtime' }, mode: 'runtime' },
|
||||
] as const)('uses shared layers, $selection resolution, and shutdown', async ({ options, mode }) => {
|
||||
it('uses shared layers, runtime resolution, and shutdown', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-resolved-profile-'))
|
||||
homes.push(home)
|
||||
mkdirSync(join(home, 'runtime'))
|
||||
@@ -145,25 +136,14 @@ describe('runProfile with an application-owned profile', () => {
|
||||
const runtime = { profile, installAnchor: join(home, 'runtime/package.json') }
|
||||
try {
|
||||
const { shutdown } = await runProfile({
|
||||
environment, profile: 'desktop', resolvedProfile: runtime, ...options,
|
||||
environment, profile: 'desktop', resolvedProfile: runtime,
|
||||
patchFiles: [overlay], args: ['--port', '0', '--no-open'],
|
||||
})
|
||||
expect(installProxyFromEnvironment).toHaveBeenCalledWith(environment, expect.any(Function))
|
||||
if (mode !== 'runtime') {
|
||||
expect(healIsolatedProfileModuleFallback).toHaveBeenCalledWith({ profile, installAnchor: runtime.installAnchor })
|
||||
} else {
|
||||
expect(healIsolatedProfileModuleFallback).not.toHaveBeenCalled()
|
||||
}
|
||||
const generation = vi.mocked(createProfileResolutionGeneration).mock.settledResults
|
||||
const resolution = vi.mocked(createRuntimeResolution).mock.settledResults
|
||||
.find(result => result.type === 'fulfilled')?.value
|
||||
expect(generation?.profileDir).toBe(home)
|
||||
expect(plugin).toHaveBeenCalledWith(PluginPackages, mode === 'link' ? {} : {
|
||||
generation,
|
||||
behavior: mode === 'dual' ? 'verify' : 'enforce',
|
||||
})
|
||||
expect(existsSync(join(home, 'profiles/node_modules'))).toBe(false)
|
||||
expect(existsSync(join(home, '.dsh-module-fallback'))).toBe(mode !== 'runtime')
|
||||
expect(existsSync(join(home, 'node_modules/test-runtime'))).toBe(mode !== 'runtime')
|
||||
expect(resolution?.profileDir).toBe(home)
|
||||
expect(plugin).toHaveBeenCalledWith(PluginPackages, { resolution })
|
||||
expect(lstatSync(localPackageDir).isDirectory()).toBe(true)
|
||||
expect(readFileSync(join(localPackageDir, 'package.json'), 'utf8')).toBe(localManifest)
|
||||
const requireFromProfile = createRequire(join(home, 'package.json'))
|
||||
|
||||
@@ -2,21 +2,21 @@ import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { testProfileResolution } from './profiles/headless/tests/profile-resolution.ts'
|
||||
|
||||
/**
|
||||
* Keyless smoke for SOURCE `dsh` execution: run `apps/cli/src/bin.ts`
|
||||
* with the exact production runtime vector (`node --import tsx/esm`, the
|
||||
* vector the root `dsh` script invokes directly) and assert the
|
||||
* required-config diagnostic. The Node compatibility matrix runs this
|
||||
* WHOLE file, so a Node release changing module hooks or TypeScript handling
|
||||
* breaks this gate instead of every developer's `pnpm dsh`; the built-bin
|
||||
* suite covers the published `lib/` entry, not this source chain.
|
||||
* Keyless source-launch and profile-resolution checks through the production
|
||||
* tsx ESM-only entry. The Node compatibility matrix runs this file without a
|
||||
* build; source tool execution with native/generated dependencies belongs to
|
||||
* the build-backed source-tool suite.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshSourceBin = 'apps/cli/src/bin.ts'
|
||||
|
||||
describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
|
||||
testProfileResolution('src')
|
||||
|
||||
it('launches the source CLI without building', async () => {
|
||||
const rootPackage = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as {
|
||||
readonly scripts?: Record<string, string>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import {
|
||||
boot,
|
||||
initProfile,
|
||||
createProfileResolutionGeneration,
|
||||
createRuntimeResolution,
|
||||
loadOverlayPatches,
|
||||
loadProfile,
|
||||
PluginPackages,
|
||||
@@ -35,7 +35,7 @@ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
|
||||
const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
|
||||
const CODEX_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-codex')
|
||||
const CLAUDE_CODE_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-claude-code')
|
||||
/** The installation anchor whose dependency surface the preset module fallback mirrors. */
|
||||
/** The installation anchor whose dependency surface the runtime resolution mirrors. */
|
||||
const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
|
||||
const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.'
|
||||
const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell
|
||||
@@ -156,7 +156,7 @@ async function bootWeb(
|
||||
profile = loadProfile('dsh-test', 'spec', INSTALL_ANCHOR, home, { userLayer: false })
|
||||
bundlePatches = profile.layers.flatMap(layer => layer.patches)
|
||||
}
|
||||
const resolution = await createProfileResolutionGeneration({ installAnchor: INSTALL_ANCHOR, home, profile })
|
||||
const resolution = await createRuntimeResolution({ installAnchor: INSTALL_ANCHOR, home, profile })
|
||||
const rootConfig = join(profileDir, 'cordis.yml')
|
||||
await writeFile(rootConfig, '[]\n')
|
||||
return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], async (bootCtx) => {
|
||||
@@ -164,7 +164,7 @@ async function bootWeb(
|
||||
installAnchor: INSTALL_ANCHOR, home, cwd: home,
|
||||
startedBundles: profileBundles ?? ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
|
||||
overlays: overrides, telemetryDisabledEnv: '1' })
|
||||
await bootCtx.plugin(PluginPackages, { generation: resolution })
|
||||
await bootCtx.plugin(PluginPackages, { resolution })
|
||||
bootCtx.provide('connection', {
|
||||
fetch: { register: () => () => {} },
|
||||
rpc: { intercept: () => () => {} },
|
||||
|
||||
@@ -172,7 +172,7 @@ describe('dsh web authentication through the real CLI', () => {
|
||||
|
||||
const exchange = await fetch(first.launchUrl, { redirect: 'manual' })
|
||||
expect(exchange.status).toBe(303)
|
||||
expect(exchange.headers.get('location')).toBe('/')
|
||||
expect(exchange.headers.get('location')).toBe('./')
|
||||
const setCookie = exchange.headers.get('set-cookie')
|
||||
if (setCookie === null) throw new Error('real CLI token exchange omitted Set-Cookie')
|
||||
expect(setCookie).toContain('HttpOnly')
|
||||
|
||||
@@ -18,18 +18,17 @@ async function main(): Promise<void> {
|
||||
const application = runProfile({
|
||||
environment: loadLayeredEnv('dsh'),
|
||||
profile: 'desktop',
|
||||
resolutionMode: process.argv[5] === 'runtime' ? 'runtime' : 'link',
|
||||
resolvedProfile: { profile, installAnchor },
|
||||
patchFiles: [],
|
||||
args: ['--no-open', '--port', '19387'],
|
||||
...(process.argv[6] === undefined ? {} : {
|
||||
...(process.argv[5] === undefined ? {} : {
|
||||
packageManager: {
|
||||
command: process.execPath,
|
||||
args: ['--expose-internals', process.argv[6]],
|
||||
args: ['--expose-internals', process.argv[5]],
|
||||
env: {
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
DSH_DESKTOP_NODE_EXECUTABLE: process.execPath,
|
||||
PATH: `${process.argv[7] ?? ''}${delimiter}${process.env.PATH ?? ''}`,
|
||||
PATH: `${process.argv[6] ?? ''}${delimiter}${process.env.PATH ?? ''}`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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 apps/desktop/README.md
|
||||
README.md: eb185783e77422d229c476eccb2b2ea817b1fdef
|
||||
README.zh.md: 95f2c011af23d6dd16da32f8a0c58f8899b24619
|
||||
README.md: f1239fa4df7b25a11c70c5cd1665535d7cfed32e
|
||||
README.zh.md: 80654189dd03df514622e2710a806eeb40491fc5
|
||||
@@ -61,7 +61,7 @@ On macOS the custom application menu also declares the standard File, Window, an
|
||||
The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version, Electron's Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
|
||||
|
||||
1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files.
|
||||
2. Before production Host startup, Desktop removes profile copies and fallback links for packages listed by the current runtime or the recorded Desktop package set. It removes their dependency declarations and overrides, and discards the lockfile when cleanup changes package state. Other plugin files, configuration, and versions remain; development skips this cleanup and startup never runs pnpm.
|
||||
2. Before Host startup, Desktop validates the runtime descriptor and prepares the profile without modifying installed packages, declarations, or the lockfile; it removes only the `.dsh-module-fallback` projections earlier link-backend launches wrote, and startup never runs pnpm. Packages inside the profile keep native precedence, and installation package names resolve through the runtime resolution ([lookup order](../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md); [cleanup removal](../../.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.md)).
|
||||
3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
|
||||
4. The main application’s Plugins page uses the shared [plugin manager](../../packages/boot/plugin-manager/README.md) against the Desktop profile. Package operations use bundled pnpm with normal user and profile configuration.
|
||||
5. The shared manager owns installation errors, activation, and restart requirements. Native recovery can disable third-party bundles even when the Host cannot start.
|
||||
|
||||
@@ -61,7 +61,7 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜
|
||||
签名资源中的 `resources/app.asar/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
|
||||
|
||||
1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。
|
||||
2. 生产版在启动 Host 前,清理当前运行包清单或已记录 Desktop 包清单中各包的 profile 副本和回退链接,同时删除对应依赖声明与 overrides;清理改变包状态时丢弃锁文件。其他插件文件、配置和版本保留;开发模式跳过此清理,启动时不运行 pnpm。
|
||||
2. 启动 Host 前,Desktop 校验运行时描述符并准备 profile,不改动已安装的包、依赖声明与锁文件;只删除早期 Link 后端启动写下的 `.dsh-module-fallback` 投影,启动从不运行 pnpm。profile 内的包保持原生优先级,本体包名通过 runtime resolution 解析([查找顺序](../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md);[清理移除](../../.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.zh.md))。
|
||||
3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。
|
||||
4. 主应用的“插件”页面通过共享[插件管理器](../../packages/boot/plugin-manager/README.zh.md)操作 Desktop profile。包操作使用内置 pnpm 及正常的用户和 profile 配置。
|
||||
5. 共享管理器负责安装错误、激活和重启要求。即使 Host 无法启动,原生恢复仍可禁用第三方 bundle。
|
||||
@@ -74,7 +74,6 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜
|
||||
|
||||
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
|
||||
|
||||
包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function smokeDesktopRuntime(
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-'))
|
||||
const profile = join(home, 'profiles', 'desktop')
|
||||
const host = new DesktopHostProcess(node, root, profile, undefined, { ...environment, DSH_HOME: home },
|
||||
undefined, join(resourcesRuntime, 'primary-runtime'), 'runtime',
|
||||
undefined, join(resourcesRuntime, 'primary-runtime'),
|
||||
{ pnpm: join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.cjs'), nodeBin: join(resourcesRuntime, 'bin') })
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
|
||||
@@ -91,7 +91,6 @@ export class DesktopHostProcess {
|
||||
* @param primaryRuntime - Optional bundled dependency payload; when supplied, missing sibling
|
||||
* `office-skills` resources fail Host startup.
|
||||
* @param packageManager - Bundled pnpm entry and Node launcher directory, scoped to package operations.
|
||||
* @param profileResolution - Package resolution mode for the application-owned profile.
|
||||
*/
|
||||
constructor(
|
||||
private readonly node: string,
|
||||
@@ -101,7 +100,6 @@ export class DesktopHostProcess {
|
||||
private readonly environment: NodeJS.ProcessEnv = process.env,
|
||||
private readonly onFailure?: (error: Error) => void,
|
||||
private readonly primaryRuntime?: string,
|
||||
private readonly profileResolution: 'link' | 'runtime' = 'link',
|
||||
private readonly packageManager?: { readonly pnpm: string; readonly nodeBin: string },
|
||||
) {}
|
||||
|
||||
@@ -119,7 +117,6 @@ export class DesktopHostProcess {
|
||||
this.runtimeDir,
|
||||
this.projectDir,
|
||||
this.primaryRuntime ?? join(this.runtimeDir, '..', 'runtime', 'primary-runtime'),
|
||||
this.profileResolution,
|
||||
...this.packageManager === undefined ? [] : [this.packageManager.pnpm, this.packageManager.nodeBin],
|
||||
], {
|
||||
cwd: this.projectDir,
|
||||
|
||||
@@ -11,6 +11,7 @@ export const DESKTOP_IPC = {
|
||||
updatesOpen: 'dsh-desktop:updates-open',
|
||||
updatesPresentation: 'dsh-desktop:updates-presentation',
|
||||
nativeThemeSet: 'dsh-desktop:native-theme-set',
|
||||
windowFullscreen: 'dsh-desktop:window-fullscreen',
|
||||
windowsAppearance: 'dsh-desktop:windows-appearance',
|
||||
windowsMenu: 'dsh-desktop:windows-menu',
|
||||
} as const
|
||||
|
||||
@@ -111,8 +111,8 @@ function developmentHostInspectPort(enabled: boolean): number | undefined {
|
||||
function createWindow(preload: string, show = false, primary = false): BrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 840,
|
||||
minWidth: 880,
|
||||
height: 820,
|
||||
minWidth: 520,
|
||||
minHeight: 600,
|
||||
show,
|
||||
...(process.platform === 'win32' && primary ? {
|
||||
@@ -143,6 +143,18 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
|
||||
if (['http:', 'https:'].includes(new URL(url).protocol)) void shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
if (process.platform === 'darwin') {
|
||||
// macOS hides the traffic lights in fullscreen; the page drops its
|
||||
// clearance for them off the html[data-fullscreen] flag this feeds.
|
||||
const sendFullscreen = (): void => {
|
||||
if (!window.isDestroyed()) window.webContents.send(DESKTOP_IPC.windowFullscreen, window.isFullScreen())
|
||||
}
|
||||
window.on('enter-full-screen', sendFullscreen)
|
||||
window.on('leave-full-screen', sendFullscreen)
|
||||
// Reloads and navigations re-register the preload listener; resend the
|
||||
// current state so a fullscreen reload does not fall back to windowed CSS.
|
||||
window.webContents.on('did-finish-load', sendFullscreen)
|
||||
}
|
||||
window.webContents.on('context-menu', (_event, { isEditable, selectionText, editFlags }) => {
|
||||
const items: MenuItemConstructorOptions[] = []
|
||||
if (isEditable) {
|
||||
@@ -251,7 +263,7 @@ async function main(): Promise<void> {
|
||||
hostInspectPort, process.env, onFailure,
|
||||
development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime')
|
||||
: join(process.resourcesPath, 'runtime', 'primary-runtime'),
|
||||
development ? 'link' : 'runtime', resources)
|
||||
resources)
|
||||
return {
|
||||
start: async () => {
|
||||
const ready = await host.start()
|
||||
@@ -326,7 +338,7 @@ async function main(): Promise<void> {
|
||||
startup ??= (async () => {
|
||||
await navigateMain(applicationUrl)
|
||||
await backend.start(async () => {
|
||||
await manager.applyRelease(app.isPackaged)
|
||||
await manager.applyRelease()
|
||||
})
|
||||
if (backend.host !== undefined) updateJournal?.action('workspace-ready')
|
||||
// The existing Web document resumes through the boot IPC response.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts'
|
||||
import { markDocumentPlatform } from './preload-platform.ts'
|
||||
import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.ts'
|
||||
import { syncNativeTheme } from './preload-theme.ts'
|
||||
import { syncWindowsAppearance } from './preload-windows.ts'
|
||||
|
||||
@@ -31,6 +31,7 @@ if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
|
||||
}
|
||||
|
||||
markDocumentPlatform()
|
||||
syncWindowFullscreen()
|
||||
syncNativeTheme()
|
||||
// Main-process IPC also verifies the owning window and top frame.
|
||||
contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? product : { protocolVersion: 1 })
|
||||
@@ -1,5 +1,8 @@
|
||||
/** Marks the document root with the host platform so shared Web UI CSS can scope desktop-only rules. */
|
||||
|
||||
import { ipcRenderer } from 'electron'
|
||||
import { DESKTOP_IPC } from './ipc.ts'
|
||||
|
||||
/**
|
||||
* Sets `data-platform` (e.g. `darwin`) on `<html>`, deferring to DOMContentLoaded
|
||||
* when the preload runs before the document root exists.
|
||||
@@ -12,3 +15,18 @@ export function markDocumentPlatform(): void {
|
||||
if (root === null) window.addEventListener('DOMContentLoaded', mark)
|
||||
else mark()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the window's macOS fullscreen state onto `<html data-fullscreen>` so
|
||||
* CSS drops the traffic-light clearance while the lights are hidden. The main
|
||||
* process sends the state on every transition and after each load.
|
||||
*/
|
||||
export function syncWindowFullscreen(): void {
|
||||
if (process.platform !== 'darwin') return
|
||||
ipcRenderer.on(DESKTOP_IPC.windowFullscreen, (_event, fullscreen: boolean) => {
|
||||
const root = document.documentElement as HTMLElement | null
|
||||
if (root === null) return
|
||||
if (fullscreen) root.dataset.fullscreen = 'true'
|
||||
else delete root.dataset.fullscreen
|
||||
})
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/** Remove application-owned packages left in external plugin profiles before production boot. */
|
||||
|
||||
import { existsSync, lstatSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { dump, load } from 'js-yaml'
|
||||
import { DESKTOP_PACKAGE_SET_FILE, readDesktopCorePackageSet } from './core-package-set.ts'
|
||||
|
||||
/** Enables the temporary production-profile cleanup; remove this module and its caller together. */
|
||||
const CLEAN_PROFILE_CORE_PACKAGES = true
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error('desktop profile cleanup: expected an object')
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function stat(path: string): ReturnType<typeof lstatSync> | undefined {
|
||||
try { return lstatSync(path) } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function remove(path: string): boolean {
|
||||
const entry = stat(path)
|
||||
if (entry === undefined) return false
|
||||
if (entry.isSymbolicLink()) unlinkSync(path)
|
||||
else rmSync(path, { recursive: entry.isDirectory() })
|
||||
return true
|
||||
}
|
||||
|
||||
function requireDirectory(path: string): void {
|
||||
const entry = stat(path)
|
||||
if (entry !== undefined && (!entry.isDirectory() || entry.isSymbolicLink())) {
|
||||
throw new Error(`desktop profile cleanup: package parent is not a real directory: ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
function prune(value: Record<string, unknown>, field: string, names: ReadonlySet<string>): boolean {
|
||||
if (value[field] === undefined) return false
|
||||
const entries = object(value[field])
|
||||
let changed = false
|
||||
for (const name of names) {
|
||||
if (!Object.hasOwn(entries, name)) continue
|
||||
Reflect.deleteProperty(entries, name)
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete current and recorded Desktop core packages, their declarations, and stale lockfile resolutions.
|
||||
* Managed links are unlinked without touching their targets; unrelated plugins and profile configuration survive.
|
||||
* The caller holds the profile lock and must not have started the Host. Development launches do nothing.
|
||||
* @param profile - Absolute Desktop profile directory.
|
||||
* @param packageNames - Package names from the verified application runtime descriptor.
|
||||
* @param production - Whether this launch uses the packaged application.
|
||||
*/
|
||||
export function cleanProfileCorePackages(profile: string, packageNames: readonly string[], production: boolean): void {
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- release cleanup has an explicit source switch.
|
||||
if (!CLEAN_PROFILE_CORE_PACKAGES || !production || !existsSync(profile)) return
|
||||
const names = new Set(packageNames)
|
||||
const recordPath = join(profile, DESKTOP_PACKAGE_SET_FILE)
|
||||
if (existsSync(recordPath)) {
|
||||
for (const entry of readDesktopCorePackageSet(profile).packages) names.add(entry.name)
|
||||
}
|
||||
const roots = [join(profile, 'node_modules'), join(profile, '.dsh-module-fallback', 'node_modules')]
|
||||
requireDirectory(join(profile, '.dsh-module-fallback'))
|
||||
for (const root of roots) {
|
||||
requireDirectory(root)
|
||||
for (const name of names) requireDirectory(dirname(join(root, name)))
|
||||
}
|
||||
const manifestPath = join(profile, 'package.json')
|
||||
const manifest = existsSync(manifestPath) ? object(JSON.parse(readFileSync(manifestPath, 'utf8'))) : undefined
|
||||
const workspacePath = join(profile, 'pnpm-workspace.yaml')
|
||||
const workspace = existsSync(workspacePath) ? object(load(readFileSync(workspacePath, 'utf8'))) : undefined
|
||||
let manifestChanged = false
|
||||
if (manifest !== undefined) {
|
||||
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
|
||||
manifestChanged = prune(manifest, field, names) || manifestChanged
|
||||
}
|
||||
if (manifest.pnpm !== undefined) manifestChanged = prune(object(manifest.pnpm), 'overrides', names) || manifestChanged
|
||||
}
|
||||
const workspaceChanged = workspace !== undefined && prune(workspace, 'overrides', names)
|
||||
const packageResidue = roots.some(root => [...names].some(name => stat(join(root, name)) !== undefined))
|
||||
if (manifestChanged || workspaceChanged || packageResidue) remove(join(profile, 'pnpm-lock.yaml'))
|
||||
if (manifestChanged) writeFileSync(manifestPath, `${JSON.stringify(manifest, undefined, 2)}\n`)
|
||||
if (workspaceChanged) writeFileSync(workspacePath, dump(workspace))
|
||||
for (const root of roots) for (const name of names) remove(join(root, name))
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/** One-time removal of package links recorded by Desktop profile management. */
|
||||
|
||||
import { lstatSync, readFileSync, readlinkSync, unlinkSync } from 'node:fs'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
|
||||
/** Legacy Desktop package-link ownership file. */
|
||||
export const DESKTOP_PROFILE_STATE = 'desktop-runtime-state.json'
|
||||
|
||||
interface DesktopPackageLink {
|
||||
readonly name: string
|
||||
readonly target: string
|
||||
}
|
||||
|
||||
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function stat(path: string): ReturnType<typeof lstatSync> | undefined {
|
||||
try { return lstatSync(path) } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove recorded links still owned by Desktop and delete their legacy state file.
|
||||
* Installed directories, changed links, and links beneath redirected package directories are retained.
|
||||
* @param profile - Desktop profile directory.
|
||||
*/
|
||||
export function migrateDesktopProfileLinks(profile: string): void {
|
||||
const statePath = join(profile, DESKTOP_PROFILE_STATE)
|
||||
if (stat(statePath) === undefined) return
|
||||
const value: unknown = JSON.parse(readFileSync(statePath, 'utf8'))
|
||||
if (!record(value) || !Array.isArray(value.links)) throw new Error('desktop profile: invalid legacy package links')
|
||||
const links = value.links.map((link: unknown): DesktopPackageLink => {
|
||||
if (!record(link) || typeof link.name !== 'string' || !PACKAGE_NAME.test(link.name)
|
||||
|| typeof link.target !== 'string' || !isAbsolute(link.target)) {
|
||||
throw new Error('desktop profile: invalid legacy package link')
|
||||
}
|
||||
return { name: link.name, target: link.target }
|
||||
})
|
||||
const modules = join(profile, 'node_modules')
|
||||
if (stat(modules)?.isDirectory() === true) {
|
||||
for (const link of links) {
|
||||
const path = join(modules, link.name)
|
||||
if (dirname(path) !== modules && stat(dirname(path))?.isDirectory() !== true) continue
|
||||
if (stat(path)?.isSymbolicLink() === true
|
||||
&& resolve(dirname(path), readlinkSync(path)) === resolve(link.target)) {
|
||||
unlinkSync(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
unlinkSync(statePath)
|
||||
}
|
||||
@@ -23,10 +23,8 @@ import type { DesktopPaths } from './paths.ts'
|
||||
import type { DesktopRelease } from './release.ts'
|
||||
import { readDesktopRuntime } from './runtime-tree.ts'
|
||||
import {
|
||||
initProfile, PROFILE_TEMPLATES, sanitizeProfile, type ProfileTemplate,
|
||||
initProfile, PROFILE_TEMPLATES, removeLinkProjections, sanitizeProfile, type ProfileTemplate,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { migrateDesktopProfileLinks } from './profile-packages.ts'
|
||||
import { cleanProfileCorePackages } from './profile-core-cleanup.ts'
|
||||
|
||||
const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime'
|
||||
const DSH_PACKAGE = '@deepseek-ai/dsh'
|
||||
@@ -81,15 +79,14 @@ export class DesktopProjectManager {
|
||||
|
||||
/**
|
||||
* Load application metadata and prepare the external plugin profile without installing packages.
|
||||
* @param production - Remove application-owned profile packages before packaged Host startup.
|
||||
*/
|
||||
async applyRelease(production = false): Promise<void> {
|
||||
async applyRelease(): Promise<void> {
|
||||
await this.withLock(() => {
|
||||
const descriptor = readDesktopRuntime(this.runtime.dsh)
|
||||
cleanProfileCorePackages(this.paths.profile, descriptor.sharedPackages.map(entry => entry.name), production)
|
||||
// Validation only: an unreadable or mismatched runtime descriptor stops preparation before the Host starts.
|
||||
readDesktopRuntime(this.runtime.dsh)
|
||||
migrateProfileSettings(this.paths.profile)
|
||||
migrateDesktopProfileLinks(this.paths.profile)
|
||||
createPluginProfile(this.paths.profile)
|
||||
removeLinkProjections(this.paths.profile)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ export class DesktopHostProcess extends Host {
|
||||
fixture.taskQueries.push({ at: Date.now(), action, active: result })
|
||||
return result
|
||||
}
|
||||
constructor(node, runtime, project, _inspectPort, environment, onFailure) {
|
||||
constructor(node, runtime, project, _inspectPort, environment, onFailure, primaryRuntime, packageManager) {
|
||||
// Port zero lets the OS allocate the development inspector; no fixed listener is acquired.
|
||||
super(node, runtime, project, 0, environment, onFailure)
|
||||
super(node, runtime, project, 0, environment, onFailure, primaryRuntime, packageManager)
|
||||
fixture.host = this
|
||||
}
|
||||
async start() {
|
||||
|
||||
@@ -110,15 +110,15 @@ describe('desktop host process', () => {
|
||||
expect(failure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes external dependencies and runtime profile resolution to the Host', async () => {
|
||||
it('passes external dependencies and package-manager paths to the Host', async () => {
|
||||
const runtime = projectWithHost(HTTP_HOST.replace('runtime: process.argv[2]',
|
||||
'pnpm: process.argv[6], nodeBin: process.argv[7], primaryRuntime: process.argv[4], profileResolution: process.argv[5], runtime: process.argv[2]'))
|
||||
'pnpm: process.argv[5], nodeBin: process.argv[6], primaryRuntime: process.argv[4], runtime: process.argv[2]'))
|
||||
const primaryRuntime = join(runtime, 'external-primary-runtime')
|
||||
const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env,
|
||||
undefined, primaryRuntime, 'runtime', { pnpm: join(runtime, 'pnpm.mjs'), nodeBin: join(runtime, 'bin') })
|
||||
undefined, primaryRuntime, { pnpm: join(runtime, 'pnpm.mjs'), nodeBin: join(runtime, 'bin') })
|
||||
hosts.push(host)
|
||||
const { url } = await host.start()
|
||||
expect(await (await fetch(url)).json()).toMatchObject({ primaryRuntime, profileResolution: 'runtime', pnpm: join(runtime, 'pnpm.mjs'), nodeBin: join(runtime, 'bin') })
|
||||
expect(await (await fetch(url)).json()).toMatchObject({ primaryRuntime, pnpm: join(runtime, 'pnpm.mjs'), nodeBin: join(runtime, 'bin') })
|
||||
})
|
||||
|
||||
it('reports a fatal event after readiness once', async () => {
|
||||
|
||||
@@ -74,6 +74,8 @@ const harness = await vi.hoisted(async () => {
|
||||
super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this); if (options.modal) policyBlocked.resolve()
|
||||
}
|
||||
isDestroyed() { return this.destroyed }
|
||||
fullscreen = false
|
||||
isFullScreen() { return this.fullscreen }
|
||||
isMinimized() { return false }
|
||||
isFocused() { return true }
|
||||
async loadURL(url: string) {
|
||||
@@ -108,7 +110,7 @@ const harness = await vi.hoisted(async () => {
|
||||
constructor(
|
||||
readonly node: string, readonly runtime: string, readonly profile: string,
|
||||
readonly inspectPort?: number, readonly environment?: NodeJS.ProcessEnv, readonly onFailure?: (error: Error) => void,
|
||||
readonly primaryRuntime?: string, readonly profileResolution?: string,
|
||||
readonly primaryRuntime?: string,
|
||||
readonly packageManager?: { pnpm: string; nodeBin: string },
|
||||
) { hosts.push(this) }
|
||||
}
|
||||
@@ -518,6 +520,39 @@ describe('desktop main startup', () => {
|
||||
expect(harness.hosts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('relays the macOS fullscreen state on transitions and after each load', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
const window = harness.windows[0]!
|
||||
const sent = () => window.webContents.send.mock.calls.filter(([channel]) => channel === DESKTOP_IPC.windowFullscreen)
|
||||
expect(sent()).toHaveLength(0)
|
||||
window.fullscreen = true
|
||||
window.emit('enter-full-screen')
|
||||
expect(sent().at(-1)).toEqual([DESKTOP_IPC.windowFullscreen, true])
|
||||
// A reload re-registers the preload listener; the finished load resends
|
||||
// the current state so fullscreen CSS survives the reload.
|
||||
window.webContents.emit('did-finish-load')
|
||||
expect(sent().at(-1)).toEqual([DESKTOP_IPC.windowFullscreen, true])
|
||||
window.fullscreen = false
|
||||
window.emit('leave-full-screen')
|
||||
expect(sent().at(-1)).toEqual([DESKTOP_IPC.windowFullscreen, false])
|
||||
const relayed = sent().length
|
||||
window.destroyed = true
|
||||
window.emit('enter-full-screen')
|
||||
expect(sent()).toHaveLength(relayed)
|
||||
})
|
||||
|
||||
it.each(['win32', 'linux'] as const)('registers no fullscreen relay on %s', async (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
const window = harness.windows[0]!
|
||||
window.emit('enter-full-screen')
|
||||
window.webContents.emit('did-finish-load')
|
||||
expect(window.webContents.send.mock.calls.filter(([channel]) => channel === DESKTOP_IPC.windowFullscreen)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('follows the Windows primary document language and palette without trusting other frames', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
await import('../src/main.ts')
|
||||
@@ -1322,7 +1357,6 @@ describe('desktop main startup', () => {
|
||||
node: process.execPath,
|
||||
runtime: join(harness.app.getAppPath(), 'dsh'),
|
||||
primaryRuntime: join('desktop-test-resources', 'runtime', 'primary-runtime'),
|
||||
profileResolution: 'runtime',
|
||||
profile: 'desktop-test-profile',
|
||||
})
|
||||
expect(harness.hosts[0]!.environment).toBe(process.env)
|
||||
|
||||
@@ -47,7 +47,20 @@ describe('packaging run records', () => {
|
||||
cwd: root, env: environment,
|
||||
})).rejects.toThrow('fatal failed')
|
||||
const descendant = JSON.parse(await readFile(join(run.directory, 'descendant.json'), 'utf8')) as { pid: number }
|
||||
expect(() => process.kill(descendant.pid, 0)).toThrow()
|
||||
// Linux can retain a dead descendant as a zombie until its new parent reaps it.
|
||||
await expect.poll(async () => {
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
const status = await readFile(`/proc/${descendant.pid}/status`, 'utf8')
|
||||
return /^State:\s+[ZXx]\b/m.test(status)
|
||||
}
|
||||
process.kill(descendant.pid, 0)
|
||||
return false
|
||||
} catch (error) {
|
||||
if (['ENOENT', 'ESRCH'].includes((error as NodeJS.ErrnoException).code ?? '')) return true
|
||||
throw error
|
||||
}
|
||||
}, { timeout: 5_000 }).toBe(true)
|
||||
const events = (await readFile(join(run.directory, 'events.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { type: string; fatalObserved?: boolean; terminationError?: boolean })
|
||||
expect(events.at(-1)).toMatchObject({ type: 'stage-end', fatalObserved: true, terminationError: false })
|
||||
run.finish(false)
|
||||
|
||||
@@ -7,7 +7,7 @@ const electron = vi.hoisted(() => ({
|
||||
ipcRenderer: { invoke: vi.fn(), on: vi.fn(), off: vi.fn(), send: vi.fn() },
|
||||
}))
|
||||
vi.mock('electron', () => electron)
|
||||
vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() }))
|
||||
vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() }))
|
||||
vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
|
||||
vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { DESKTOP_IPC } from '../src/ipc.ts'
|
||||
import { syncWindowFullscreen } from '../src/preload-platform.ts'
|
||||
|
||||
const on = vi.hoisted(() => vi.fn())
|
||||
vi.mock('electron', () => ({ ipcRenderer: { on } }))
|
||||
|
||||
afterEach(() => {
|
||||
delete document.documentElement.dataset.fullscreen
|
||||
vi.restoreAllMocks()
|
||||
on.mockClear()
|
||||
})
|
||||
|
||||
it.each(['win32', 'linux'] as const)('subscribes no fullscreen channel on %s', (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
syncWindowFullscreen()
|
||||
expect(on).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mirrors the sent fullscreen state onto html[data-fullscreen] on darwin', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
syncWindowFullscreen()
|
||||
expect(on).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.windowFullscreen, expect.any(Function))
|
||||
const handler = on.mock.calls[0]?.[1] as (event: unknown, fullscreen: boolean) => void
|
||||
handler({}, true)
|
||||
expect(document.documentElement.dataset.fullscreen).toBe('true')
|
||||
handler({}, false)
|
||||
expect(document.documentElement.dataset.fullscreen).toBeUndefined()
|
||||
// A repeated windowed report stays a no-op rather than toggling the flag.
|
||||
handler({}, false)
|
||||
expect(document.documentElement.dataset.fullscreen).toBeUndefined()
|
||||
})
|
||||
Loaded 100 of 690 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user