diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml new file mode 100644 index 0000000000..f3e123135e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml @@ -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-shared-office-runtime.md +2026-09-17-shared-office-runtime.md: 25b6ddccbb5b17b93bec2c0b024dfcb63e94abc3 +2026-09-17-shared-office-runtime.zh.md: 99dffe8d0233e60480689ff983b6261cbb83bb7b diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md new file mode 100644 index 0000000000..25b6ddccbb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md @@ -0,0 +1,37 @@ +# Agent Note: Shared Office runtime for Desktop and SDK + +Status: implemented + +English | [中文](2026-09-17-shared-office-runtime.zh.md) + +## Problem + +SDK deployments need the same Office authoring libraries as Desktop while keeping interpreter payloads in read-only container image layers. A Desktop-owned query and builder require downstream carriers to duplicate dependency locks and path conventions. + +## Decision + +[`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.md) owns manifest validation, interpreter paths, installation and the model query. It stays in the skill group beside its Office workflow consumer: the payload supplies those workflows, while the package exposes no workspace entity service. Desktop imports this package and retains copy-on-first-use installation. The packaged SDK profile enables the query and Office skills from its carrier default; `DSH_PRIMARY_RUNTIME` overrides that path, including an empty-string opt-out. It reads the payload in place. The common base and `sdk-minimal` do not enable Office implicitly. + +The [shared build entry](../../../../scripts/primary-runtime/prepare.ts) owns the download lock, extraction and native smoke checks for Desktop targets and GNU/Linux x64/ARM64. Desktop supplies output paths and its release version, requires Node.js and pnpm, and applies its signing-specific subprocess environment. Other carriers choose their output directory and may build a Python-only payload. The existing `desktopVersion` manifest field retains its name and records the carrier release; `payloadDigest` distinguishes locked inputs and component selection. + +The [Desktop primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own Desktop installation and platform signing. This decision replaces only its Desktop-only builder and query placement; both records remain active. + +`runtime.json` stores Python, Node.js and pnpm versions at the top level and all Python distribution versions in `pythonPackages`. Build metadata does not single out numpy or pandas. The shared parser accepts legacy `components` files without rewriting them, retains their consistency checks, and returns only the canonical flat fields. Mixed formats are rejected. The build digest includes the assembly format so changed manifest bytes invalidate payload reuse. + +The Python runtime wheel carries target-native CPython and Office libraries plus external skills in a sibling `-/` resource directory. Short platform directory names leave room for Python extension and dependency DLL paths after installation on Windows. Bundling supplies defaults; the skill registry still lets project, custom and user skills override same-name bundled entries, and profile patches can disable or replace Office skills without replacing Python. This retains configurable workflows while eliminating a separate environment installation step. Real filesystem resources let Python load native extensions and skills invoke their shared checker without startup extraction. The wheel grows by the compressed payload size; public-index size limits remain enforced by the release workflow. + +## Alternatives considered + +**Keep implementation in Desktop.** This leaves SDK and container builders depending on application packaging and signing code despite needing only interpreters, Office resources and a query. + +**Enable Office in the common base.** A base deployment has no guaranteed bundled interpreter or resources. Explicit SDK carrier configuration makes that requirement observable without changing unrelated profiles. + +**Always copy to the Harness home.** Copying duplicates image-layer content, requires a writable destination and cannot serve the intended immutable carrier deployment. + +**One component field per Python library.** This duplicates the distribution map and requires schema changes for each library. A complete map records the locked package set independently of the interpreter and package-manager fields. + +## Consequences + +Successful tool preparation is shared by concurrent calls for one plugin lifetime. Failed preparation is retryable; disposal removes registration and waits for outstanding filesystem work. Copy mode verifies a staged replacement and restores the preceding installation after a failed publication. In-place mode performs no payload writes. Paths are absolute, and runtime metadata and declared entries are checked before results are returned. + +SDK configuration changes require restart. Missing Office resources produce a startup warning and leave the optional skill provider inactive; runtime metadata and interpreter availability are validated on the first tool call. musl builds remain outside the lock. Cross-target assembly verifies archive hashes but requires execution on the target host; the Linux artifact CI lane owns native Linux checks. Unit coverage pins manifest, installation and lifecycle behavior; SDK profile tests observe optional tools, skill discovery and results, and the recorded workspace-dependencies scenario pins model-visible validation errors. diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md new file mode 100644 index 0000000000..99dffe8d02 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Desktop 与 SDK 共享 Office 运行时 + +Status: implemented + +[English](2026-09-17-shared-office-runtime.md) | 中文 + +## 问题 + +SDK 部署需要与 Desktop 相同的 Office 创作库,同时将解释器 payload 保存在只读容器镜像层中。由 Desktop 持有查询工具和构建器,会要求下游载体重复维护依赖锁与路径约定。 + +## 决策 + +[`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.zh.md) 持有清单校验、解释器路径、安装逻辑和模型查询。它保留在 skill 组中,与 Office 工作流消费者相邻:payload 服务于这些工作流,本包不提供 workspace 实体服务。Desktop 引用本包并保留首次使用时复制安装。打包的 SDK profile 通过载体默认路径启用查询和 Office skills;`DSH_PRIMARY_RUNTIME` 覆盖该路径,空字符串表示禁用。它原位读取 payload。公共 base 与 `sdk-minimal` 不会隐式启用 Office。 + +[共享构建入口](../../../../scripts/primary-runtime/prepare.ts) 持有 Desktop 目标与 GNU/Linux x64/ARM64 的下载锁、解压和本机冒烟检查。Desktop 提供输出路径和发布版本,要求包含 Node.js 与 pnpm,并应用签名专用子进程环境。其他载体选择自己的输出目录,也可构建 Python-only payload。现有 `desktopVersion` 清单字段保留名字并记录载体发布版本;`payloadDigest` 区分锁定输入与组件选择。 + +[Desktop 第一方运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md)继续持有 Desktop 安装与平台签名规则。本决策仅替代其中由 Desktop 独占构建器和查询实现的部分;两份记录均保持有效。 + +`runtime.json` 在顶层保存 Python、Node.js 和 pnpm 版本,并在 `pythonPackages` 中保存全部 Python 分发包版本。构建元数据不单独列出 numpy 或 pandas。共享解析器接受旧 `components` 文件而不改写它们,保留旧格式的一致性校验,并仅返回统一的扁平字段。混合格式会被拒绝。构建摘要包含组装格式,因此清单字节变化会使旧产物身份失效。 + +Python runtime wheel 将目标平台的 CPython、Office 库和外部 skills 放在可执行文件同级的 `-/` 资源目录中。较短的平台目录名为 Windows 安装后的 Python 扩展与依赖 DLL 路径保留长度空间。打包提供默认资源;skill 注册表仍允许项目、自定义目录和用户 skills 覆盖同名随包条目,profile patch 可在保留 Python 的同时禁用或替换 Office skills。这样既保留工作流配置能力,又省去单独安装环境的步骤。真实文件系统资源允许 Python 加载原生扩展,并让 skills 调用共用检查脚本,无需启动时解包。wheel 体积增加一个压缩 payload;发布流程仍执行公开索引的大小限制检查。 + +## 考虑过的替代方案 + +**将实现保留在 Desktop。** SDK 和容器构建器只需要解释器、Office 资源和查询,却仍会依赖应用打包及签名代码。 + +**在公共 base 中启用 Office。** base 部署不保证提供内置解释器或资源。显式 SDK 载体配置可以让该要求可见,并保持其他 profile 的行为。 + +**总是复制到 Harness home。** 复制会重复镜像层内容、要求目标可写,也无法满足预期的不可变载体部署。 + +**为每个 Python 库增加组件字段。** 这会重复分发包版本表,并使新增库要求修改 schema。完整版本表将锁定包集合与解释器、包管理器字段分开记录。 + +## 影响 + +同一插件生命周期内的并发调用共享成功的工具准备结果。准备失败后可重试;卸载移除工具注册并等待未完成的文件系统工作。复制模式验证暂存替换内容,并在发布失败后恢复原安装。原位模式不写入 payload。路径为绝对路径,返回结果前会检查运行时元数据和所声明的条目。 + +SDK 配置变更需要重启。缺少 Office 资源会产生启动警告,并使可选的 skill 提供方保持未激活;运行时元数据和解释器是否存在在首次工具调用时校验。musl 构建仍不在锁文件范围内。跨目标组装校验归档哈希,但需要在目标主机执行;Linux 产物 CI 负责本机 Linux 检查。单元覆盖固定清单、安装与生命周期行为;SDK profile 测试观察可选工具、skill 发现与结果,workspace-dependencies 录制场景固定模型可见的校验错误。 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml index 50233385bf..27bbadb4ae 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md -2026-09-14-desktop-primary-runtime.md: 51bbaac89c5c2ec9a3a016030e7b320d2b5566b8 -2026-09-14-desktop-primary-runtime.zh.md: 4f27d61b5e713f66e2f478894dbd547cd7ecfff4 +2026-09-14-desktop-primary-runtime.md: a1491ca0c47e40c6d5b885c9f6a42ccf8180b78c +2026-09-14-desktop-primary-runtime.zh.md: 6447086a6948ed262699b62061eb44af8d6071d7 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md index 51bbaac89c..a1491ca0c4 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md @@ -12,9 +12,9 @@ Desktop agents need predictable Python data-processing libraries and an independ Desktop ships Python, Node.js, pnpm, data-processing libraries and Office authoring libraries as one release-bound payload. The path-query tool installs the payload from application resources into the fixed Harness-home directory and returns absolute paths and the bundled Python distribution versions. The version report excludes packages added by users. It does not change PATH, environment variables or package-manager configuration. pnpm uses its native global-install rules. -The application version, component versions, Python distribution versions and locked-input digest live in `runtime.json`, not the directory name. The digest covers the selected target's interpreter and wheel archives, shared wheel inputs, Python distribution versions, pnpm version and assembly format; other targets do not invalidate it. Key order within the selected target, wheel records and distribution map, plus wheel-entry order, participates in this identity; top-level lock key order does not. Extraction or assembly changes that alter payload bytes without changing locked inputs require an explicit format bump. Installation publishes a completed staged copy and retains the previous directory until replacement succeeds. Matching payloads reuse installed files; replacements also replace user-added Python dependencies inside the managed tree. Older manifests without a digest remain readable and differ from the current payload. Distribution names use PEP 503 normalization; duplicate normalized names are rejected, and present numpy/pandas distribution versions must agree with their component versions. The Desktop single-instance owner and the tool's shared installation promise serialize normal installation requests. +The application version, top-level interpreter/package-manager versions, Python distribution map and locked-input digest live in `runtime.json`, not the directory name. The digest covers the selected target's interpreter and wheel archives, shared wheel inputs, Python distribution versions, pnpm version and assembly format; other targets do not invalidate it. Key order within the selected target, wheel records and distribution map, plus wheel-entry order, participates in this identity; top-level lock key order does not. Extraction or assembly changes that alter payload bytes without changing locked inputs require an explicit format bump. Installation publishes a completed staged copy and retains the previous directory until replacement succeeds. Matching payloads reuse installed files; replacements also replace user-added Python dependencies inside the managed tree. Older manifests without a digest remain readable and differ from the current payload. Distribution names use PEP 503 normalization; duplicate normalized names are rejected. The reader normalizes legacy `components` files and retains their numpy/pandas consistency checks; current manifests carry Python libraries only in the distribution map. The Desktop single-instance owner and the tool's shared installation promise serialize normal installation requests. -Node downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload. +The shared Node build entry downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload. macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior. @@ -22,6 +22,8 @@ Windows signed packaging separates materialization from execution with supervise Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes. +The [shared-runtime decision](../architecture/2026-09-17-shared-office-runtime.md) owns builder, lock and query-package placement for Desktop and SDK carriers, including GNU/Linux x64 and Python-only payloads. Desktop retains the installation and signing behavior described here and requires the full Python, Node.js and pnpm payload. + ## Alternatives considered **System interpreters only.** They do not provide predictable availability or preinstalled numpy and pandas. diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md index 4f27d61b5e..6447086a69 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md @@ -12,9 +12,9 @@ Desktop 代理需要在没有开发环境的机器上获得确定的 Python 数 Desktop 将 Python、Node.js、pnpm、数据处理库和 Office 创作库作为绑定应用版本的产物交付。路径查询工具从应用资源将产物安装到 Harness home 下的固定目录,并返回绝对路径与内置 Python 分发包版本。版本报告不包含用户自行添加的包。它不修改 PATH、环境变量或包管理器配置。pnpm 使用原生全局安装规则。 -应用版本、组件版本、Python 分发包版本和锁定输入摘要记录在 `runtime.json` 中,不放在目录名里。摘要涵盖所选目标的解释器与 wheel 压缩包、共享 wheel 输入、Python 分发包版本、pnpm 版本和组装格式;其他目标不会使其失效。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序参与该身份计算;锁文件顶层键的顺序不参与。解压或组装逻辑在锁定输入不变时改变产物字节,必须显式提升格式版本。安装发布完整的暂存副本,并在替换成功前保留之前的目录。相同产物复用已安装文件;替换也会移除受管目录内用户添加的 Python 依赖。不含摘要的旧清单仍可读取,并与当前产物区分。分发包名称按 PEP 503 归一化,归一化后重复的名称会被拒绝;清单包含 numpy/pandas 分发包版本时,必须与相应组件版本一致。Desktop 单实例所有者和工具共享的安装 Promise 串行处理正常安装请求。 +应用版本、顶层解释器和包管理器版本、Python 分发包版本表和锁定输入摘要记录在 `runtime.json` 中,不放在目录名里。摘要涵盖所选目标的解释器与 wheel 压缩包、共享 wheel 输入、Python 分发包版本、pnpm 版本和组装格式;其他目标不会使其失效。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序参与该身份计算;锁文件顶层键的顺序不参与。解压或组装逻辑在锁定输入不变时改变产物字节,必须显式提升格式版本。安装发布完整的暂存副本,并在替换成功前保留之前的目录。相同产物复用已安装文件;替换也会移除受管目录内用户添加的 Python 依赖。不含摘要的旧清单仍可读取,并与当前产物区分。分发包名称按 PEP 503 归一化,归一化后重复的名称会被拒绝。读取器归一化旧 `components` 文件并保留其 numpy/pandas 一致性校验;当前清单仅在分发包版本表中记录 Python 库。Desktop 单实例所有者和工具共享的安装 Promise 串行处理正常安装请求。 -Node 下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。 +共享 Node 构建入口下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。 macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。 @@ -22,6 +22,8 @@ Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应 Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。 +[共享运行时决策](../architecture/2026-09-17-shared-office-runtime.zh.md)持有 Desktop 与 SDK 载体的构建器、锁文件和查询包归属,覆盖 GNU/Linux x64 与 Python-only payload。Desktop 保留本文的安装与签名行为,并要求完整的 Python、Node.js 和 pnpm payload。 + ## Alternatives considered **只使用系统解释器。** 无法保证可用性或预装 numpy 和 pandas。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 11b7e048f6..c92bdd5546 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: 2b0bd012c23c873b3003916491daad467cf757b3 -2026-07-30-generated-third-party-notices.zh.md: 0c7b4df8edb60f2f70781e179edc7377da930128 +2026-07-30-generated-third-party-notices.md: 71f1fd0a5b650d53c5ef21438e17e0a6e162bdf5 +2026-07-30-generated-third-party-notices.zh.md: e3adb43e4b407945a8cca88105de689407b5908b diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index 2b0bd012c2..71f1fd0a5b 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -12,13 +12,13 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o ## Decision -[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [Desktop runtime lock](../../../../apps/desktop/scripts/primary-runtime-lock.json). The root README pair links the file from its License section. +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [shared runtime lock](../../../../scripts/primary-runtime/lock.json). The root README pair links the file from its License section. -**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or Desktop runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. +**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or shared runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case. -The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the Desktop runtime lock's `pythonPackages`. Desktop entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The Desktop wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Desktop distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions. +The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the shared runtime lock's `pythonPackages`. Bundled entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The shared wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Bundled distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions. **Tiering follows distribution, not manifest section.** Installed runtime libraries are identified by `dependencies` or `optionalDependencies` outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/`. Browser inputs resolved by the shipping tsdown and Vite configurations also count as runtime, even in `devDependencies`; [browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) owns that classification. Test-support dependencies do not ship merely because their manifest says `dependencies`, and the generator explicitly discloses `tsx` because source launches execute through its ESM hook. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 0c7b4df8ed..e3adb43e4b 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [Desktop 运行时锁文件](../../../../apps/desktop/scripts/primary-runtime-lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [共享运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 -**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或 Desktop 运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 +**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除** manifest 不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。 -文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及 Desktop 运行时锁文件 `pythonPackages` 中的每个分发包。Desktop 条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。Desktop wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。Desktop 分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。 +文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及共享运行时锁文件 `pythonPackages` 中的每个分发包。内置条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。共享 wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。内置分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。 **分层依据是分发内容,而非 manifest 字段名。** 安装的运行时库由 `DEV_ONLY_AREAS` 之外的 `dependencies` 或 `optionalDependencies` 识别;排除区域为根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/`。发布所用的 tsdown 与 Vite 配置解析到的浏览器输入也属于运行时,即使它们位于 `devDependencies`;[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)拥有这项分类。测试支撑依赖不会仅因字段写成 `dependencies` 就被交付,而生成器显式披露 `tsx`,因为源码启动通过其 ESM 钩子执行。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32b58e9bc1..0c2077159b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -357,6 +357,9 @@ jobs: if: vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium + - name: Build and execute the Linux Office runtime + run: CI=true pnpm run prepare:primary-runtime --target linux-x64 --output "$RUNNER_TEMP/office-runtime" --cache "$RUNNER_TEMP/office-runtime-downloads" + - name: Run compatibility, snapshot, and artifact gates run: pnpm run check:ci:consumers diff --git a/.gitignore b/.gitignore index 7ad33230ad..86e330fc0c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,7 @@ mise.toml dist-exe/ /dist/ apps/desktop/.desktop-build/ -python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-* -python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/ +python/sdk-runtime/src/deepseek_harness_runtime/runtime/ python/**/__pycache__/ python/**/.pytest_cache/ packages/**/__pycache__/ diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ec698dca33..ab890ca559 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,7 +5,7 @@ DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Desktop bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. +This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). @@ -245,9 +245,9 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | | [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | -## Desktop bundled Python distributions +## Bundled Python distributions -The [Desktop runtime lock](apps/desktop/scripts/primary-runtime-lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. +The [shared runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. | Distribution | Locked version | Project license | | --- | --- | --- | diff --git a/apps/cli/package.json b/apps/cli/package.json index b3cff4af5d..2521fe120d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -69,6 +69,8 @@ "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-time-context": "workspace:^", diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index dce2ecbce0..cf6305caf7 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -13,7 +13,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur const modules = join(root, 'node_modules', '@deepseek-ai') const hostDirectory = fileURLToPath(new URL('../../desktop-host/', import.meta.url)) const manifest = JSON.parse(readFileSync(join(hostDirectory, 'package.json'), 'utf8')) as { dependencies: Record } - const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths', '@deepseek-ai/dsh-tools']) + const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths']) for (const name of Object.keys(manifest.dependencies)) { const destination = join(root, 'node_modules', name) mkdirSync(dirname(destination), { recursive: true }) @@ -22,7 +22,6 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur } for (const [name, source] of [ ['dsh-home-paths', `export const resolveDshHome = () => ${JSON.stringify(root)}`], - ['dsh-tools', 'export const defineTool = value => value'], ] as const) { writeFileSync(join(modules, name, 'package.json'), '{"type":"module","exports":"./index.js"}') writeFileSync(join(modules, name, 'index.js'), source) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 7019b87f71..e4a615b8bc 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -1,13 +1,14 @@ import { createServer } from 'node:http' -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import { execa } from 'execa' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' +import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies' const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) const launch = resolveExampleLaunch({ @@ -408,3 +409,123 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 30_000) }) + +/** Materialize a native-layout payload whose paths the query can validate without executing binaries. */ +async function officeFixture(root: string, pythonOnly: boolean) { + const source = join(root, 'resources', 'primary-runtime') + const manifest: PrimaryRuntimeManifest = { + desktopVersion: '1.0.0', platform: process.platform, arch: process.arch, + python: '3.12.14', + ...(pythonOnly ? {} : { node: '24.21.0', pnpm: '11.7.0' }), + pythonPackages: { 'python-docx': '1.2.0', 'python-pptx': '1.0.2', openpyxl: '3.1.5' }, + } + const paths = workspaceDependencyPaths(source, manifest) + for (const file of [paths.python, paths.node, paths.pnpm]) { + if (file === undefined) continue + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, 'fixture interpreter') + } + await mkdir(paths.pythonPackages, { recursive: true }) + if (paths.nodePackages !== undefined) await mkdir(paths.nodePackages, { recursive: true }) + await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest)) + await cp(join(repoRoot, 'packages/skill/skill-office/assets'), join(root, 'resources', 'office-skills'), { recursive: true }) + return { source, paths } +} + +it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', 'wrong-type'] as const)('composes Office resources through the SDK profile (%s)', async (mode) => { + const root = await mkdtemp(join(tmpdir(), 'sdk-office-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + const enabled = mode !== 'unset' && mode !== 'empty' + const { source, paths } = await officeFixture(root, mode === 'python-only') + if (mode === 'missing-assets') await rm(join(root, 'resources', 'office-skills'), { recursive: true }) + if (mode === 'wrong-type') { + await rm(paths.python) + await mkdir(paths.python) + } + const requests: Record[] = [] + const server = createServer((request, response) => { + let body = '' + request.setEncoding('utf8').on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + requests.push(JSON.parse(body) as Record) + const query = requests.length === 1 && enabled + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(messagesResponse(query + ? { type: 'tool_use', id: 'workspace-dependencies', name: 'load_workspace_dependencies', input: {} } + : { type: 'text', text: 'done' }, query ? 'tool_use' : 'end_turn')) + }) + }) + onTestFinished(() => new Promise((resolve) => { server.close(() => { resolve() }) })) + await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model fixture did not bind') + const home = join(root, 'home') + const officeLaunch = resolveExampleLaunch({ + srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib', + configArgs: ['--profile', 'sdk'], + env: { DSH_HOME: home, DSH_PRIMARY_RUNTIME: mode === 'unset' || mode === 'bundled' ? undefined : mode === 'empty' ? '' : source + '/', + DSH_BUNDLED_PRIMARY_RUNTIME: mode === 'unset' ? undefined : mode === 'bundled' || mode === 'empty' ? source : join(root, 'unused-default'), + DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: 'local-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` }, + }) + const child = execa(officeLaunch.command, officeLaunch.args, { cwd: repoRoot, env: officeLaunch.env, timeout: 60_000, reject: false }) + onTestFinished(async () => { child.kill('SIGKILL'); await child }) + let buffer = '', stderr = '' + const lines: string[] = [] + child.stdout.on('data', (chunk: Buffer) => { + buffer += chunk.toString() + const parts = buffer.split('\n') + buffer = parts.pop() ?? '' + lines.push(...parts) + }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + const send = (id: number, method: string, params?: object) => { + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`) + return waitForLine(lines, value => value.id === id, () => stderr) + } + expect(await send(1, 'initialize', { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' })).toHaveProperty('result') + await send(2, 'session/prompt', { sessionId: 'office', contentBlocks: [{ type: 'text', text: 'Query workspace dependencies.' }] }) + await waitForLine(lines, (value) => { + const params = value.params as { sessionId?: string; event?: { type?: string } } | undefined + return value.method === 'session.event' && params?.sessionId === 'office' && params.event?.type === 'turn/end' + }, () => stderr) + const names = (requests[0]!.tools as { name: string }[]).map(value => value.name) + expect(names.includes('load_workspace_dependencies')).toBe(enabled) + for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { + expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets') + } + if (mode === 'wrong-type') { + expect(requests).toHaveLength(2) + const messages = requests[1]!.messages as { content: { type: string; tool_use_id?: string; content?: unknown }[] }[] + const result = messages.flatMap(message => message.content).find(block => block.tool_use_id === 'workspace-dependencies') + expect(JSON.parse(JSON.stringify(result).replaceAll(JSON.stringify(paths.python).slice(1, -1), ''))).toMatchInlineSnapshot(` + { + "content": [ + { + "text": "Error: primary runtime: expected file at ", + "type": "text", + }, + ], + "is_error": true, + "tool_use_id": "workspace-dependencies", + "type": "tool_result", + } + `) + } else if (enabled) { + expect(requests).toHaveLength(2) + const content: unknown = expect.arrayContaining([ + expect.objectContaining({ type: 'tool_result', tool_use_id: 'workspace-dependencies', + content: [{ type: 'text', text: JSON.stringify(paths, undefined, 2) }] }), + ]) + expect(requests[1]!.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: 'user', content }), + ])) + } + if (mode === 'missing-assets') expect(stderr).toContain('check_office.py') + await send(3, 'shutdown') + const exit = await child + expect(exit.timedOut).toBe(false) + expect(exit.signal).toBeUndefined() + expect(exit.exitCode, stderr).toBe(0) + await expect(readFile(join(home, 'dsh-runtimes', 'dsh-primary-runtime', 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) +}) diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 092e3c2813..5014681e9a 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -18,7 +18,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", "@deepseek-ai/dsh-skill-office": "workspace:^" } } diff --git a/apps/desktop-host/src/office.ts b/apps/desktop-host/src/office.ts index 9645435e8c..8420aa0feb 100644 --- a/apps/desktop-host/src/office.ts +++ b/apps/desktop-host/src/office.ts @@ -3,7 +3,7 @@ import { dirname, join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import * as officeSkills from '@deepseek-ai/dsh-skill-office' -import * as workspaceDependencies from './workspace-dependencies.ts' +import * as workspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies' /** Loader identity for the application-owned Office composition. */ export const name = 'desktop-office' diff --git a/apps/desktop-host/src/primary-runtime.ts b/apps/desktop-host/src/primary-runtime.ts deleted file mode 100644 index 130e75d9cc..0000000000 --- a/apps/desktop-host/src/primary-runtime.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** Offline installation and absolute paths for Desktop's bundled script dependencies. */ - -import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises' -import { dirname, join } from 'node:path' - -/** Versions recorded by the Desktop build, independent of user-installed packages. */ -export interface PrimaryRuntimeManifest { - readonly desktopVersion: string - readonly platform: string - readonly arch: string - /** Locked payload identity; absent only in installations made before payload hashing. */ - readonly payloadDigest?: string - /** Installed wheel distribution versions; absent in older release manifests. */ - readonly pythonPackages?: Readonly> - readonly components: { - readonly python: string - readonly node: string - readonly pnpm: string - readonly numpy: string - readonly pandas: string - } -} - -/** Absolute entry points and bundled versions; pnpm runs through the returned Node executable. */ -export interface WorkspaceDependencies { - readonly python: string - readonly node: string - readonly pnpm: string - readonly pythonPackages: string - readonly nodePackages: string - /** Locked distribution versions; excludes packages users add to the installed environment. */ - readonly pythonDistributions: Readonly> -} - -/** - * Read build metadata, rejecting duplicate normalized names and conflicting component/distribution versions. - * @param root - Installed or bundled primary runtime directory. - * @returns Validated component versions and target identifiers. - */ -export async function readPrimaryRuntime(root: string): Promise { - const value: unknown = JSON.parse(await readFile(join(root, 'runtime.json'), 'utf8')) - if (typeof value !== 'object' || value === null) throw new Error('primary runtime: invalid metadata') - const record = value as Record - const components = record.components - const packages = record.pythonPackages - if (typeof record.desktopVersion !== 'string' || record.desktopVersion.length === 0 - || !['win32', 'darwin'].includes(String(record.platform)) || !['x64', 'arm64'].includes(String(record.arch)) - || typeof components !== 'object' || components === null - || !['python', 'node', 'pnpm', 'numpy', 'pandas'].every(key => /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/u.test(String((components as Record)[key]))) - || (record.payloadDigest !== undefined && (typeof record.payloadDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(record.payloadDigest))) - || (packages !== undefined && (typeof packages !== 'object' || packages === null || Array.isArray(packages) - || !Object.entries(packages).every(([name, version]) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(name) - && typeof version === 'string' && /^\d[\w.!+-]*$/u.test(version))))) { - throw new Error('primary runtime: invalid metadata') - } - const manifest = value as PrimaryRuntimeManifest - const entries = Object.entries(manifest.pythonPackages ?? {}) - const distributions = new Map(entries.map(([name, version]) => [name.toLowerCase().replace(/[-_.]+/gu, '-'), version])) - if (distributions.size !== entries.length) throw new Error('primary runtime: invalid metadata') - for (const name of ['numpy', 'pandas'] as const) { - const version = distributions.get(name) - if (version !== undefined && version !== manifest.components[name]) { - throw new Error(`primary runtime: conflicting ${name} distribution version`) - } - } - return manifest -} - -/** - * Resolve platform-specific interpreter and library locations without changing the environment. - * @param root - Absolute installation directory. - * @param manifest - Validated runtime metadata. - * @returns Absolute paths for explicit script execution and recorded bundled Python versions. - */ -export function workspaceDependencyPaths(root: string, manifest: PrimaryRuntimeManifest): WorkspaceDependencies { - const dependencies = join(root, 'dependencies') - const windows = manifest.platform === 'win32' - return { - python: join(dependencies, 'python', ...(windows ? ['python.exe'] : ['bin', 'python3'])), - node: join(dependencies, 'node', 'bin', windows ? 'node.exe' : 'node'), - pnpm: join(dependencies, 'pnpm', 'bin', 'pnpm.mjs'), - pythonPackages: join(dependencies, 'python', ...(windows ? ['Lib'] : ['lib', `python${manifest.components.python.split('.').slice(0, 2).join('.')}`]), 'site-packages'), - nodePackages: join(dependencies, 'node', 'node_modules'), - pythonDistributions: manifest.pythonPackages ?? {}, - } -} - -async function exists(path: string): Promise { - try { - if ((await lstat(path)).isSymbolicLink()) throw new Error(`primary runtime: installation path is a filesystem link: ${path}`) - return true - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false - throw error - } -} - -/** - * Install the application-owned payload locally, retaining a complete previous tree on copy failure. - * @param source - Payload carried by the current Desktop installation. - * @param root - Fixed primary runtime directory under the Harness home. - * @returns Paths into the installed payload; no PATH or package-manager configuration is changed. - */ -export async function installPrimaryRuntime(source: string, root: string): Promise { - const manifest = await readPrimaryRuntime(source) - if (manifest.platform !== process.platform || manifest.arch !== process.arch) throw new Error('primary runtime: incompatible platform or architecture') - await mkdir(dirname(root), { recursive: true }) - const previous = `${root}.previous` - await exists(previous) - await exists(root) - if (!await exists(root) && await exists(previous)) await rename(previous, root) - if (await exists(join(root, 'runtime.json')) && JSON.stringify(await readPrimaryRuntime(root)) === JSON.stringify(manifest)) { - const paths = workspaceDependencyPaths(root, manifest) - for (const path of [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages]) await stat(path) - return paths - } - const staging = await mkdtemp(join(dirname(root), '.primary-runtime-')) - try { - await cp(source, staging, { recursive: true, dereference: true }) - const paths = workspaceDependencyPaths(staging, manifest) - for (const path of [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages]) await stat(path) - await rm(previous, { recursive: true, force: true }) - const replacing = await exists(root) - if (replacing) await rename(root, previous) - try { await rename(staging, root) } catch (error) { - if (replacing) await rename(previous, root) - throw error - } - await rm(previous, { recursive: true, force: true }) - } finally { - await rm(staging, { recursive: true, force: true }) - } - return workspaceDependencyPaths(root, manifest) -} diff --git a/apps/desktop-host/src/workspace-dependencies.ts b/apps/desktop-host/src/workspace-dependencies.ts deleted file mode 100644 index 7444495263..0000000000 --- a/apps/desktop-host/src/workspace-dependencies.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** Desktop tool exposing bundled interpreters without modifying command resolution. */ - -import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' -import { installPrimaryRuntime, type WorkspaceDependencies } from './primary-runtime.ts' - -export const name = 'desktop-workspace-dependencies' -export const inject = ['tools'] - -/** Application-selected payload and installation directories. */ -export interface Config { - readonly source: string - readonly root: string -} - -/** - * Register the read-only path query, preparing bundled files on its first invocation. - * @param ctx - Desktop tool registry owner. - * @param config - Application payload and fixed installation paths. - */ -export function apply(ctx: Context, config: Config): void { - let installation: Promise | undefined - ctx.effect(() => async () => { - // Tool execution reports installation failures; disposal only waits for filesystem work to settle. - await installation?.catch(() => undefined) - }) - ctx.tools.register(defineTool({ - name: 'load_workspace_dependencies', - description: 'Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings.', - parameters: {}, - output: { - schema: { - type: 'object', additionalProperties: false, - properties: { - python: { type: 'string', required: true }, - node: { type: 'string', required: true }, - pnpm: { type: 'string', required: true }, - pythonPackages: { type: 'string', required: true }, - nodePackages: { type: 'string', required: true }, - pythonDistributions: { type: 'object', additionalProperties: true, required: true, description: 'Bundled distribution names and versions recorded in runtime.json; excludes user-installed additions.' }, - }, - }, - render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, undefined, 2) }], - }, - execute: () => { - installation ??= installPrimaryRuntime(config.source, config.root).catch((error: unknown) => { - installation = undefined - throw error - }) - return installation - }, - presentCall: () => ({ card: 'generic', title: 'Load workspace dependencies', kind: 'read' }), - })) -} diff --git a/apps/desktop-host/tests/primary-runtime.spec.ts b/apps/desktop-host/tests/primary-runtime.spec.ts deleted file mode 100644 index cd1cff25c9..0000000000 --- a/apps/desktop-host/tests/primary-runtime.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { afterEach, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import { ToolCallId } from '@deepseek-ai/dsh-llm' -import { installPrimaryRuntime, readPrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../src/primary-runtime.ts' -import * as workspaceDependencies from '../src/workspace-dependencies.ts' - -const roots: string[] = [] -afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) - -async function fixture() { - const directory = await mkdtemp(join(tmpdir(), 'dsh-primary-runtime-')) - roots.push(directory) - const source = join(directory, 'resources') - const root = join(directory, 'home', 'dsh-runtimes', 'dsh-primary-runtime') - const manifest: PrimaryRuntimeManifest = { - desktopVersion: '1.0.0', platform: process.platform === 'win32' ? 'win32' : 'darwin', arch: process.arch, - components: { python: '3.12.14', node: '24.21.0', pnpm: '11.7.0', numpy: '2.3.5', pandas: '3.0.1' }, - pythonPackages: { 'python-docx': '1.2.0', 'python-pptx': '1.0.2', openpyxl: '3.1.5' }, - } - const paths = workspaceDependencyPaths(source, manifest) - for (const path of [paths.python, paths.node, paths.pnpm]) { - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, 'interpreter') - } - await mkdir(paths.pythonPackages, { recursive: true }) - await mkdir(paths.nodePackages, { recursive: true }) - await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest)) - return { source, root, manifest, directory } -} - -it.each(['win32', 'darwin'])('returns %s interpreter and package paths', (platform) => { - const manifest: PrimaryRuntimeManifest = { desktopVersion: '1', platform, arch: 'x64', components: { python: '3.12.14', node: '24.21.0', pnpm: '11.7.0', numpy: '2.3.5', pandas: '3.0.1' } } - const paths = workspaceDependencyPaths('/runtime', manifest) - expect(paths.pythonDistributions).toEqual({}) - expect(paths.python).toBe(join('/runtime', 'dependencies', 'python', ...(platform === 'win32' ? ['python.exe'] : ['bin', 'python3']))) - expect(paths.pythonPackages).toBe(join('/runtime', 'dependencies', 'python', ...(platform === 'win32' ? ['Lib'] : ['lib', 'python3.12']), 'site-packages')) -}) - -it.skipIf(process.platform === 'linux')('installs offline, reuses the same release, and leaves environment and user packages unchanged', async () => { - const { source, root, manifest } = await fixture() - const environment = { ...process.env } - const installed = await installPrimaryRuntime(source, root) - await writeFile(join(installed.pythonPackages, 'user-package.py'), 'user content') - expect(await installPrimaryRuntime(source, root)).toEqual(installed) - expect(installed.pythonDistributions).toEqual(manifest.pythonPackages) - expect(await readFile(join(installed.pythonPackages, 'user-package.py'), 'utf8')).toBe('user content') - expect(process.env).toEqual(environment) -}) - -it.skipIf(process.platform === 'linux')('replaces release components and recovers an interrupted directory swap', async () => { - const { source, root, manifest } = await fixture() - await installPrimaryRuntime(source, root) - await rename(root, `${root}.previous`) - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, desktopVersion: '2.0.0' })) - await installPrimaryRuntime(source, root) - expect((await readPrimaryRuntime(root)).desktopVersion).toBe('2.0.0') -}) - -it.skipIf(process.platform === 'linux')('replaces dependencies when the locked payload changes without a Desktop version change', async () => { - const { source, root, manifest } = await fixture() - const first = { ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.1.2' } } - await writeFile(join(source, 'runtime.json'), JSON.stringify(first)) - const installed = await installPrimaryRuntime(source, root) - await writeFile(join(installed.pythonPackages, 'old-package.py'), 'old dependency') - const next = { ...first, payloadDigest: 'b'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } } - await writeFile(join(source, 'runtime.json'), JSON.stringify(next)) - await writeFile(join(workspaceDependencyPaths(source, next).pythonPackages, 'new-package.py'), 'new dependency') - await installPrimaryRuntime(source, root) - expect(await readPrimaryRuntime(root)).toEqual(next) - expect(await readFile(join(installed.pythonPackages, 'new-package.py'), 'utf8')).toBe('new dependency') - await expect(readFile(join(installed.pythonPackages, 'old-package.py'))).rejects.toMatchObject({ code: 'ENOENT' }) -}) - -it.skipIf(process.platform === 'linux')('upgrades a release manifest without a payload digest', async () => { - const { source, root, manifest } = await fixture() - await installPrimaryRuntime(source, root) - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } })) - await installPrimaryRuntime(source, root) - expect((await readPrimaryRuntime(root)).payloadDigest).toBe('a'.repeat(64)) -}) - -it.skipIf(process.platform === 'linux')('replaces changed payload bytes when only the digest changes', async () => { - const { source, root, manifest } = await fixture() - const first = { ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } } - const sourceFile = join(workspaceDependencyPaths(source, first).pythonPackages, 'library.py') - await writeFile(join(source, 'runtime.json'), JSON.stringify(first)) - await writeFile(sourceFile, 'first wheel bytes') - const installed = await installPrimaryRuntime(source, root) - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...first, payloadDigest: 'b'.repeat(64) })) - await writeFile(sourceFile, 'repacked wheel bytes') - await installPrimaryRuntime(source, root) - expect(await readFile(join(installed.pythonPackages, 'library.py'), 'utf8')).toBe('repacked wheel bytes') - expect((await readPrimaryRuntime(root)).pythonPackages).toEqual(first.pythonPackages) -}) - -it.skipIf(process.platform === 'linux')('keeps the installed release when the replacement payload is incomplete', async () => { - const { source, root, manifest } = await fixture() - await installPrimaryRuntime(source, root) - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, desktopVersion: '2.0.0' })) - await rm(workspaceDependencyPaths(source, manifest).python) - await expect(installPrimaryRuntime(source, root)).rejects.toThrow() - expect((await readPrimaryRuntime(root)).desktopVersion).toBe('1.0.0') -}) - -it.skipIf(process.platform === 'linux')('refuses linked installation directories without modifying their targets', async () => { - const { source, root, directory } = await fixture() - const outside = join(directory, 'outside') - await mkdir(outside) - await writeFile(join(outside, 'keep'), 'untouched') - await mkdir(dirname(root), { recursive: true }) - await symlink(outside, root, process.platform === 'win32' ? 'junction' : 'dir') - await expect(installPrimaryRuntime(source, root)).rejects.toThrow('filesystem link') - expect(await readFile(join(outside, 'keep'), 'utf8')).toBe('untouched') -}) - -it('rejects malformed metadata and incompatible targets', async () => { - const { source, root, manifest } = await fixture() - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, arch: process.arch === 'x64' ? 'arm64' : 'x64' })) - await expect(installPrimaryRuntime(source, root)).rejects.toThrow('incompatible') - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, components: { ...manifest.components, python: '../escape' } })) - await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata') -}) - -it.each([['numpy', 'numpy'], ['pandas', 'pandas'], ['Numpy', 'numpy'], ['PANDAS', 'pandas']] as const)('rejects conflicting %s component and distribution versions', async (distribution, name) => { - const { source, manifest } = await fixture() - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, pythonPackages: { [distribution]: '0.0.1' } })) - await expect(readPrimaryRuntime(source)).rejects.toThrow(`conflicting ${name} distribution version`) - const consistent = { ...manifest, pythonPackages: { [distribution]: manifest.components[name] } } - await writeFile(join(source, 'runtime.json'), JSON.stringify(consistent)) - expect(await readPrimaryRuntime(source)).toEqual(consistent) -}) - -it.each([ - { payloadDigest: 'invalid' }, - { pythonPackages: ['python-docx'] }, - { pythonPackages: { 'python-docx': '../escape' } }, - { pythonPackages: { '../escape': '1.2.0' } }, - { pythonPackages: { numpy: '2.3.5', Numpy: '2.3.5' } }, - { pythonPackages: { Pillow: '12.3.0', pillow: '12.3.0' } }, - { pythonPackages: { typing_extensions: '4.16.0', 'typing.extensions': '4.16.0' } }, -])('rejects invalid locked payload metadata: %j', async (invalid) => { - const { source, manifest } = await fixture() - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, ...invalid })) - await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata') -}) - -it.skipIf(process.platform === 'linux')('loads the real tool through Cordis, exposes installed paths, and unregisters on disposal', async () => { - const { source, root, manifest, directory } = await fixture() - const ctx = new Context() - try { - ctx.baseUrl = pathToFileURL(directory).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - const modules = new Map([ - ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['dependencies', workspaceDependencies], - ]) - ctx.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (!modules.has(specifier)) throw new Error(`unexpected plugin ${specifier}`) - return modules.get(specifier) - }, - } as unknown as NonNullable - const config = join(directory, 'cordis.yml') - await writeFile(config, `- name: agents\n- name: systemPrompt\n- name: tools\n- name: dependencies\n config:\n source: ${JSON.stringify(source)}\n root: ${JSON.stringify(root)}\n`) - await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } }) - await ctx.loader.await() - for (const entry of ctx.loader.entries()) await entry.fiber?.await() - const environment = { ...process.env } - const request = { signal: new AbortController().signal, name: 'load_workspace_dependencies', arguments: {} } - const results = await Promise.all(['first', 'second'].map(id => ctx.tools.execute({ ...request, callId: ToolCallId(id) }))) - for (const result of results) { - expect(result.isError).toBe(false) - expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(workspaceDependencyPaths(root, manifest), undefined, 2) }]) - } - expect(process.env).toEqual(environment) - const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'dependencies') - expect(entry).toBeDefined() - await entry?.fiber?.dispose() - expect(ctx.tools.schemas().some(tool => tool.name === 'load_workspace_dependencies')).toBe(false) - } finally { - await ctx.fiber.dispose() - } -}) diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 89780f7b94..81277cb3ff 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -13,7 +13,7 @@ { "path": "../../packages/jobs/jobs" }, { "path": "../../packages/skill/skill-office" }, { "path": "../../packages/util/home-paths" }, - { "path": "../../packages/core/tools" }, + { "path": "../../packages/skill/tool-workspace-dependencies" }, { "path": "../cli" }, diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index ad60f636ee..79e9bfbae3 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: aa2d9163067ea48b398e200b644fbeda6ea370a9 -README.zh.md: a27b551b4fc2ea37d5a2da45edbcf4b2901e5dde +README.md: 94d09c628d6bb40933f95cd3874d412ab18521df +README.zh.md: bfb156bfaa0400799498eabb6034f32c2d50e7f3 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index aa2d916306..94d09c628d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -24,11 +24,11 @@ Desktop carries independent Python, Node.js and pnpm distributions. Python inclu Desktop registers `office-docx`, `office-pptx`, and `office-xlsx` by default. The skills use the bundled Python libraries for creation and focused edits, then reopen the files and run a shared structural checker before delivery. PowerPoint creation and editing use python-pptx. Skill resources are copied to `runtime/office-skills` outside ASAR so Python can read the checker. An available `render_document` tool can add visual inspection; its absence does not prevent authoring or delivery. See the [Office skill package](../../packages/skill/skill-office/README.md) for checks and limitations. -The payload follows the Desktop release. `runtime.json` records the Desktop version, target, component and Python distribution versions, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names and conflicting numpy/pandas component and distribution versions reject the manifest. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running. +The payload follows the Desktop release. `runtime.json` records the Desktop version, target, top-level interpreter/package-manager versions and the Python distribution map, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names reject the manifest. Legacy `components` manifests remain readable through normalization, with their existing library-version consistency checks. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running. The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices. -Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](scripts/primary-runtime-lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. +Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. | Decision | Why | Direct consequence | |---|---|---| diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a27b551b4f..bfb156bfaa 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -24,11 +24,11 @@ Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 nump Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技能使用内置 Python 库创建文件和进行定点编辑,随后重新打开文件,并在交付前运行共享结构检查器。PowerPoint 的创建和编辑使用 python-pptx。技能资源复制到 ASAR 外的 `runtime/office-skills`,让 Python 可以读取检查器。可用的 `render_document` 工具可以补充视觉检查;缺少该工具不妨碍创作或交付。检查范围与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。 -该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、组件与 Python 分发包版本,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复,或 numpy/pandas 的组件版本与分发包版本冲突时,清单会被拒绝。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。 +该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、顶层解释器和包管理器版本及 Python 分发包版本表,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复时,清单会被拒绝。旧 `components` 清单通过归一化继续可读,并保留其原有库版本一致性校验。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 -Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](scripts/primary-runtime-lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 +Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 | 决策 | 原因 | 直接结果 | |---|---|---| diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bd93859ecd..b9cb4b3592 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -53,11 +53,9 @@ "electron": "^44.0.0", "electron-builder": "^26.15.3", "extract-zip": "^2.0.1", - "fflate": "^0.8.2", "js-yaml": "^4.2.0", "koffi": "^3.1.0", "pnpm": "11.7.0", - "tar": "^7.5.0", "typescript": "^6.0.3" } } diff --git a/apps/desktop/scripts/prepare-primary-runtime.ts b/apps/desktop/scripts/prepare-primary-runtime.ts index 9163db1234..44484baefc 100644 --- a/apps/desktop/scripts/prepare-primary-runtime.ts +++ b/apps/desktop/scripts/prepare-primary-runtime.ts @@ -1,171 +1,35 @@ -/** Prepare pinned, relocatable script interpreters without installing into the build host. */ +/** Desktop resource locations and signing-aware verification for the shared runtime builder. */ -import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { cp } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' -import extractZip from 'extract-zip' -import { x as extractTar } from 'tar' -import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../desktop-host/src/primary-runtime.ts' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { preparePrimaryRuntime as preparePayload, smokePrimaryRuntime as smokePayload } from '../../../scripts/primary-runtime/prepare.ts' +import { parsePrimaryRuntime, workspaceDependencyPaths } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' -import lock from './primary-runtime-lock.json' with { type: 'json' } /** - * Download or reuse an archive only when its bytes match the release lock. - * @param url - Locked archive URL. - * @param sha256 - Expected SHA-256 digest. - * @param cache - Download cache directory. - * @returns Verified local archive path. - */ -export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string): Promise { - const destination = join(cache, sha256) - let bytes: Buffer - try { bytes = readFileSync(destination) } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - const response = await fetch(url) - if (!response.ok) throw new Error(`primary runtime download: ${String(response.status)} ${url}`) - bytes = Buffer.from(await response.arrayBuffer()) - } - if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`primary runtime download: checksum mismatch for ${url}`) - writeFileSync(destination, bytes) - return destination -} - -async function pythonArchive(target: keyof typeof lock.targets, cache: string): Promise { - const artifact = lock.targets[target] - const filename = `cpython-${lock.pythonVersion}+${lock.pythonRelease}-${artifact.pythonTarget}-install_only_stripped.tar.gz` - return downloadPrimaryRuntimeAsset(`https://github.com/astral-sh/python-build-standalone/releases/download/${lock.pythonRelease}/${encodeURIComponent(filename)}`, artifact.pythonSha256, cache) -} - -/** - * Identify the inputs that assemble one target's payload, excluding unrelated target locks. - * @param target - Desktop target whose archives are installed. - * @param runtimeLock - Locked interpreter and wheel inputs. - * @param pnpmVersion - Package-manager version copied into the payload. - * @returns SHA-256 payload identity for installation reuse. - */ -export function primaryRuntimePayloadDigest(target: keyof typeof lock.targets, runtimeLock: typeof lock, pnpmVersion: string): string { - const { pythonVersion, pythonRelease, nodeVersion, wheels, pythonPackages } = runtimeLock - // Identity preserves key order within the selected target, wheel records and distribution map, plus wheel-entry order. - // Bump format when extraction or assembly changes payload bytes without changing locked inputs. - return createHash('sha256').update(JSON.stringify({ - format: 2, target, pythonVersion, pythonRelease, nodeVersion, - artifact: runtimeLock.targets[target], wheels, pythonPackages, pnpm: pnpmVersion, - })).digest('hex') -} - -/** - * Unpack a locked library wheel, retaining auxiliary scripts in its distribution data directory. - * @param archive - Hash-verified wheel archive. - * @param destination - Absolute site-packages directory. - * @returns Resolves after extraction without command wrappers; rejects other wheel installation schemes. - */ -export async function unpackPrimaryRuntimeWheel(archive: string, destination: string): Promise { - await extractZip(archive, { - dir: destination, - onEntry: (entry) => { - const [directory, scheme] = entry.fileName.split('/') - if (directory?.endsWith('.data') && scheme !== '' && scheme !== 'scripts') { - throw new Error(`primary runtime: wheel requires unsupported installation paths: ${entry.fileName}`) - } - }, - }) -} - -/** - * Copy the skill package's complete asset tree to ordinary filesystem resources. - * @param source - The package's assets directory. - * @param destination - Desktop runtime resource directory outside ASAR. - * @returns Resolves after replacing the external assets with the complete package tree. - */ -export async function prepareOfficeSkillAssets(source: string, destination: string): Promise { - rmSync(destination, { recursive: true, force: true }) - await cp(source, destination, { recursive: true, dereference: true }) -} - -/** - * Materialize the selected Desktop target's primary runtime in its build resources. + * Prepare Desktop resources for its selected packaging target. * @param options - Signed Windows packaging defers execution until its supervised signing stage. - * @returns Resolves after materialization and, unless deferred, native-target execution checks. + * @returns Resolves after preparation and, unless deferred, native-target execution checks. */ export async function preparePrimaryRuntime(options: { deferSmoke?: boolean } = {}): Promise { - const target = resolveDesktopBuildTarget() const paths = resolveDesktopTargetBuildPaths() - const artifact = lock.targets[target] - mkdirSync(paths.runtime, { recursive: true }) - mkdirSync(paths.downloads, { recursive: true }) - const staging = mkdtempSync(join(tmpdir(), 'dsh-primary-')) - try { - const output = join(staging, 'payload') - const dependencies = join(output, 'dependencies') - mkdirSync(dependencies, { recursive: true }) - const nodeFilename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}` - const nodeArchive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${nodeFilename}`, artifact.nodeSha256, paths.downloads) - const unpackedNode = join(staging, 'node') - mkdirSync(unpackedNode) - if (target === 'win-x64') await extractZip(nodeArchive, { dir: unpackedNode }) - else await extractTar({ file: nodeArchive, cwd: unpackedNode }) - const nodeSource = join(unpackedNode, nodeFilename.replace(/\.(?:zip|tar\.gz)$/u, '')) - mkdirSync(join(dependencies, 'node', 'bin'), { recursive: true }) - mkdirSync(join(dependencies, 'node', 'node_modules')) - writeFileSync(join(dependencies, 'node', 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n') - cpSync(join(nodeSource, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])), - join(dependencies, 'node', 'bin', target === 'win-x64' ? 'node.exe' : 'node')) - cpSync(join(nodeSource, 'LICENSE'), join(dependencies, 'node', 'LICENSE')) - await extractTar({ file: await pythonArchive(target, paths.downloads), cwd: dependencies }) - const require = createRequire(import.meta.url) - const pnpmManifest = require.resolve('pnpm') - const pnpm = JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string } - await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true }) - const desktop = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')) as { version: string } - const manifest: PrimaryRuntimeManifest = { - desktopVersion: desktop.version, - platform: target === 'win-x64' ? 'win32' : 'darwin', - arch: target === 'mac-arm64' ? 'arm64' : 'x64', - payloadDigest: primaryRuntimePayloadDigest(target, lock, pnpm.version), - pythonPackages: lock.pythonPackages, - components: { - python: lock.pythonVersion, node: lock.nodeVersion, pnpm: pnpm.version, - numpy: lock.pythonPackages.numpy, pandas: lock.pythonPackages.pandas, - }, - } - const entries = workspaceDependencyPaths(output, manifest) - for (const wheel of [...artifact.wheels, ...lock.wheels]) { - await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages) - } - writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`) - const destination = join(paths.runtime, 'primary-runtime') - rmSync(destination, { recursive: true, force: true }) - await cp(output, destination, { recursive: true, dereference: true }) - } finally { - rmSync(staging, { recursive: true, force: true }) - } - const hostRequire = createRequire(resolve(import.meta.dirname, '..', '..', 'desktop-host', 'package.json')) - await prepareOfficeSkillAssets(join(dirname(hostRequire.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'), - join(paths.runtime, 'office-skills')) + const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string } + await preparePayload({ target: resolveDesktopBuildTarget(), output: paths.runtime, cache: paths.downloads, version }) if (!options.deferSmoke) smokePrimaryRuntime(join(paths.runtime, 'primary-runtime')) } /** - * Execute the native payload's interpreters, package manager and Python libraries. + * Verify Desktop's complete payload after preparation or platform signing. * @param root - Final payload directory, including any platform signatures. */ export function smokePrimaryRuntime(root: string): void { - const manifest = JSON.parse(readFileSync(join(root, 'runtime.json'), 'utf8')) as PrimaryRuntimeManifest + const manifest = parsePrimaryRuntime(JSON.parse(readFileSync(join(root, 'runtime.json'), 'utf8'))) if (manifest.platform !== process.platform || manifest.arch !== process.arch) return - if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.') + if (Object.keys(manifest.pythonPackages).length === 0) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.') const entries = workspaceDependencyPaths(root, manifest) - const options = { stdio: 'inherit', timeout: 120_000, env: scrubWindowsSigningEnvironment(process.env) } as const - execFileSync(entries.python, ['-I', '-c', 'import decimal, xml.parsers.expat, lzma, uuid, numpy, pandas; assert numpy.arange(4).sum() == 6; assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3'], options) - execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages), - manifest.components.python, join(dirname(root), 'office-skills', 'scripts', 'check_office.py')], options) - execFileSync(entries.python, ['-I', '-B', '-m', 'pip', 'check'], options) - execFileSync(entries.node, ['-e', `if (process.versions.node !== ${JSON.stringify(manifest.components.node)}) process.exit(1)`], options) - execFileSync(entries.node, [entries.pnpm, '--version'], options) + if (entries.node === undefined || entries.pnpm === undefined) throw new Error('primary runtime: the Desktop payload must declare node and pnpm components.') + smokePayload(root, scrubWindowsSigningEnvironment(process.env)) } if (import.meta.main) await preparePrimaryRuntime() diff --git a/apps/desktop/tests/primary-runtime-preparation.spec.ts b/apps/desktop/tests/primary-runtime-preparation.spec.ts index 2e0f802af5..ec9dda7edb 100644 --- a/apps/desktop/tests/primary-runtime-preparation.spec.ts +++ b/apps/desktop/tests/primary-runtime-preparation.spec.ts @@ -1,131 +1,18 @@ -import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +/** Desktop carriers keep a complete interpreter payload even when SDK carriers choose Python-only. */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' -import { zipSync } from 'fflate' +import { join } from 'node:path' import { expect, it } from 'vitest' -import { downloadPrimaryRuntimeAsset, prepareOfficeSkillAssets, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts' -import lock from '../scripts/primary-runtime-lock.json' with { type: 'json' } +import { smokePrimaryRuntime } from '../scripts/prepare-primary-runtime.ts' -const libraryWheel = Buffer.from('UEsDBAoAAAAAAASeLl0sYMPjDAAAAAwAAAAJAAAAc2FtcGxlLnB5c2FtcGxlID0gNDIKUEsBAh4DCgAAAAAABJ4uXSxgw+MMAAAADAAAAAkAAAAAAAAAAQAAAKSBAAAAAHNhbXBsZS5weVBLBQYAAAAAAQABADcAAAAzAAAAAAA=', 'base64') -const relocatedWheel = Buffer.from('UEsDBAoAAAAAAASeLl3x0Nj9FAAAABQAAAAeAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlcmVxdWlyZXMgcmVsb2NhdGlvbgpQSwECHgMKAAAAAAAEni5d8dDY/RQAAAAUAAAAHgAAAAAAAAABAAAApIEAAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlUEsFBgAAAAABAAEATAAAAFAAAAAAAA==', 'base64') -const externalLibraryWheel = Buffer.from('UEsDBBQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAc2FtcGxlLTEuMC5kYXRhL3B1cmVsaWIvc2FtcGxlLnB5cmVxdWlyZXMgbGlicmFyeSByZWxvY2F0aW9uClBLAQIUAxQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAAAAAAAAAAACAAQAAAABzYW1wbGUtMS4wLmRhdGEvcHVyZWxpYi9zYW1wbGUucHlQSwUGAAAAAAEAAQBPAAAAWwAAAAAA', 'base64') - -it.each(Object.entries(lock.targets))('records every locked wheel distribution and version for %s', (_target, artifact) => { - const normalize = (name: string): string => name.toLowerCase().replace(/[-_.]+/gu, '-') - const distributions = [...artifact.wheels, ...lock.wheels].map(({ url }) => { - const [name, version] = basename(new URL(url).pathname).split('-') - return [normalize(name!), version] as const - }) - const declared = Object.entries(lock.pythonPackages).map(([name, version]) => [normalize(name), version] as const) - expect(new Set(distributions.map(([name]) => name)).size).toBe(distributions.length) - expect(new Set(declared.map(([name]) => name)).size).toBe(declared.length) - expect(Object.fromEntries(distributions)).toEqual(Object.fromEntries(declared)) -}) - -it('keeps a target payload identity independent of other target archives', () => { - const changed = structuredClone(lock) - changed.targets['win-x64'].wheels[0]!.sha256 = 'a'.repeat(64) - expect(primaryRuntimePayloadDigest('mac-arm64', changed, '11.7.0')).toBe(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0')) - expect(primaryRuntimePayloadDigest('win-x64', changed, '11.7.0')).not.toBe(primaryRuntimePayloadDigest('win-x64', lock, '11.7.0')) -}) - -it('invalidates payload identity for shared wheels, package versions and package-manager changes', () => { - const wheel = structuredClone(lock), distribution = structuredClone(lock) - wheel.wheels[0]!.sha256 = 'a'.repeat(64) - distribution.pythonPackages['python-docx'] = '1.2.1' - const original = primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0') - expect(primaryRuntimePayloadDigest('mac-arm64', wheel, '11.7.0')).not.toBe(original) - expect(primaryRuntimePayloadDigest('mac-arm64', distribution, '11.7.0')).not.toBe(original) - expect(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.1')).not.toBe(original) -}) - -it('reports missing distribution metadata before trying to execute a stale native payload', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-stale-runtime-')) +it('rejects a Python-only native Desktop payload before executing interpreters', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-python-only-')) try { - await writeFile(join(root, 'runtime.json'), JSON.stringify({ platform: process.platform, arch: process.arch })) - expect(() => { smokePrimaryRuntime(root) }).toThrow('missing Python distribution versions; prepare the payload') - } finally { await rm(root, { recursive: true, force: true }) } -}) - -it('extracts a hash-verified cached library without a Python installer or network request', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) - try { - const hash = createHash('sha256').update(libraryWheel).digest('hex') - const archive = join(root, hash) - await writeFile(archive, libraryWheel) - expect(await downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).toBe(archive) - await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) - expect(await readFile(join(root, 'site-packages/sample.py'), 'utf8')).toBe('sample = 42\n') - await writeFile(archive, 'corrupt archive') - await expect(downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).rejects.toThrow('checksum mismatch') - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('fully extracts a large deflate-compressed wheel entry', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-large-wheel-')) - try { - const expected = Buffer.alloc(1024 * 1024) - // Seeded xorshift bytes keep the compressed entry larger than the stream buffers. - let state = 26 - for (let index = 0; index < expected.length; index++) { - state ^= state << 13 - state ^= state >>> 17 - state ^= state << 5 - expected[index] = state & 255 - } - const archive = join(root, 'large.whl') - await writeFile(archive, zipSync({ 'large.bin': expected }, { level: 6 })) - await unpackPrimaryRuntimeWheel(archive, root) - expect(await readFile(join(root, 'large.bin'))).toEqual(expected) - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('retains auxiliary wheel scripts without generating command wrappers', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) - try { - const archive = join(root, 'relocated.whl') - await writeFile(archive, relocatedWheel) - await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) - expect(await readFile(join(root, 'site-packages/sample-1.0.data/scripts/sample'), 'utf8')).toBe('requires relocation\n') - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('rejects library files requiring an unsupported installation scheme', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) - try { - const archive = join(root, 'relocated.whl') - await writeFile(archive, externalLibraryWheel) - await expect(unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))).rejects.toThrow('unsupported installation paths') - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('copies complete Office resources outside the application archive and removes obsolete assets', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-office-assets-')) - try { - const source = join(root, 'package', 'assets') - const destination = join(root, 'Contents', 'Resources', 'runtime', 'office-skills') - await mkdir(join(source, 'scripts'), { recursive: true }) - await writeFile(join(source, 'scripts', 'check_office.py'), 'print("checker")\n') - for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - await mkdir(join(source, name)) - await writeFile(join(source, name, 'SKILL.md'), `# ${name}\n`) - } - await prepareOfficeSkillAssets(source, destination) - await writeFile(join(destination, 'obsolete.py'), 'old helper') - await prepareOfficeSkillAssets(source, destination) - for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - expect(await readFile(join(destination, name, 'SKILL.md'), 'utf8')).toBe(`# ${name}\n`) - } - expect(await readFile(join(destination, 'scripts', 'check_office.py'), 'utf8')).toBe('print("checker")\n') - await expect(readFile(join(destination, 'obsolete.py'))).rejects.toMatchObject({ code: 'ENOENT' }) + await writeFile(join(root, 'runtime.json'), JSON.stringify({ + desktopVersion: '1.0.0', platform: process.platform, arch: process.arch, + python: '3.12.14', pythonPackages: { numpy: '2.3.5', pandas: '3.0.1' }, + })) + expect(() => { smokePrimaryRuntime(root) }).toThrow('Desktop payload must declare node and pnpm') } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 2948072fd0..241d90707c 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -227,14 +227,19 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () document.body.append(probe) const actual = getComputedStyle(element) const reference = getComputedStyle(probe) + // The sibling access-mode trigger: the chip shares its corner + // curvature (the theme's superellipse, not a circular capsule). + const accessMode = document.querySelector('button[aria-label^="Access mode"]') const result = { color: actual.color, backgroundColor: actual.backgroundColor, height: actual.height, borderRadius: actual.borderRadius, + cornerShape: actual.getPropertyValue('corner-shape'), fontSize: actual.fontSize, referenceColor: reference.color, referenceBackgroundColor: reference.backgroundColor, + siblingCornerShape: accessMode === null ? null : getComputedStyle(accessMode).getPropertyValue('corner-shape'), } probe.remove() return result @@ -242,7 +247,11 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(planStyle.color).toBe(planStyle.referenceColor) expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor) expect(planStyle.height).toBe('28px') - expect(planStyle.borderRadius).toBe('999px') + // Half the 28px height, the compact Button geometry, under the theme's + // corner curvature; a 999px pill would need the circular opt-out. + expect(planStyle.borderRadius).toBe('14px') + expect(planStyle.siblingCornerShape).not.toBeNull() + expect(planStyle.cornerShape).toBe(planStyle.siblingCornerShape) expect(planStyle.fontSize).toBe('13px') await planButton.click() await expect.poll(() => planButton.count()).toBe(0) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 5362264b42..788b6aa871 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -10,7 +10,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import type { Browser, Page } from 'playwright' +import type { Browser, ConsoleMessage, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -211,6 +211,51 @@ describe('web e2e: plan review takeover round trip', () => { } }, 60_000) + it.skipIf(MODE === 'record')('opens a review that arrives while a global panel is active', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-panel-return')) + const agent = scaffold.ctx.agents.get(reviewedSession) + if (agent === undefined) throw new Error('The reviewed Session has no active agent') + // The Plugins panel replaces the Conversation and its right Sidebar. The + // review arrives while neither is mounted; returning mounts both in one + // commit, and the review's automatic open runs before the Sidebar's own + // effects. A crash there retires the opener for the rest of the page. + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + await expect.poll(() => page.locator('[data-composer-input]').count()).toBe(0) + const crashes: string[] = [] + const onConsole = (message: ConsoleMessage): void => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) crashes.push(message.text()) + } + page.on('console', onConsole) + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent, signal: controller.signal, + questions: [{ id: 'off-screen', question: 'Approve this plan?', + detail: '# Off-screen review\n\nSubmitted while the Plugins panel was open.', + options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' }, + }], + }) + const outcome = asked.then(value => value, (error: unknown) => ({ error })) + try { + const row = page.locator('[role="treeitem"]').filter({ has: page.locator('[data-state="warning"]') }).first() + await row.waitFor({ timeout: 10_000 }) + await row.click() + const card = page.locator('[data-plan-review-key]') + await card.waitFor({ timeout: 10_000 }) + const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]') + await preview.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await preview.getByText('Submitted while the Plugins panel was open.').isVisible()).toBe(true) + expect(await card.getByRole('button', { name: 'Open plan in sidebar' }).count()).toBe(1) + expect(await page.locator('[data-slot-error]').count()).toBe(0) + expect(crashes).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + page.off('console', onConsole) + controller.abort() + await outcome + } + }, 60_000) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md', 'preview.expected.md', diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 08ba301635..486b8ab023 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 4c89900a886e06f58d581af305bfa080b2d92d6a -config-catalog.zh.md: ff41610c361e1e1265f3201f8d62842195e7bf98 +config-catalog.md: 862bb8a03e140ce61c904537b6d0b2d809ce8ae5 +config-catalog.zh.md: 3fec8404bd853dae26b389f87376770b81ec6e6a diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4c89900a88..862bb8a03e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3455,6 +3455,28 @@ export interface Config { Source: [`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +Requires: `tools` + +```ts config-catalog +/** Payload location and optional installation directory. */ +export interface Config { + /** Payload directory carrying `runtime.json` and `dependencies/`. */ + readonly source: string + /** + * Installation directory under the Harness home. When set, the payload is copied there on the + * first call (the Desktop behavior); when omitted, the payload is used in place without copying, + * which suits read-only carriers such as container image layers. + */ + readonly root?: string +} +``` + +Source: [`packages/skill/tool-workspace-dependencies/src/index.ts:15`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tools` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ff41610c36..3fec8404bd 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3457,6 +3457,28 @@ export interface Config { 来源:[`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +依赖:`tools` + +```ts config-catalog +/** Payload location and optional installation directory. */ +export interface Config { + /** Payload directory carrying `runtime.json` and `dependencies/`. */ + readonly source: string + /** + * Installation directory under the Harness home. When set, the payload is copied there on the + * first call (the Desktop behavior); when omitted, the payload is used in place without copying, + * which suits read-only carriers such as container image layers. + */ + readonly root?: string +} +``` + +来源:[`packages/skill/tool-workspace-dependencies/src/index.ts:15`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tools` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1d41a997fa..de5e777638 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 1b013cd5d96c133ed3ffcd419d2cdebe653187e9 -module-graph.zh.md: d8b981f500fce55c29a5ab99df1cb493064f3365 +module-graph.md: e4ac3c5b8769b9ccbd7b7ebe0d2addf597f8ef1d +module-graph.zh.md: 0d9ee982c4ecf07b4f5d4ec70591c19b93d37184 diff --git a/docs/module-graph.md b/docs/module-graph.md index 1b013cd5d9..e4ac3c5b87 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,6 +65,7 @@ flowchart TD pkg_skill_filesystem["skill-filesystem"] pkg_skill_office["skill-office"] pkg_tool_skill["tool-skill"] + pkg_tool_workspace_dependencies["tool-workspace-dependencies"] end subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] @@ -775,6 +776,7 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools + pkg_tool_workspace_dependencies --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools @@ -1499,6 +1501,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index d8b981f500..0d9ee982c4 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -67,6 +67,7 @@ flowchart TD pkg_skill_filesystem["skill-filesystem"] pkg_skill_office["skill-office"] pkg_tool_skill["tool-skill"] + pkg_tool_workspace_dependencies["tool-workspace-dependencies"] end subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] @@ -777,6 +778,7 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools + pkg_tool_workspace_dependencies --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools @@ -1501,6 +1503,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 6235f1e0fe..91d84a0c78 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 70d32d5818690fbaa3717ece8314ac8a5a8ffcbf -tool-catalog.zh.md: 72493ae3b858b40e46caa5f4962c2132f2bb55c6 +tool-catalog.md: f8ea8e3c41b74011c6e4dbf3dcc3c9d63d1635b2 +tool-catalog.zh.md: a91470bc81f90bc61273f196100564eef171c040 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 70d32d5818..f8ea8e3c41 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -43,6 +43,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | +| `@deepseek-ai/dsh-tool-workspace-dependencies` | `load_workspace_dependencies` | `ctx.tools` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | @@ -2438,6 +2439,23 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim Source: [`packages/workflow/tool-workflow/src/index.ts`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +### `load_workspace_dependencies` + +Get absolute paths to bundled Python and library directories, plus bundled Python distribution versions. Node.js and pnpm paths are included when the payload provides them. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. When Node.js and pnpm paths are returned, run pnpm with that Node executable and pnpm script path. This does not change PATH or package-manager settings. + +```json +{ + "type": "object", + "properties": {} +} +``` + +Source: [`packages/skill/tool-workspace-dependencies/src/index.ts`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tool-web` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 72493ae3b8..a91470bc81 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -47,6 +47,7 @@ | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - | +| `@deepseek-ai/dsh-tool-workspace-dependencies` | `load_workspace_dependencies` | `ctx.tools` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`、`web_search` | `ctx.tools`、`ctx.web`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | web_search 和 web_fetch 将提供方选择置于 ctx.web 之后,使模型可见 schema 在更换后端时保持稳定。 | @@ -2446,6 +2447,23 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 来源:[`packages/workflow/tool-workflow/src/index.ts`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +### `load_workspace_dependencies` + +获取随包附带的 Python 和库目录的绝对路径,以及随包 Python 发行版的版本。payload 提供 Node.js 和 pnpm 时才返回对应路径。Python 含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml 与 XlsxWriter。除非用户或工作区指令选择了别的环境,Office 文件请使用这些库。返回 Node.js 和 pnpm 路径时,用该 Node 可执行文件和 pnpm 脚本路径运行 pnpm。本工具不改 PATH,也不改包管理器设置。 + +```json +{ + "type": "object", + "properties": {} +} +``` + +来源:[`packages/skill/tool-workspace-dependencies/src/index.ts`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tool-web` diff --git a/lefthook.yml b/lefthook.yml index 04106e1fd1..1d5c8898c6 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -28,7 +28,7 @@ pre-commit: # lefthook only inspects files present on disk — so that one case still # falls through to the freshness assertion in the test lane. - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,apps/desktop/scripts/primary-runtime-lock.json,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' + glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/primary-runtime/lock.json,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - name: whitespace (staged) diff --git a/package.json b/package.json index eb4b2b92c9..827b4000dd 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "website" ], "scripts": { + "prepare:primary-runtime": "tsx scripts/primary-runtime/prepare.ts", "build": "tsx scripts/build.ts", "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", @@ -226,7 +227,9 @@ "@yarnpkg/cli-dist": "4.17.1", "eslint-plugin-sonarjs": "^4.1.0", "execa": "^10.0.0", + "extract-zip": "^2.0.1", "fast-check": "^4.8.0", + "fflate": "^0.8.2", "istanbul-lib-report": "^3.0.1", "js-yaml": "^4.2.0", "jscpd": "^5.0.12", @@ -239,14 +242,17 @@ "micromark-extension-gfm": "^3.0.0", "oxlint": "1.76.0", "oxlint-tsgolint": "7.0.2001", + "pnpm": "11.7.0", "publint": "^0.3.21", "smol-toml": "^1.7.1", "spdx-expression-parse": "^5.0.0", + "tar": "^7.5.0", "tsdown": "^0.22.2", "tsx": "^4.22.4", "typescript": "^6.0.3", "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.1.8", - "@deepseek-ai/dsh-skill-office": "workspace:^" + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^" } } diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index ac62ef092e..1563c25b1f 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/sdk-app/README.md -README.md: 0bac09b5a7e355434e35b04f4e9abd3e816505af -README.zh.md: e0e0d7e1e147c0a2dfaed7014ea0b3a0a9028f2b +README.md: 6d6106e6dd803cee5c51cee0a38f80705986ddfa +README.zh.md: 9de1fb6e17f3f83b7d8c2acbf87154b1cdfea38f diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index 0bac09b5a7..6d6106e6dd 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -33,6 +33,8 @@ The startup provider binds stdin EOF to the launcher's bounded successful shutdo The SDK uses the base `read`, `write`, and `edit` defaults. To add `str_replace_editor`, use the explicit insertion patch in the [base configuration guide](../base/README.md#use-this-package). The standalone `sdk-minimal` profile owns its separate tool selection. +The packaged Python runtime enables the [Office runtime query and skills](../../skill/tool-workspace-dependencies/README.md#use-this-package) by default. `DSH_PRIMARY_RUNTIME` overrides its bundled payload; an empty value disables both rows. Source launches without a carrier default remain opt-in. The tool reads the payload in place. Profile patches can disable `skill-office` or replace its `assetRoot` independently of `workspace-dependencies`; filesystem skills with the same name take precedence over bundled skills. See the [runtime package](../../../python/sdk-runtime/README.md) for packaging and configuration. + ----- diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index e0e0d7e1e1..9de1fb6e17 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -33,6 +33,8 @@ kind: "package-bundle" SDK 使用 base 默认提供的 `read`、`write` 和 `edit`。要添加 `str_replace_editor`,请使用 [base 配置指南](../base/README.zh.md#use-this-package)中的显式插入 patch。独立的 `sdk-minimal` profile 自行决定其工具选择。 +打包的 Python runtime 默认启用 [Office 运行时查询与 skills](../../skill/tool-workspace-dependencies/README.zh.md#use-this-package)。`DSH_PRIMARY_RUNTIME` 覆盖随包 payload;空值禁用两行。没有载体默认路径的源码启动仍需显式启用。工具原位读取 payload。profile patch 可独立于 `workspace-dependencies` 禁用 `skill-office` 或替换其 `assetRoot`;同名文件系统 skills 优先于随包 skills。打包与配置见 [runtime 包](../../../python/sdk-runtime/README.zh.md)。 + ----- diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index 91c67debc1..2abfbc952d 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -23,3 +23,18 @@ - id: hmr disabled: true + +# The packaged carrier supplies a default; DSH_PRIMARY_RUNTIME overrides it, +# including an empty-string opt-out. Each row can be patched independently. +- insert: + - id: workspace-dependencies + name: '@deepseek-ai/dsh-tool-workspace-dependencies' + disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)" + config: + source: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '')" + + - id: skill-office + name: '@deepseek-ai/dsh-skill-office' + disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)" + config: + assetRoot: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')" diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 45e484464f..bdc3bd2c84 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -37,7 +37,9 @@ "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", - "commander": "^15.0.0" + "commander": "^15.0.0", + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 24fc6a38eb..e6ffe166f1 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md -README.md: 96ecd3777ef0ce4ce6babd977eef055ad5c683ec -README.zh.md: fb6eb850b868913bdccec175500ff4e26d7a2157 +README.md: d87d288ace572bcaf97778f771b1ecc9b4400356 +README.zh.md: 14b98d14376bcf7b27a07a249b14627714b31282 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 96ecd3777e..d87d288ace 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and `dsh-plan-mode`; the chip then ### What the chip shows -While the effective target is plan mode, the seat renders the blue "Plan ×" status button, which executes `/plan off`. Otherwise the seat stays empty: a host without plan mode, or a Draft with no session, shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan" — unless the owning surface supplies its own placeholder. +While the effective target is plan mode, the seat renders the blue "Plan" status button — the plan glyph ahead of the label, swapped for a circled cross while the enabled button is hovered or keyboard-focused — which executes `/plan off`. Otherwise the seat stays empty: a host without plan mode, or a Draft with no session, shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan" — unless the owning surface supplies its own placeholder. ### Reading submitted plans @@ -51,7 +51,7 @@ Admission failures (`matched: false`, business errors, transport faults) surface The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint. -Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. +Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The automatic open reads `ctx.sidebarRight.mounted` through a bound hook and runs once a seat is on screen: a review arriving while a global panel is active mounts ahead of the returning seat in the same commit, before that seat binds. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index fb6eb850b8..14b98d1437 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 徽章显示什么 -当有效目标为 plan 模式时,该座位渲染 蓝色「计划 ×」状态按钮,执行 `/plan off`。否则座位保持为空:未组合 plan-mode 的宿主,或尚无会话的 Draft,都不显示任何内容。plan 模式为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——「describe your task to generate plan」——除非所属 surface 提供自己的 placeholder。 +当有效目标为 plan 模式时,该座位渲染蓝色「计划」状态按钮——标签前是计划图标,按钮可用且被悬停或键盘聚焦时换成圆形 ×——点击执行 `/plan off`。否则座位保持为空:未组合 plan-mode 的宿主,或尚无会话的 Draft,都不显示任何内容。plan 模式为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——「describe your task to generate plan」——除非所属 surface 提供自己的 placeholder。 ### 查看已提交的计划 @@ -51,7 +51,7 @@ kind: "package-reference" 徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。 -计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 +计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在座位出现在屏幕上后才执行:全局面板打开期间到达的审批,会在返回时与座位在同一次提交里先挂载、早于座位完成绑定。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx index 3b2e03951b..7ac1ad09f8 100644 --- a/packages/client/ui-plan/src/client/PlanCard.tsx +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -4,8 +4,9 @@ import { FileTypeIcon, IconChevronRightOutlineRegular } from '@deepseek-ai/dsh-c import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { shallowEqual } from '@deepseek-ai/dsh-client-store' import type {} from './plan-definition.ts' -import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client' import type { createPlanReviewStore } from './review-store.ts' import css from './PlanPreview.module.css' @@ -20,6 +21,10 @@ export interface PlanOpenInjected { export interface PlanReviewOpenInjected { /** Open the logged plan, or the request's temporary document when no invocation exists. */ openReview: (review: PropsRuntime<'conversation.plan-review.actions'>['review'], requestKey: string) => void + readonly hooks: { + /** The session whose right Sidebar seat is mounted; `undefined` while none is on screen. */ + readonly sidebarMounted: HostObservable + } } /** @@ -52,17 +57,23 @@ export function PlanCards({ turn, useChat, openPlan, t }: PropsRuntime<'conversa /** * Open each pending plan automatically and retain a manual opener without answering it. + * + * The automatic open waits for a mounted Sidebar seat: a review that arrives + * while the Conversation is off screen mounts in the same commit as the seat, + * ahead of it, and the seat binds from its own effect. Reading the bound + * session through the hook opens once that binding exists. * @param props - Review identity, Session store, localized copy, and navigation. * @returns an opener for either logged or temporary plan text. */ -export function PlanReviewOpen({ review, requestKey, openReview, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace & PropsLocale<'plan'> & PropsStore>) { +export function PlanReviewOpen({ review, requestKey, openReview, useSidebarMounted, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace & PropsLocale<'plan'> & PropsStore>) { const identity = review.callId === undefined ? `review:${requestKey}` : `call:${review.callId}` const opened = useStore(state => state.opened[identity] === true) + const mounted = useSidebarMounted(session => session !== undefined) useEffect(() => { - if (opened) return + if (opened || !mounted) return openReview(review, requestKey) actions.markOpened(identity) - }, [identity, opened, openReview, review, requestKey, actions]) + }, [identity, opened, mounted, openReview, review, requestKey, actions]) return } diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index 793ca4e5d6..7784f0ac08 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -1,5 +1,9 @@ /* Active plan status: a business accent pill at the control row's own height - (28px, like the + control and the sibling selects). */ + (28px, like the + control and the sibling selects). The radius is half that + height — the compact Button geometry — under the theme's corner curvature, + the same corners as the sibling access-mode trigger; a 999px pill would + need the circular `corner-shape: round` opt-out and read rounder than + every neighbouring control. */ .wrap { display: inline-flex; @@ -15,8 +19,7 @@ min-width: 34px; padding: 0 8px; border: none; - border-radius: 999px; - corner-shape: round; + border-radius: 14px; background: var(--dsw-alias-state-business-tertiary); color: var(--dsw-alias-state-business-primary); font-size: 13px; @@ -43,12 +46,28 @@ cursor: default; } -.close { +/* One 14px glyph slot ahead of the label (the access-mode trigger's layout): + the plan glyph at rest, the circled close while the enabled chip is hovered + or keyboard-focused, marking the pending click as turning plan mode off. */ +.glyph { display: inline-flex; - align-items: center; + flex: 0 0 auto; + width: 14px; + height: 14px; color: currentColor; } +.hoverGlyph, +.chip:hover:not(:disabled) .restGlyph, +.chip:focus-visible .restGlyph { + display: none; +} + +.chip:hover:not(:disabled) .hoverGlyph, +.chip:focus-visible .hoverGlyph { + display: block; +} + .error { color: var(--dsw-alias-state-error-primary); font-size: 12px; diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx index 8cbd71d878..30bb1a2972 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.tsx +++ b/packages/client/ui-plan/src/client/PlanModeControl.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseCircleFillRegular, IconPlanOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and // its {locked} owner share). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -58,10 +58,11 @@ export function PlanChip({ useProjection, locked, exitPlanMode, t }: PlanChipPro disabled={locked || leaving} onClick={off} > - {t('chip.label')} - - + + + + {t('chip.label')} {error !== null && {t('chip.exitFailed')}} diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index bd0a270474..d5366e8d46 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -88,6 +88,7 @@ export function apply(ctx: ClientContext): void { params: { planReview: { markdown: review.plan, title: extractMarkdownPlainText(review.plan, { mode: 'first-line' }) } }, }) }, + hooks: { sidebarMounted: ctx.sidebarRight.mounted }, }), }, PlanReviewOpen)) ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index 71bc23a044..ce6a761fe1 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { PlanChip } from '../src/client/PlanModeControl.tsx' import { PlanCards, PlanReviewOpen, type PlanOpenInjected, type PlanReviewOpenInjected } from '../src/client/PlanCard.tsx' @@ -31,13 +32,14 @@ function providePreview(ctx: Context) { const registerType = vi.fn(() => removeType) const openResourceIn = vi.fn() const openResource = vi.fn() + const mounted = createSnapshotStore(undefined) const subagentAddress = vi.fn(() => undefined) ctx.provide('sessions', { subagentAddress }) ctx.provide('resources', { register: vi.fn(() => removeResources) }) ctx.provide('sidebarRightTabs', { register: registerType }) - ctx.provide('sidebarRight', { openResourceIn, openResource }) + ctx.provide('sidebarRight', { openResourceIn, openResource, mounted }) ctx.provide('remote.session', {}) - return { events, removeResources, removeType, registerType, openResourceIn, openResource, subagentAddress } + return { events, removeResources, removeType, registerType, openResourceIn, openResource, mounted, subagentAddress } } const SID = 's-plan' as SessionId @@ -183,6 +185,8 @@ describe('ui-plan browser apply', () => { const review = b.slots.entries('conversation.plan-review.actions')[0]! expect(review.component).toBe(PlanReviewOpen) const reviewInjected = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(SID) + // The automatic open reads the service's mounted-seat source, not a copy. + expect(reviewInjected.hooks.sidebarMounted).toBe(b.mounted) const pending = { id: 'review', question: 'Approve?', plan: plan.markdown, callId: plan.callId, approve: { label: 'Approve' } } reviewInjected.openReview(pending, 'question:1') expect(b.openResource).toHaveBeenLastCalledWith(address) diff --git a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx index 5e1e0fa286..5348e816b2 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx @@ -11,6 +11,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/client' import { PlanChip, type PlanChipProps } from '../src/client/PlanModeControl.tsx' +import css from '../src/client/PlanModeControl.module.css' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '../src/client/locales.ts' @@ -54,6 +55,21 @@ describe('PlanChip', () => { expect(chip().textContent).toBe('计划') }) + it('leads with the plan glyph and swaps in the circled close only while hovered', () => { + setup({ active: true, pending: false }) + // Glyph-then-label like the sibling access-mode trigger; the two glyphs + // share one slot and CSS shows exactly one of them, so no trailing cross. + const glyphs = chip().querySelectorAll('svg') + expect(glyphs).toHaveLength(2) + const slot = glyphs[0]!.parentElement! + expect(slot).toBe(chip().firstElementChild) + expect(slot.getAttribute('aria-hidden')).toBe('true') + expect(glyphs[0]!.classList.contains(css.restGlyph!)).toBe(true) + expect(glyphs[1]!.classList.contains(css.hoverGlyph!)).toBe(true) + expect(glyphs[1]!.parentElement).toBe(slot) + expect(chip().lastChild?.textContent).toBe('计划') + }) + it('executes /plan off once and follows the projection down', async () => { let resolve!: (value: string | null) => void const exitPlanMode = vi.fn(() => new Promise((done) => { resolve = done })) diff --git a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx index e0efcb40ef..4fb47890c6 100644 --- a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' @@ -7,6 +7,7 @@ import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client' import { chatViewDefinition } from '../../ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { en, zh } from '../src/client/locales.ts' import { PlanCards, PlanReviewOpen } from '../src/client/PlanCard.tsx' @@ -29,6 +30,16 @@ function planNode(data: typeof plan) { function planHook(nodes: readonly unknown[]): Parameters[0]['useChat'] { return ((select: (snapshot: unknown) => unknown) => select({ nodes: { values: () => nodes } })) as Parameters[0]['useChat'] } +/** The opener's props share; each test supplies the seat hook beside it. */ +function reviewProps(review: { plan: string; callId?: typeof plan.callId }, openReview: Mock, store: ReturnType['create']>) { + return { review, requestKey: 'question:1', t, openReview, actions: store.actions, + useStore: (select: (state: ReturnType) => unknown) => select(store.getSnapshot()), + } as unknown as Parameters[0] +} +/** A bound `useSidebarMounted` reading one observable seat value. */ +function seatHook(read: () => SessionId | undefined) { + return (select: (session: SessionId | undefined) => S): S => select(read()) +} describe('submitted plan identity', () => { it('reads native, running PTC, and settled PTC calls', () => { @@ -164,9 +175,7 @@ describe('plan entry points and document', () => { const openReview = vi.fn() const store = createPlanReviewStore().create() const review = { plan: markdown, ...(logged ? { callId: plan.callId } : {}) } - const props = { review, requestKey: 'question:1', t, openReview, actions: store.actions, - useStore: (select: (state: ReturnType) => unknown) => select(store.getSnapshot()), - } as unknown as Parameters[0] + const props = { ...reviewProps(review, openReview, store), useSidebarMounted: seatHook(() => target.session.sessionId) } const first = render() expect(openReview).toHaveBeenCalledExactlyOnceWith(review, 'question:1') first.rerender() @@ -186,6 +195,31 @@ describe('plan entry points and document', () => { } as Parameters[0]} />) expect(openReview).toHaveBeenCalledTimes(4) }) + it('waits for a mounted sidebar seat before opening automatically, then opens once', () => { + // The review and the seat mount in one commit, the review first: its effect + // runs while no seat is bound, and the seat's own effect binds afterwards. + const openReview = vi.fn() + const store = createPlanReviewStore().create() + const mounted = createSnapshotStore(undefined) + const review = { plan: markdown, callId: plan.callId } + const props = { ...reviewProps(review, openReview, store), useSidebarMounted: seatHook(() => mounted.getSnapshot()) } + const view = render() + expect(openReview).not.toHaveBeenCalled() + expect(store.getSnapshot().opened).toEqual({}) + // The manual opener stays available without a seat; the service decides what to do. + fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' })) + expect(openReview).toHaveBeenCalledTimes(1) + mounted.set(target.session.sessionId) + view.rerender() + expect(openReview).toHaveBeenCalledTimes(2) + expect(store.getSnapshot().opened).toEqual({ [`call:${plan.callId}`]: true }) + view.rerender() + mounted.set(undefined) + view.rerender() + mounted.set(target.session.sessionId) + view.rerender() + expect(openReview).toHaveBeenCalledTimes(2) + }) it('renders temporary Markdown and reports expired navigation after reload', () => { const address = reviewPreviewAddress(target.session.sessionId, 'window:question:1') expect(isReviewPreviewAddress(address)).toBe(true) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 4f926c7e00..1a31577bb3 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -278,6 +278,24 @@ export const IconCloseFillMedium = (props: IconProps) => ( ) +// A 7-unit disc with the cross knocked out of it (even-odd), so the cross +// takes the color of whatever sits behind the glyph. +const IconCloseCircleFillArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular IconCloseCircleFill artwork (cross knocked out of a filled disc); its fill-only geometry is weight-independent. */ +export const IconCloseCircleFillRegular = (props: IconProps) => ( + +) + +/** Medium IconCloseCircleFill artwork; it matches Regular because the geometry is fill-only. */ +export const IconCloseCircleFillMedium = (props: IconProps) => ( + +) + const IconCopyOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (