Merge remote-tracking branch 'origin/feat/persistent-ptc' into feat/experimental-dsh-cua
This commit is contained in:
commit
b44230bdbe
353 files changed
+7356
-1080
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
|
||||
2026-08-09-client-conversation-node-assembly.md: bb1d68cc045710d46a44ffb295c2307628338521
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: 6291211d589b3389e7dc9a42d15fe6ac0d9e7ab9
|
||||
2026-08-09-client-conversation-node-assembly.md: c8773b77fe761c3a566e02f0bd6aeb18f26e0a89
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: 076ad35178996e01eae26b3909feae8d967414c1
|
||||
@@ -14,6 +14,8 @@ Business events also use different correlation models. Tool has call IDs, Assist
|
||||
|
||||
## Decision
|
||||
|
||||
The [three-stage Tool lifecycle](2026-09-22-tool-call-three-phases.md) owns transient initialization, repeated start matches for one ID, and tool preparation; this record's other node-assembly responsibilities remain applicable.
|
||||
|
||||
Client Runtime provides a target-neutral Conversation Node assembly engine. Business plugins register Event Definitions, and view plugins register per-Session View Builders. `ui-conversation` registers the first built-in Definitions and the `chat` builder; Session only submits the current contiguous `SessionEventLikeEntry` window to the engine and publishes its snapshot instead of interpreting individual conversation businesses. The entry's outer discriminator distinguishes standard and packed records, while both carry an aligned inner `SessionEventLike` for Definition dispatch.
|
||||
|
||||
This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation.
|
||||
|
||||
+2
@@ -14,6 +14,8 @@ Client Session 既维护传输窗口、连接状态和待处理交互,也在
|
||||
|
||||
## 决策
|
||||
|
||||
start 的瞬态创建入口、同 ID 多次 start 及工具准备阶段以[工具调用三阶段](2026-09-22-tool-call-three-phases.zh.md)为准;本记录的其他节点组装职责继续适用。
|
||||
|
||||
Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builder;Session 只负责把当前连续 `SessionEventLikeEntry` window 送入引擎并发布它的 snapshot,且不解释具体 conversation 业务。entry 的外层 discriminator 区分标准与 packed record,两者都携带字段对齐的内部 `SessionEventLike`,供 Definition dispatch。
|
||||
|
||||
本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md
|
||||
2026-09-11-sandboxed-node-ptc-runtime.md: f851775460c5bd01760d31552bde8c691807ec06
|
||||
2026-09-11-sandboxed-node-ptc-runtime.zh.md: 94f3a37eb702a97161c1d08cec531fcffb3460f2
|
||||
2026-09-11-sandboxed-node-ptc-runtime.md: 7efe06e8a4a48b2c1d4dcb672abc09961f3a2b14
|
||||
2026-09-11-sandboxed-node-ptc-runtime.zh.md: 2382b02247ebe6e0c524cd89fa64c8e59cf49797
|
||||
@@ -34,6 +34,8 @@ Program completion, timeout, cancellation and protocol failure all close executi
|
||||
|
||||
The default elapsed deadline is 120 seconds, capped at 600 seconds by default. Trusted service consumers may request `timeoutMs: null` to disable this timer; [workflow sandbox reuse](2026-09-13-workflow-ptc-sandbox-reuse.md) owns that caller-controlled lifetime. Omitted and numeric requests, including model-facing `run_code`, retain the numeric defaults and caps. An enabled deadline includes runtime setup and nested tool or approval waits. V8 old-generation memory, serialized outer output and control traffic have separate configured bounds. The heap limit excludes native allocations and descendant memory, and elapsed time is not a process-tree CPU budget.
|
||||
|
||||
The separate [best-effort persistent provider](2026-09-23-best-effort-persistent-ptc.md) retains disposable memory under explicit full access; this decision continues to own the default confined provider.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the worker and add tool checks.** Tool checks cannot intercept direct Node imports or establish OS confinement. Keeping a worker inside a confined supervisor process preserves worker metering but adds another execution lifetime without supplying a process-tree CPU limit.
|
||||
|
||||
@@ -34,6 +34,8 @@ Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前
|
||||
|
||||
默认经过时间截止为 120 秒,默认上限为 600 秒。可信服务消费方可以请求 `timeoutMs: null` 来禁用该定时器;[工作流沙箱复用](2026-09-13-workflow-ptc-sandbox-reuse.zh.md)负责这种由调用方控制的生命周期。省略 timeout 或使用数值的请求(包括面向模型的 `run_code`)保留数值默认值与上限。启用的截止包括运行时准备以及嵌套工具或审批等待。V8 老生代内存、序列化外层输出与控制通信具有独立配置的上限。堆限制不包含原生分配和后代内存,经过时间也不是进程树 CPU 预算。
|
||||
|
||||
独立的[尽力持久提供方](2026-09-23-best-effort-persistent-ptc.zh.md)在显式完全访问下保留可丢失的内存;本决定继续负责默认的受约束提供方。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留 worker 并增加工具检查。** 工具检查不能拦截直接 Node 导入,也不能建立 OS 约束。在受限监督进程中保留 worker 可以保留 worker 计量,但会增加一层执行生命周期,仍不能提供进程树 CPU 限制。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md
|
||||
2026-09-15-guided-plugin-installation.md: 451b1f316eebfdce9c9efcffbf48202177d04343
|
||||
2026-09-15-guided-plugin-installation.zh.md: 0c228695828238b4dbfdae7d2153e0358e92360c
|
||||
2026-09-15-guided-plugin-installation.md: 68bd519b7cb4342c42761dfaef53940c83ceb2d8
|
||||
2026-09-15-guided-plugin-installation.zh.md: f3cbefedeb151d37078b55140635b69516fafed2
|
||||
@@ -14,7 +14,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
|
||||
|
||||
**A failed or cancelled installation restores the profile files.** `installBundle` snapshots `package.json` and `pnpm-lock.yaml` before pnpm runs and puts them back when pnpm fails, when the run is cancelled, or when the package pnpm added declares no bundle patch. This reverses, for installations only, the [manager's decision](2026-09-14-current-profile-plugin-management.md) to retain partial changes: an installation that did not produce a usable bundle must not leave a dependency the page cannot show. Removals keep that decision. Downloaded files can stay under `node_modules` and the pnpm store.
|
||||
|
||||
**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
|
||||
**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. A run the manager terminated is classified `timeout` whatever exit status the signal left behind, so installation restores the files and removal reports failure instead of reading it as a success ([run bounds](../bug-fix/2026-09-23-bounded-pnpm-runs.md)). The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
|
||||
|
||||
**GitHub connection failures have a short budget before installation.** The Host runs a read-only `git ls-remote` check with the profile's Git and proxy configuration and a default `githubConnectionTimeoutMs` of 5000 ms. Credential helpers and prompts are disabled for this check, so authentication cannot consume the connection budget. Only network failures and timeouts prevent pnpm from starting; pnpm still owns authentication, HTTPS-to-SSH fallback, ref resolution and the full installation. The budget does not limit downloads or builds. The existing failure kind and `failedAt: 'spec-host'` let the Client show a connection error or timeout and offer a domestic registry for a replacement package spec. Cancellation and manager disposal terminate the check and its descendants before settling.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
**失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。
|
||||
|
||||
**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
|
||||
**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。被管理器终止的运行,不论信号留下什么退出状态都归类为 `timeout`,因此安装会还原文件,删除则报告失败而不会读成成功([运行上界](../bug-fix/2026-09-23-bounded-pnpm-runs.zh.md))。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
|
||||
|
||||
**GitHub 连接失败在安装前有单独的短时限。** 宿主通过只读的 `git ls-remote` 检查连接,沿用 profile 的 Git 与代理配置,`githubConnectionTimeoutMs` 默认为 5000 毫秒。这次检查禁用凭据助手和认证提示,避免认证过程占用连接预算。只有网络失败与超时会阻止 pnpm 启动;认证、HTTPS 到 SSH 的回退、ref 解析及完整安装仍由 pnpm 负责。时限不约束下载或构建。已有的失败类型与 `failedAt: 'spec-host'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。
|
||||
|
||||
|
||||
@@ -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-22-tool-call-three-phases.md
|
||||
2026-09-22-tool-call-three-phases.md: e7b42a4ed9e340b73257f7053bddd5221ccc0d20
|
||||
2026-09-22-tool-call-three-phases.zh.md: 5a68575fd8e2a68199cbedf1ac180a22254b7c54
|
||||
@@ -0,0 +1,67 @@
|
||||
# Agent Note: Tool calls own preparation, dispatch, and results by callId
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-tool-call-three-phases.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The model often provides a tool name and call ID while it is still generating arguments, but `tool/call` arrives only after the entire Assistant stream ends. When one response contains several calls, an earlier call waits for later calls even after its own arguments are complete. File content is part of the arguments to `write`, so this delay can last several seconds.
|
||||
|
||||
Creating tool nodes only at `tool/call` presents that interval as no tool activity. Putting a preparing call in an Assistant part instead requires the Group to switch references between Assistant and Tool nodes. Preparation and dispatch belong to one tool lifecycle and do not require two cooperating nodes.
|
||||
|
||||
## Decision
|
||||
|
||||
### One Tool owns three stages
|
||||
|
||||
The [Tool Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts) groups calls by callId and supplies explicit stage data to tool components.
|
||||
|
||||
| Stage | Creation or update evidence | Available data and presentation |
|
||||
|---|---|---|
|
||||
| `preparing` | A live delta with a call ID and tool name | Call identity, name, and time; no argument body, and one non-expandable row. |
|
||||
| `start` | `tool/call` | Complete arguments; enables the tool's existing call presentation. |
|
||||
| `result` | `tool/result` | The result and any complete arguments paired within the loaded window. |
|
||||
|
||||
An argument block ending does not trigger dispatch presentation. Showing preparation changes neither model requests, tool dispatch timing, Session events, nor the persisted format. An empty argument string does not stand in for a dispatched call.
|
||||
|
||||
### Start marks an initialization candidate
|
||||
|
||||
The [Conversation Definition](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) retains its match/start/update interface and existing roles. Durable events and transient deltas may both match as start. The earliest start in the current event sequence initializes State; every later Match, including another start for the same ID, goes through update.
|
||||
|
||||
A named tool delta and `tool/call` are both initialization candidates. A live delta can create preparation; a historical `tool/call` creates the dispatched stage directly. Matching still reads only the current event, without Context lookup, additional roles, registries, or separate caches.
|
||||
|
||||
When stream settlement removes temporary deltas, the Assembler selects the start from the remaining Matches, refreshes predecessor indexes, and recomputes State. A published preparation node hides when no call evidence remains; a later dispatch retains the same callId. Undispatched calls do not receive synthetic execution results.
|
||||
|
||||
### Converge on final state without replaying preparation
|
||||
|
||||
Historical pages do not expand settled Assistant messages into live deltas. Reconnection restores only an attempt that is still live. Live presentation may follow preparing → start → result, while history follows start → result directly. Equal durable events produce equal final state; intermediate presentation and preparation-group identity need not match.
|
||||
|
||||
Groups include Tool nodes from preparation onward and classify and count them by tool name. AssistantNodeView does not search tool blocks, and Groups neither encode tool groupPart values nor take over tool lifecycles. Grouping retains the [node-input grouping decision](2026-09-21-conversation-build-groups.md); the Builder does not interpret preparation business rules.
|
||||
|
||||
### Shared rows reuse their existing presentation
|
||||
|
||||
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model selects the tool title and combines any generic tool-name prefix with the available argument summary. Preparation has no arguments or result, and the shared argument parser returns null without parsing JSON. ToolRow prevents preparation rows from expanding.
|
||||
|
||||
Read, read_image, write/edit, search, web, todo, question, details, and the generic fallback use this path. Custom rows such as Bash, Skill, Present, and Cordis keep their own preparation branches. No automatic/custom registration declaration or second tool-classification list in ToolTree is introduced.
|
||||
|
||||
### Relationship to existing records
|
||||
|
||||
This decision replaces the [business-node assembly](2026-08-09-client-conversation-node-assembly.md) restriction that starts must be durable and a Context may receive only one start. That record's stable IDs, business Definitions, Contexts, Locations, predecessor dependencies, and target Builder responsibilities remain applicable.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Create nodes only at `tool/call`.** This cannot show a known tool identity while arguments are being generated and leaves a visible wait in multi-call responses.
|
||||
|
||||
**Transfer presentation through Assistant parts and Groups.** A tool would need two presentation sources, additional deduplication, and identity transfer. Lifecycle ownership becomes distributed, and renderers must recover Assistant content from part identifiers.
|
||||
|
||||
**Persist or replay preparing.** Preparation describes current generation; durable calls and results already suffice to reconstruct the final presentation. Reproducing the same intermediate process adds unnecessary state and event interpretation.
|
||||
|
||||
**Add a preparation registration mechanism.** Generic tools already share ToolRow and argument models, including reusable icons and titles. Additional declarations or configuration for this stage would maintain another representation of tool presentation.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A tool may appear before its arguments are complete, but preparation neither implies execution nor exposes interactions requiring arguments.
|
||||
- Multiple starts with one ID belong to one lifecycle. Independent calls must use distinct IDs; a second-start error no longer detects identity reuse.
|
||||
- Shared rows do not replace the whole row component when preparation becomes dispatch. Custom rows may still switch their internal components by stage.
|
||||
- Partial JSON parsing and `useToolCallDelta` are not provided; argument-driven early presentation is outside this implementation.
|
||||
- Assembly tests separately cover live preparation, withdrawal, active-stream reconstruction, durable history, and paged convergence. Component tests cover argument-free presentation and stage changes; a recorded session covers browser preparation and reload results.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Agent Note: 工具调用按 callId 管理准备、派发与结果
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-tool-call-three-phases.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
模型生成工具参数时,工具名和调用 ID 往往已经可用,但 `tool/call` 要等整条 Assistant 流结束后才到达。一次响应包含多个调用时,前一个调用即使已经生成完整参数,仍要等待后续调用。文件正文也属于 `write` 参数,这段等待可能持续数秒。
|
||||
|
||||
只从 `tool/call` 创建工具节点,会把这段时间表现为没有工具活动;把准备中的调用放在 Assistant 片段里,又需要 Group 在 Assistant 与正式 Tool 节点之间切换引用。准备态和正式调用属于同一个工具生命周期,不应由两个节点配合表达。
|
||||
|
||||
## 决策
|
||||
|
||||
### 同一个 Tool 拥有三个阶段
|
||||
|
||||
[Tool Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts) 按 callId 聚合调用,向工具组件提供明确的阶段数据。
|
||||
|
||||
| 阶段 | 创建或更新依据 | 可用数据与展示 |
|
||||
|---|---|---|
|
||||
| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称和时间;不提供参数正文,只显示不可展开的一行。 |
|
||||
| `start` | `tool/call` | 完整参数;启用工具原有的调用展示。 |
|
||||
| `result` | `tool/result` | 调用结果,以及当前窗口内可配对的完整参数。 |
|
||||
|
||||
参数块结束不触发派发阶段。展示准备态不改变模型请求、工具派发时机、Session 事件或持久化格式,也不把空参数字符串伪装成正式调用。
|
||||
|
||||
### start 表示可创建入口
|
||||
|
||||
沿用 [Conversation Definition](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 的 `match/start/update` 接口和既有角色。持久事件与瞬态 delta 都可以匹配为 start;当前事件序列中最早的 start 初始化 State,后续 Match,包括同 ID 的其他 start,都交给 update。
|
||||
|
||||
Tool 的具名 delta 和 `tool/call` 都是创建入口。实时先收到 delta,就从 preparing 创建;历史先收到 `tool/call`,就从 start 创建。匹配函数仍然只读取当前事件,不查询 Context,不增加角色、注册表或旁路缓存。
|
||||
|
||||
流结束移除临时 delta 后,Assembler 从剩余 Match 重新选择 start,刷新前序索引并重算 State。没有剩余调用证据时,已发布的准备节点隐藏;后续正式调用仍使用相同 callId。未派发的调用不合成执行结果。
|
||||
|
||||
### 最终状态收敛,不重现准备过程
|
||||
|
||||
历史分页不把已结束 Assistant 消息展开为实时 delta。重连只恢复仍在生成的活跃流。实时展示可以经历 preparing → start → result,历史回放可以直接经历 start → result;相同持久事件得到相同最终状态即可,不要求中间过程或准备态分组身份一致。
|
||||
|
||||
Group 从准备阶段起就聚合 Tool 节点,按工具名分类和计数。AssistantNodeView 不查找工具块,Group 不编码工具 groupPart,也不接替工具生命周期。分组仍遵循[节点输入的分组决策](2026-09-21-conversation-build-groups.zh.md),Builder 不解释准备态业务。
|
||||
|
||||
### 通用行复用现有外壳
|
||||
|
||||
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型选择工具标题,并组合通用工具名前缀与已有参数摘要。准备阶段没有参数或结果,共享参数解析入口直接返回 null,不解析 JSON;ToolRow 禁止准备行展开。
|
||||
|
||||
read、read_image、write/edit、search、web、todo、question、details 和通用回退使用这条路径。Bash、Skill、Present、Cordis 等自定义外壳保留自己的准备态分支。不增加自动/自管注册声明,不在 ToolTree 维护另一份工具分类名单。
|
||||
|
||||
### 与已有记录的关系
|
||||
|
||||
本记录替代[业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md)中“start 只能是持久事件,同一 Context 只能收到一次 start”的限制。该记录的稳定 ID、业务 Definition、Context、Location、前序依赖和目标 Builder 职责继续适用。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只在 `tool/call` 创建节点。** 无法展示参数生成期间已经确定的工具身份,多调用响应仍有明显空等。
|
||||
|
||||
**通过 Assistant 片段和 Group 转接。** 工具需要两种展示来源、额外去重和身份衔接;生命周期归属分散,渲染器还要根据片段标识反查 Assistant 内容。
|
||||
|
||||
**保存或回放 preparing。** 准备态只描述当前生成过程,持久调用和结果已经足以重建最终展示;恢复相同中间过程会增加不必要的状态和事件解释。
|
||||
|
||||
**新增准备态注册机制。** 通用工具已经共享 ToolRow 和参数模型,已有图标与标题可以直接复用。仅为这一阶段增加声明或配置,会多维护一套工具展示信息。
|
||||
|
||||
## 影响
|
||||
|
||||
- 工具可以在参数完整前出现,但准备展示不表示工具已经执行,也不暴露依赖参数的交互。
|
||||
- 同一 ID 的多个 start 被视为同一个生命周期;业务必须为独立调用提供不同 ID,不能再依赖第二个 start 报错检测身份复用。
|
||||
- 通用行不因准备/派发分支替换整行组件;自定义外壳仍可按阶段切换内部组件。
|
||||
- 不提供部分 JSON 解析或 `useToolCallDelta`,参数驱动的提前展示不属于本次实现。
|
||||
- 组装测试分别覆盖实时准备态、撤回、活跃流重建、持久历史与分页收敛;组件测试检查无参数展示和阶段切换,录制会话覆盖浏览器中的准备态与重载结果。
|
||||
@@ -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-23-best-effort-persistent-ptc.md
|
||||
2026-09-23-best-effort-persistent-ptc.md: ab35a716ff6c0c7330e0c62500b84ee6173b988a
|
||||
2026-09-23-best-effort-persistent-ptc.zh.md: 0ec7ae1f7c789eadd87c80365354f914fc720b98
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Best-effort persistent PTC memory
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-23-best-effort-persistent-ptc.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Interactive automation retains application objects, imported modules and helper functions between model calls. Recreating every object for every program prevents consumers from using ordinary JavaScript references across observations and actions.
|
||||
|
||||
## Decision
|
||||
|
||||
The optional `dsh-ptc-runtime-node/persistent` provider keeps an unsandboxed Node REPL process per explicit, object-identity state owner. PTC uses the live Agent as owner and awaits release during its context disposal. The default [sandboxed provider](2026-09-11-sandboxed-node-ptc-runtime.md) retains its fresh-process behavior. This extends the [PTC foundation](../feature/2026-06-15-ptc.md) with disposable memory, while preserving registry dispatch and logged model requests.
|
||||
|
||||
The Session records submitted programs, nested tool calls and returned output. It does not serialize the REPL heap or rerun earlier programs after restart, resume or fork: replaying automation could repeat external side effects. A missing variable is a normal program error, and the model-visible instructions require recreating variables and reacquiring resources. No environment identifier or new durable Session event is required because the provider exposes no unlogged model input.
|
||||
|
||||
Cancellation, deadline expiry, protocol or output-limit failure, owner disposal and process exit discard the environment. Ordinary exceptions retain earlier mutations. A retained function uses the active evaluation's host bindings; asynchronous work from an earlier evaluation cannot acquire a later evaluation's tool authority or output sink.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Serializing arbitrary JavaScript objects cannot preserve native resources and closures. Replaying prior programs can repeat writes and input actions. Both would imply recovery guarantees that this provider does not offer.
|
||||
|
||||
A realm inside the Harness process shares process lifetime with model code and cannot terminate a synchronous loop independently. A managed child process permits cancellation and resource cleanup while retaining imports and variables during healthy use. This process is not a file sandbox or a multi-tenant security boundary.
|
||||
|
||||
## Consequences
|
||||
|
||||
Enabling the provider grants direct Node code full host access. It is opt-in, rejects explicit sandbox policies, and leaves nested tools under their normal authorization and logging. Memory and native handles may disappear without a Harness restart. The model must recover from reference or resource errors; the runtime never retries a program automatically.
|
||||
|
||||
An idle owner retains a process with no expiration timer. Callers release owners explicitly; ownerless service calls use temporary processes. Programs use REPL statements and a final expression rather than top-level return, and calls for one retained owner cannot overlap.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 尽力保留的持久 PTC 内存
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-23-best-effort-persistent-ptc.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
交互式自动化需要在模型调用之间保留应用对象、导入模块和辅助函数。每个程序都重新创建对象,会使消费者无法在观察和操作之间使用普通 JavaScript 引用。
|
||||
|
||||
## Decision
|
||||
|
||||
可选提供方 `dsh-ptc-runtime-node/persistent` 为每个以对象身份标识的显式状态所有者保留无沙盒 Node REPL 进程。PTC 使用存活 Agent 作为所有者,并在其上下文销毁时等待释放。默认的[沙盒提供方](2026-09-11-sandboxed-node-ptc-runtime.zh.md)保留每次启动新进程的行为。此决定为 [PTC 基础](../feature/2026-06-15-ptc.zh.md)增加可丢失的内存,同时保留注册表分派和模型请求日志。
|
||||
|
||||
Session 记录提交的程序、嵌套工具调用和返回输出。它不序列化 REPL 堆,也不在重启、恢复或派生后重跑以前的程序,因为回放自动化可能重复外部副作用。缺失变量是普通程序错误,模型可见说明要求重新创建变量和获取资源。提供方没有暴露未记录的模型输入,因此不需要环境标识符或新的持久 Session 事件。
|
||||
|
||||
取消、截止到期、协议失败、输出超限、所有者销毁和进程退出会丢弃环境。普通异常保留此前发生的修改。保留的函数使用当前执行的宿主绑定;较早执行产生的异步工作不能取得后续执行的工具权限或输出接收器。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
序列化任意 JavaScript 对象无法保留原生资源和闭包。回放以前的程序可能重复写入和输入操作。这两种方案都会隐含本提供方没有承诺的恢复保证。
|
||||
|
||||
Harness 进程内的执行域与模型代码共享进程寿命,无法单独终止同步循环。受管子进程允许取消和资源清理,同时在正常使用时保留导入模块和变量。该进程不提供文件沙盒或多租户安全隔离。
|
||||
|
||||
## Consequences
|
||||
|
||||
启用提供方会允许直接 Node 代码完全访问宿主。它需要显式启用,拒绝显式沙盒策略,嵌套工具继续遵循原有授权和日志规则。即使 Harness 没有重启,内存和原生句柄也可能丢失。模型必须从引用错误或资源错误中恢复;运行时不会自动重试程序。
|
||||
|
||||
空闲所有者保留进程,没有过期计时器。调用方显式释放所有者;没有所有者的服务调用使用临时进程。程序使用 REPL 语句和末尾表达式,不能使用顶层 return;同一保留环境的调用不能重叠。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md
|
||||
2026-09-22-windows-open-through-shell-resolution.md: 7bfe559e7f26b96fa37d3bbed44f0403d4e51b59
|
||||
2026-09-22-windows-open-through-shell-resolution.zh.md: ff33455da1653f9d24f9ddcc21c1594e8875bb5e
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Windows opens go through explorer.exe
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-windows-open-through-shell-resolution.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Windows desktop client's settings sheet offers **Open configuration file**, which prepares the active profile's `cordis.patch.yml` and hands the path to the native text-document opener (issue #4428). On the reporting machine the gesture opened nothing and reported nothing. The opener ran `powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath '<path>'"`, so an association resolved inside the host process decided the outcome, and `Invoke-Item` exits 0 when that resolution finds no application.
|
||||
|
||||
That machine records the `.yml` default only in `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.yml\UserChoiceLatest` (progid `VSCode.yml`), with no `UserChoice` record and no `HKCU\Software\Classes\.yml` default. Explorer opens VS Code on a double-click of the same file. Run from the host process, `Invoke-Item`, `cmd /c start`, the `Shell.Application` default verb, and `AssocQueryString` all reported no application for that extension, and `cmd /c start` displayed the shell's own application chooser instead of opening one. `Invoke-Item` still exited 0, so `SettingsController.openSettingsDocument` answered `{ opened: true }` and the sheet rendered no error.
|
||||
|
||||
## Decision
|
||||
|
||||
`openWindowsPath` hands the path to `explorer.exe` as one argv element, and `revealNativePath` reaches the same shell through the shared `runExplorer` helper. Both encode the target as a file URI with its commas and equals signs percent-encoded, because Explorer parses its own command line and splits fields at both — a raw path containing either opens a different target without reporting it. Nothing else is escaped: Explorer rejects percent-encoded non-ASCII in a file URI and falls back to Documents, so those characters reach it literally. Explorer performs the default-application resolution a double-click performs, so the shell's answer — including a default recorded only in the newer per-user record — selects the application, and a machine with no handler at all gets Explorer's own "How do you want to open this file?" chooser instead of silence.
|
||||
|
||||
`runExplorer` holds the one Explorer invocation rule for both operations: exit code 1 is the handoff Explorer returns after passing the request to the desktop process already running, and it resolves the caller's promise; every other failure and every cancellation still rejects. The helper replaced the PowerShell command string, and with it `powershellLiteral` and its single-quote doubling: a path crosses the process boundary as an argv element, which removes the shell-layer escaping rather than the encoding Explorer's own parser requires. `explorerTarget` owns that one encoding step for both intents.
|
||||
|
||||
Windows still names no browser for HTML and SVG, so `openInBrowser` continues to decline the platform and the default, association, and text-editor intents all reach the same Explorer handoff.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `Invoke-Item`.** Rejected. It asks a different resolver than the shell does, so a user whose default Explorer honors can still get a silent no-op, and its zero exit code removes the error the sheet would otherwise render.
|
||||
|
||||
**Resolve the default inside the host process.** Rejected. Reading `UserChoiceLatest`, then `UserChoice`, then the classes default, and launching the winning progid's `shell\open\command` reimplements an order Windows owns, including the `UserChoice` hash Windows validates; the shell's own answer is the definition of the application a double-click uses.
|
||||
|
||||
**`cmd /c start <path>`.** Rejected. It needs a command shell for the same resolution Explorer already performs, and it reported no application on the machine that reproduced the defect.
|
||||
|
||||
**Fall back to a fixed editor when no application resolves.** Rejected. A Windows installation always ships a usable editor, but choosing it in the opener would override the user's association and turn a missing association into a silently wrong application.
|
||||
|
||||
## Consequences
|
||||
|
||||
The settings sheet's open gesture and the artifact and workspace opens share one Windows behavior: the application a double-click would launch. A machine whose recorded default Explorer honors but `AssocQueryString` does not — the reported state — now opens the configured editor.
|
||||
|
||||
The opener can no longer distinguish "the shell opened it" from "the shell declined": Explorer reports 0 or 1 for both, so the Remote still answers `{ opened: true }`, and a machine with no handler shows the shell's chooser as its only feedback. Nothing here verifies that a window appeared.
|
||||
|
||||
An interactive desktop session is a precondition. In a non-interactive Windows session (a service, or a scheduled task without an interactive logon) `explorer.exe` invokes no association and still exits 1, so that exit code does not tell this case apart from a delegated open: an SSH session 0 on Windows 11 ARM64 build 26200 measured exit code 1 after about a second, where an `Invoke-Item` command resolved the association in-process. A Host started that way loses the capability and pays the wait, and a caller's abort signal is what bounds it. No fallback for that session is implemented.
|
||||
|
||||
Directories take the same path. The rejected alternative recorded in [the open-in-app note](../feature/2026-08-25-promote-open-anywhere-plugin.md) was a detached, credential-scrubbed `explorer.exe <dir>` spawn, which is not what this handoff does: the `explorer.exe` child inherits the host environment and no detached process is created, while the application or folder window Explorer delegates to starts from the desktop session's environment rather than the host's.
|
||||
|
||||
## Verification
|
||||
|
||||
`path-opener.spec.ts` pins the encoded file URI Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, and the preserved failure and cancellation. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy. On Windows 11 ARM64 build 26200 an independent pass drove the module's own `openNativeAssociatedPath`, `openNativePath`, and `openNativeTextFile` from a sha256-matched copy beside an association probe that recorded every argument the shell passed: each comma-bearing target arrived complete as `%2C`, a plain file opened, and a directory named for a comma opened as a window rooted there. The negative control — the same file as a file URI whose comma is left raw — invoked no association and opened the user's Documents folder, and still exited 1. Exit 1 therefore records the delegation rather than the outcome, so accepting it cannot turn a wrong target into a success; the encoding is what keeps the path whole. Nothing here observes a window. A later pass on the same build read the shell's own window and selection state instead of pixels: the encoded `/select,` target selected the named file; an unencoded `=` opened Documents instead of the file; and a target whose non-ASCII characters stay percent-encoded opened Documents where the literal characters opened the file.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Windows 打开经 explorer.exe 完成
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-windows-open-through-shell-resolution.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Windows 桌面客户端设置页的「打开配置文件」会准备当前 profile 的 `cordis.patch.yml`,并把该路径交给原生文本编辑器打开器(issue #4428)。在报告者机器上,这个动作既没有打开窗口,也没有任何提示。打开器执行的是 `powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath '<path>'"`,因此由宿主进程内的关联解析决定结果,而 `Invoke-Item` 在解析不到应用时同样以退出码 0 结束。
|
||||
|
||||
该机器的 `.yml` 默认值只记录在 `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.yml\UserChoiceLatest`(progid 为 `VSCode.yml`),既没有 `UserChoice` 记录,也没有 `HKCU\Software\Classes\.yml` 默认值。对同一个文件双击,资源管理器会打开 VS Code。而在宿主进程中执行时,`Invoke-Item`、`cmd /c start`、`Shell.Application` 的默认动词和 `AssocQueryString` 都报告该扩展名没有应用,`cmd /c start` 则弹出 shell 自己的应用选择框而不是打开某个应用。`Invoke-Item` 仍然返回 0,于是 `SettingsController.openSettingsDocument` 回答 `{ opened: true }`,设置页不渲染任何错误。
|
||||
|
||||
## 决策
|
||||
|
||||
`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。两者都把目标编码成文件 URI 并对**逗号与等号**做百分号编码,因为资源管理器自己解析命令行并以这两个字符分段——含其中任一字符的原始路径都会打开另一个目标且不作任何提示。其余字符不做转义:资源管理器会拒绝 file URI 里百分号编码的非 ASCII 并回落到「文档」,因此这些字符以字面形式交给它。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。
|
||||
|
||||
`runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径以 argv 元素跨进程边界,去掉的是 shell 层转义,而不是资源管理器自身解析所需的编码;该编码由 `explorerTarget` 为两种意图统一持有。
|
||||
|
||||
Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 继续拒绝该平台,默认意图、关联意图与文本编辑器意图都走同一个 Explorer 交接。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**继续使用 `Invoke-Item`。** 已否决。它询问的解析器与 shell 不同,因此即使某个用户的默认值能被资源管理器识别,仍会得到静默的无效操作;而且它的零退出码正好抹掉了设置页本应渲染的错误。
|
||||
|
||||
**在宿主进程内自行解析默认值。** 已否决。依次读取 `UserChoiceLatest`、`UserChoice` 和 classes 默认值,再启动胜出 progid 的 `shell\open\command`,等于重新实现一套 Windows 自己拥有的顺序,其中还包括 Windows 会校验的 `UserChoice` 哈希;而双击所用应用的定义就是 shell 自己的答案。
|
||||
|
||||
**使用 `cmd /c start <path>`。** 已否决。它为资源管理器本已执行的同一套解析引入一个命令 shell,并且在复现该缺陷的机器上同样报告没有应用。
|
||||
|
||||
**解析不到应用时回退到固定编辑器。** 已否决。Windows 安装总是带有可用的编辑器,但在打开器里选择它等于覆盖用户的关联,并把「缺少关联」变成「静默地打开错误应用」。
|
||||
|
||||
## 后果
|
||||
|
||||
设置页的打开动作与产物、工作区的打开动作共享同一个 Windows 行为:双击会启动的那个应用。对于「资源管理器认、`AssocQueryString` 不认」这一被报告的状态,现在会打开已配置的编辑器。
|
||||
|
||||
打开器再也无法区分「shell 打开了」与「shell 拒绝了」:两种情况 Explorer 都返回 0 或 1,因此 Remote 依旧回答 `{ opened: true }`,完全没有处理程序的机器只能看到 shell 的选择框。此处不校验窗口是否真的出现。
|
||||
|
||||
交互桌面会话现在是前提。在非交互 Windows 会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 不调用任何关联,且同样以退出码 1 返回,因此该退出码无法与「已委派」区分:Windows 11 ARM64 build 26200 的 SSH session 0 实测约 1 秒后返回退出码 1,而同一会话里 `Invoke-Item` 命令能在进程内解析关联。这样启动的 Host 会失去能力且要付出等待,只有调用方的 abort 信号能给这段等待封顶。该会话下的兜底尚未实现。
|
||||
|
||||
目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe <dir>` 派生进程,那不是本交接的做法:`explorer.exe` 子进程继承宿主环境,也不创建 detached 进程;而它转交给桌面会话启动的应用或文件夹窗口,继承的是桌面会话环境而不是宿主进程环境。
|
||||
|
||||
## 验证
|
||||
|
||||
`path-opener.spec.ts` 固定两种意图交给 Explorer 的编码文件 URI、打开与选中都可接受的交接退出码 1,以及仍然报错的普通失败与取消。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows。在 Windows 11 ARM64 build 26200 上,一次独立验证用 sha256 与源码一致的副本直接驱动模块自身的 `openNativeAssociatedPath`、`openNativePath`、`openNativeTextFile`,旁边挂一个记录 shell 实收参数的探针关联:每个含逗号的目标都以 `%2C` 完整到达、普通文件正常打开、名为 `dir,comma` 的目录打开为根目录正确的窗口。反证——同一个文件以逗号未编码的 file URI 交给 `explorer.exe`——没有触发任何关联、打开了用户的「文档」,而且**同样返回退出码 1**。因此退出码 1 记录的是委派而非结果,接受它不会把错误目标变成成功;真正保住路径完整的是那段编码。此处不观测窗口是否出现。同一 build 的后续一轮改为直接读取 shell 自己的窗口与选中状态:编码后的 `/select,` 目标选中了目标文件;未编码的 `=` 会打开「文档」而不是文件;非 ASCII 字符保持百分号编码时同样打开「文档」,改为字面字符则正常打开。
|
||||
@@ -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/bug-fix/2026-09-23-bounded-pnpm-runs.md
|
||||
2026-09-23-bounded-pnpm-runs.md: 7ce6b1776e4587240072201781c9da5bc78650e8
|
||||
2026-09-23-bounded-pnpm-runs.zh.md: 1cb91132cca22a1130fdc48ce3d3dbd354fa5d16
|
||||
@@ -0,0 +1,59 @@
|
||||
# Agent Note: Bounding a pnpm run that stops progressing
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-23-bounded-pnpm-runs.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A profile package operation could hold the profile write lock forever. A `dsh web` process held `~/.dsh/profiles/web/package.json.lock` for 22 minutes after its pnpm child printed `Done in 2s`: that child never exited, so the operation awaited an end that never came, the lock was never released, and every later management call in the process queued behind it. pnpm 11.13.0 consumes its one-shot worker-pool teardown once, re-creates the pool lazily afterwards, and unreferences a worker only during destruction, so the pool keeps the parent process alive with no repository-side change able to fix it.
|
||||
|
||||
A second cause reaches the same end by another route: a lifecycle script that inherited pnpm's stdout and stderr keeps those pipes open after pnpm exits, so a reader waiting for end of file never sees one, and the lock stays held while that reader waits.
|
||||
|
||||
## Decision
|
||||
|
||||
- A run completes when its process exits, not when its pipes end. The operation awaits the raw child's `exit` event and races execa's promise, which resolves only once the piped stdio closes.
|
||||
- The pipes then drain under a fixed 2000 ms grace period, after which this operation closes them. Failure classification reads a tail pnpm writes before it exits, so the grace period belongs to finishing a run rather than to deployment configuration.
|
||||
- Only the close this drain causes is ignored. A reading that failed before the cut still surfaces as the run's failure, and the log records that the tail was cut short.
|
||||
- A run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` beside its own exit status, and is not asked of the next registry, which bounds how long one operation can hold the lock.
|
||||
- A terminated run is classified `timeout` whatever exit status the signal left behind. Installation and removal report failure instead of success, so a manifest pnpm had half written is restored rather than activated.
|
||||
- A terminated run stops its whole process tree and waits for it to disappear before the caller restores the profile and releases the lock, because an approved lifecycle script runs as a child of pnpm and outlives it. execa's `killDescendants` addresses the tree on every kill path, including cancellation, and is passed only for a captured run, so a run that inherits the terminal keeps the caller's process group.
|
||||
- `dsh plugin` inherits the caller's terminal and captures nothing, so no silence bound applies to it and its operator keeps the ability to interrupt.
|
||||
|
||||
## Why a silence bound rather than a duration bound
|
||||
|
||||
A capture-only run that prints nothing for ten minutes is stuck: pnpm reports its own progress and its scripts' output there. A total duration bound would instead kill legitimate work, because a non-TTY pnpm prints a build script's output when that script finishes, and a native build on a slow machine compiles silently for longer than any fixed budget we could justify for it. Silence separates a stalled run from a slow one without guessing a duration, and 600000 ms leaves an approved build ten times the slowest silent stretch maintainers expect from it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Wait for execa's promise.** execa resolves after the piped stdio ends, which is the end a held pipe withholds, so the completion signal had to move to the process itself.
|
||||
|
||||
**Bound total duration instead of silence.** A stalled run and a compiling run are indistinguishable by duration alone, and the bound would destroy legitimate work whenever the machine is slow enough.
|
||||
|
||||
**Terminate only the pnpm process.** An approved `postinstall` keeps writing into the profile after the operation reports a timeout and releases the lock, so a later restoration races that script. Reproduced with a local dependency whose postinstall writes a file, and with pnpm 11.13.0.
|
||||
|
||||
**Detach the run and reap the tree later.** Rollback and lock release must happen after the tree stopped writing, and deferring the reap leaves open exactly the window this decision closes.
|
||||
|
||||
**Keep the pipes as the completion signal and bound only that wait.** The drain does bound the wait, but a stalled worker pool never ends its pipes, so the process exit remains the completion signal.
|
||||
|
||||
## Testing
|
||||
|
||||
| Evidence | Behaviour |
|
||||
|---|---|
|
||||
| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | Silence-bound termination, a run that trapped the signal and exited 0, the bounded drain over a held pipe, a reading failure that still surfaces through the cut, a rejection reason that is not an Error, and the cut notice. |
|
||||
| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | Real descendants: a pipe that outlives its process still drains, and a stalled run's tree is stopped before the operation returns, so a marker its script would have written never appears. |
|
||||
| [run-tree.spec.ts](../../../../packages/boot/plugin-manager/tests/run-tree.spec.ts) | Platform group-leadership decisions, the probe target each platform addresses, and the wait bound. |
|
||||
| [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) | A terminated run that exited 0 restores the manifest, reports `timeout`, and is not activated; a terminated removal reports failure. |
|
||||
|
||||
The real-process cases cover POSIX only: Windows cannot stage a descendant that keeps inherited pipes open past its parent, and the drain itself is platform-independent. With those cases skipped, the mocked cases still hold the per-file coverage gate on Windows.
|
||||
|
||||
## Related
|
||||
|
||||
[Guided plugin installation](../architecture/2026-09-15-guided-plugin-installation.md) remains the owner of what an installation or removal does with a finished run: the manifest snapshot it restores, the classification it renders, and the cancellation contract. This note owns only how a run ends and how long it may hold the profile lock.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A stalled run is reported up to `idleTimeoutMs` late and the profile lock is held for that long. A silent but healthy native build longer than the default is killed and classified `timeout`; a profile that compiles such a dependency raises `idleTimeoutMs`, trading slower stall detection for tolerance.
|
||||
- A cut tail is incomplete, so the failure classification and the returned output see only what pnpm wrote inside the grace period. The diagnostic log records the cut.
|
||||
- Terminating a run kills its whole tree, so a lifecycle script that had already started is stopped instead of being allowed to finish.
|
||||
- The bound is a workaround for a third-party defect (issue #4981, PR #4982) and becomes unnecessary once pnpm tears down its worker pool before a command returns.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Agent Note: 为停止推进的 pnpm 运行设界
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-23-bounded-pnpm-runs.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
一次 profile 包操作可能永久持有 profile 写锁。某个 `dsh web` 进程在其 pnpm 子进程打印 `Done in 2s` 之后仍持有 `~/.dsh/profiles/web/package.json.lock` 达 22 分钟:该子进程从未退出,于是操作等待一个永不到来的结束点,锁始终未释放,进程内后续每一次管理调用都排在它后面。pnpm 11.13.0 只消费一次性的 worker 池收尾,之后又惰性重建该池,并且只在销毁时才对 worker 取消引用,因此该池会让父进程一直存活,仓库侧无从修复。
|
||||
|
||||
第二条成因以另一条路径导致同一结果:继承了 pnpm stdout 与 stderr 的生命周期脚本会在 pnpm 退出后继续持有这些管道,于是等待文件结束的读取方永远等不到,锁在读等期间一直被持有。
|
||||
|
||||
## 决策
|
||||
|
||||
- 运行的完成点是进程退出,而非管道结束。操作等待原始子进程的 `exit` 事件,并与 execa 的 promise 竞速;后者只在管道化 stdio 关闭后才 resolve。
|
||||
- 随后管道在一个固定的 2000 毫秒宽限窗口内排空,窗口结束后由本次操作关闭它们。失败分类读取的是 pnpm 退出前写入的尾部,因此该宽限属于「完成一次运行」,而不是部署配置项。
|
||||
- 只有本次排空造成的关闭才被忽略。切断之前的读取失败仍作为该运行的失败上报,日志会记录尾部被切断。
|
||||
- 捕获输出的运行若在 `idleTimeoutMs` 内没有任何输出即被终止,在自身退出状态之外报告 `timedOut`,且不再询问下一个注册表,从而使单次操作持有锁的时长有上界。
|
||||
- 被终止的运行不论信号留下什么退出状态都归类为 `timeout`。安装与删除都报告失败而非成功,因此 pnpm 只写了一半的 manifest 会被还原,而不会被启用。
|
||||
- 被终止的运行会停止整棵进程树并等待其消失,之后调用方才还原 profile 并释放锁,因为获批的生命周期脚本是 pnpm 的子进程且存活更久。execa 的 `killDescendants` 覆盖每一条 kill 路径(含取消),且只对捕获输出的运行启用,因此继承终端的运行仍留在调用方的进程组内。
|
||||
- `dsh plugin` 继承调用方终端、不捕获输出,因此不受静默上界约束,其操作者仍可中断。
|
||||
|
||||
## 为何用静默上界而非时长上界
|
||||
|
||||
只捕获输出、十分钟没有任何输出的运行已经卡住:pnpm 自身的进度与脚本输出都写在那里。而总时长上界会误杀正当工作,因为非 TTY 的 pnpm 只在该脚本结束时才打印它的输出,慢机器上的原生构建以超过任何可辩护固定预算的时间静默编译。静默把卡住的运行与较慢的运行区分开,无需猜测时长;600000 毫秒给获批构建留下的静默余量,是维护者预期的最慢静默时长的十倍。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**等待 execa 的 promise。** execa 在管道化 stdio 结束后才 resolve,而那正是被持有管道扣住不发的结束点,因此完成信号必须移到进程本身。
|
||||
|
||||
**改为对总时长设界。** 仅凭时长无法区分卡住的运行与正在编译的运行,而且只要机器够慢,这个上界就会破坏正当工作。
|
||||
|
||||
**只终止 pnpm 进程。** 获批的 `postinstall` 会在操作报告超时并释放锁之后继续写入 profile,于是后续的还原与该脚本互相竞争。已用 pnpm 11.13.0 与一个 postinstall 会写文件的本地依赖复现。
|
||||
|
||||
**让运行脱离父进程、稍后再回收进程树。** 还原与释放锁必须在进程树停止写入之后发生,推迟回收恰好留下本决策要关闭的那个窗口。
|
||||
|
||||
**保留管道作为完成信号,只对该等待设界。** 排空确实为等待设了界,但卡住的 worker 池永不结束其管道,因此进程退出仍是完成信号。
|
||||
|
||||
## 验证
|
||||
|
||||
| 证据 | 覆盖行为 |
|
||||
|---|---|
|
||||
| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | 静默上界终止、捕获信号后以 0 退出的运行、持有管道下的有界排空、经切断仍上报的读取失败、非 Error 的拒绝原因,以及切断提示。 |
|
||||
| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | 真实孙进程:存活超过其父进程的管道仍能排空;卡住运行的进程树在操作返回前被停止,因此其脚本本应写入的标记不会出现。 |
|
||||
| [run-tree.spec.ts](../../../../packages/boot/plugin-manager/tests/run-tree.spec.ts) | 各平台的进程组归属判定、各平台探测的目标,以及等待上界。 |
|
||||
| [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) | 以 0 退出的被终止运行会还原 manifest、报告 `timeout` 且不被启用;被终止的删除报告失败。 |
|
||||
|
||||
真进程用例仅覆盖 POSIX:Windows 无法搭出「孙进程在其父进程退出后仍持有继承管道」的前提,而排空本身与平台无关。跳过这些用例时,mock 用例仍能在 Windows 上满足逐文件覆盖率门禁。
|
||||
|
||||
## 相关
|
||||
|
||||
[引导式插件安装](../architecture/2026-09-15-guided-plugin-installation.zh.md)仍是「安装或删除如何处理一次已结束运行」的归属:它还原的 manifest 快照、渲染的分类与取消约定。本记录只负责运行如何结束,以及它可以持有 profile 锁多久。
|
||||
|
||||
## 后果
|
||||
|
||||
- 卡住的运行最迟在 `idleTimeoutMs` 之后才被报告,profile 锁在这段时间内一直被持有。静默但健康、且超过默认值的原生构建会被终止并归类为 `timeout`;需要编译此类依赖的 profile 应提高 `idleTimeoutMs`,以更慢的卡住检测换取容忍度。
|
||||
- 被切断的尾部不完整,因此失败分类与返回输出只看到 pnpm 在宽限窗口内写入的内容。诊断日志记录了这次切断。
|
||||
- 终止运行会杀掉整棵进程树,因此已经开始的生命周期脚本会被停止,而不会被允许跑完。
|
||||
- 该上界是针对第三方缺陷的变通(issue #4981、PR #4982);一旦 pnpm 在命令返回前完成其 worker 池收尾,它就不再必要。
|
||||
@@ -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-06-15-ptc.md
|
||||
2026-06-15-ptc.md: d5ed3fc79d4586d3754b7273e08970fb066fe6e9
|
||||
2026-06-15-ptc.zh.md: 1434db5c352cdb14b857dc5d4ca3bb2f5332e98e
|
||||
2026-06-15-ptc.md: 220f8d27fa912fe708be251e04947cf6e1a46239
|
||||
2026-06-15-ptc.zh.md: e244d0173e3734ebe00c07f5362da0662310dd8d
|
||||
@@ -101,6 +101,8 @@ Deployments switching to `'ptc'` must update any native-only `toolOrder`. Assemb
|
||||
- **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a PTC mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior.
|
||||
- **Snapshot:** The `ptc-turn`, `both-mode-turn`, and `ptc-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. The TypeScript SDK PTC scenario mounts its Node runtime through an explicit test-owned profile patch and pins Session events and JSON-RPC notifications; its expected response and completed-turn checks run before refresh writes.
|
||||
|
||||
The optional [best-effort persistent provider](../architecture/2026-09-23-best-effort-persistent-ptc.md) permits disposable cross-call memory without restoring objects or replaying programs after restart; the default provider stays stateless.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**An add-on consumer plugin with zero core changes.** Rejected because `agent/request` is call-config-only under [reconstructable requests](../architecture/2026-07-05-reconstructable-requests.md), while transforming an assembled tool list would have to undo `toolOrder` canonicalization without owning its config and would depend on listener order. Which tools the model is offered, and in which representation, is the registry's single concern: native schemas and the SDK are two projections of one visible store.
|
||||
|
||||
@@ -101,6 +101,8 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认
|
||||
- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 PTC mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。
|
||||
- **快照:** `ptc-turn`、`both-mode-turn` 和 `ptc-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。TypeScript SDK PTC 场景通过测试拥有的显式 profile patch 挂载 Node 运行时,并固定 Session 事件与 JSON-RPC 通知;预期回复和完成轮次检查在 refresh 写入前执行。
|
||||
|
||||
可选的[尽力持久提供方](../architecture/2026-09-23-best-effort-persistent-ptc.zh.md)允许跨调用保留可丢失的内存,不在重启后恢复对象或回放程序;默认提供方保持无状态。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../architecture/2026-07-05-reconstructable-requests.zh.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一个可见存储的两种投影。
|
||||
|
||||
@@ -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-08-25-promote-open-anywhere-plugin.md
|
||||
2026-08-25-promote-open-anywhere-plugin.md: 83888cb548046cd8e023cd2b7c87f123cda0ed0f
|
||||
2026-08-25-promote-open-anywhere-plugin.zh.md: 4ef476ce7043d2dcee05dec9f604849741ce5e96
|
||||
2026-08-25-promote-open-anywhere-plugin.md: e5a435d24c2400b68fad8cccb094b176ad83f590
|
||||
2026-08-25-promote-open-anywhere-plugin.zh.md: 108091f3e7c2736063d67bc6b2d7fe0c039b35db
|
||||
@@ -21,7 +21,7 @@ The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `pac
|
||||
- **Resolution sources are platform-honest and cheap.** macOS checks the known application directories (`/Applications`, `~/Applications`) for the entry's bundle spellings and launches `open -a <resolved bundle>`; Xcode follows `xcode-select -p`. Windows reads `App Paths`, then Uninstall records kept only when they prove an executable on disk, then known paths and the newest versioned install directory where an application uses one — one batched `reg.exe query` per root per pass. GitHub Desktop resolves its versioned executable together with the packaged `cli.js` and invokes the supported `github open <path>` behavior without a command shell. CLI names resolve in-process through `ctx.subprocess.resolveExecutable()` (PATH/PATHEXT stat, no shell, no `which`/`where.exe` children); Linux GUI entries whose CLI is off PATH fall back to their XDG desktop entry's verified `TryExec`/`Exec`, while `xdg-open` is offered only when the host announces a display server. The remaining host commands (`xcode-select`, `reg.exe`, icon extraction) run through `@deepseek-ai/dsh-native-command` (argv, never a shell).
|
||||
- **Three independent deadlines replace the single `commandTimeoutMs`**: `probeTimeoutMs` (resolution commands), `iconTimeoutMs` (icon extraction commands), and `launchWatchMs` (the early-failure watch window), so tuning one operation never changes another's response time — the shipped bundle keeps conservative 10 s command deadlines (timeouts are failure bounds, not latency budgets) with a 1 s watch window, which is what bounds how long the open route and the button's busy dress hold a successful launch. Launches spawn detached with a credential-scrubbed environment (`scrubbedParentEnv` from `dsh-subprocess`) plus explicit adapter entries; Windows GUI launchers remain visible unless an adapter explicitly hides its CLI process because that process opens the GUI separately. A launcher still running when the watch window closes counts as launched and is never killed or awaited (kitty and the JetBrains IDEs stay in the foreground for their window lifetime).
|
||||
- **Icons extract on every platform with a host source.** macOS converts the resolved bundle's `.icns` to a 128px PNG (`plutil` + `sips`); Windows extracts the resolved executable's associated icon as a 32px PNG through a generated PowerShell script run with positional `-File` args (no command-line path parsing); Linux follows the spec's desktop entry `Icon=` into the hicolor theme and pixmaps directories (PNG or SVG, filesystem only). Any failure answers 404 and the browser keeps its generic glyph.
|
||||
- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a <bundle>` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, the OS shell's open verb with the full parent environment, not a detached scrubbed spawn), because a direct `explorer.exe <dir>` spawn does not reliably raise a window. Closed unions end in `assertNever`.
|
||||
- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a <bundle>` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, which hands the directory to the OS shell with the full parent environment, not a detached scrubbed spawn), because a detached, credential-scrubbed `explorer.exe <dir>` spawn does not reliably raise a window; the opener's own `explorer.exe` handoff does ([Windows open resolution](../bug-fix/2026-09-22-windows-open-through-shell-resolution.md)). Closed unions end in `assertNever`.
|
||||
- **Every route runs behind the composition connection service's trust fence** (`requestRejection`: the Host/Origin fence defeating DNS rebinding and cross-site calls, plus browser authentication), the same guard the API gateway applies to its WebSocket upgrade; the mechanism's one home is the `src/index.ts` module comment. On top of that fence the open route validates the body at the wire: an `application/json` media type (exact essence, not a substring match), a 64 KiB bound with a drained 413, string field types, only probed-available catalog ids, and an absolute path naming an existing directory.
|
||||
- **The DSH-version gate is deleted.** It existed because the plugin rode release-to-release against an interface it did not own; a first-party package is versioned with the repository, so the gate, its `sessionStorage`/`localStorage` trust ledger, and the argv-walking version probe have no referent.
|
||||
- **The last choice persists through `createSnapshotStore(..., { persist })`** (`dsh.open-in-app.choice`), replacing hand-rolled `localStorage` access. A fresh store has no platform-specific choice; the component uses the first available host entry until the user chooses one.
|
||||
|
||||
@@ -21,7 +21,7 @@ Status: implemented
|
||||
- **解析来源按平台务实且廉价。** macOS 在已知应用目录(`/Applications`、`~/Applications`)查条目的 bundle 拼写,启动 `open -a <解析出的 bundle>`;Xcode 跟随 `xcode-select -p`。Windows 依次读 `App Paths`、只在能证明磁盘可执行文件时采用的 Uninstall 记录、已知路径,以及采用版本化安装目录的应用中最新的目录——每趟每根一条批量 `reg.exe query`。GitHub Desktop 会同时解析版本化可执行文件与随包提供的 `cli.js`,不经命令 shell 调用受支持的 `github open <path>` 行为。CLI 名称经 `ctx.subprocess.resolveExecutable()` 进程内解析(PATH/PATHEXT stat,无 shell、无 `which`/`where.exe` 子进程);CLI 不在 PATH 上的 Linux GUI 条目回退到其 XDG desktop 条目验证过的 `TryExec`/`Exec`,且只有主机声明了 display server 时才提供 `xdg-open`。其余主机命令(`xcode-select`、`reg.exe`、图标提取)经 `@deepseek-ai/dsh-native-command`(argv,绝不走 shell)执行。
|
||||
- **三个独立期限取代单一 `commandTimeoutMs`**:`probeTimeoutMs`(解析命令)、`iconTimeoutMs`(图标提取命令)、`launchWatchMs`(早期失败看护窗口),调整一种操作的超时不再改变其他操作的响应时间——随发行 bundle 保守地保留 10 秒命令期限(超时是失败上界而非延迟预算),看护窗口 1 秒,它才是约束 open 路由与按钮忙碌态挂起一次成功启动时长的量。启动以清理过凭据的环境(`dsh-subprocess` 的 `scrubbedParentEnv`)叠加适配器显式环境后 detached 派生;Windows GUI launcher 默认保持可见,只有负责另行打开 GUI 的 CLI 适配器会显式隐藏自己的进程。看护窗口关闭时仍在运行的启动器计为已启动,绝不会被杀死或等待(kitty 与 JetBrains IDE 在整个窗口生命周期内保持前台)。
|
||||
- **有主机来源的平台都提取图标。** macOS 把解析出的 bundle 的 `.icns` 转 128px PNG(`plutil` + `sips`);Windows 用生成的 PowerShell 脚本以位置式 `-File` 参数(路径不经命令行解析)提取解析出的可执行文件的关联图标为 32px PNG;Linux 沿 spec 的 desktop 条目 `Icon=` 查 hicolor 主题与 pixmaps 目录(PNG 或 SVG,纯文件系统)。任何失败应答 404,浏览器保持通用占位图形。
|
||||
- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a <bundle>`)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,OS shell 的 open verb,携带完整父环境,而非 detached 的清理环境 spawn),因为直接 spawn `explorer.exe <dir>` 不能可靠地弹出窗口。封闭 union 以 `assertNever` 收尾。
|
||||
- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a <bundle>`)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,把目录交给 OS shell 并携带完整父环境,而非 detached 的清理环境 spawn),因为 detached 且经过凭据清洗的 `explorer.exe <dir>` 派生进程不能可靠地弹出窗口;打开器自身的 `explorer.exe` 交接可以([Windows 打开解析](../bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md))。封闭 union 以 `assertNever` 收尾。
|
||||
- **所有路由都运行在组合 connection 服务的信任栅栏之后**(`requestRejection`:挫败 DNS rebinding 与跨站调用的 Host/Origin 栅栏,加上浏览器认证),与 API gateway 施加在其 WebSocket upgrade 上的守卫相同;机制的唯一出处是 `src/index.ts` 的模块注释。在该栅栏之上,open 路由在 wire 边界校验请求体:`application/json` 媒体类型(精确 essence,而非子串匹配)、以排空后 413 的方式把 body 限制在 64 KiB、校验字段类型、只接受探测为可用的目录 id,并要求指向现存目录的绝对路径。
|
||||
- **删除了 DSH 版本门禁。** 它存在是因为插件逐版本骑乘一个它不拥有的接口;第一方包与仓库同版本发布,门禁、它的 `sessionStorage`/`localStorage` 信任台账和 argv 遍历版本探测都失去了所指。
|
||||
- **上次选择经 `createSnapshotStore(..., { persist })` 持久化**(`dsh.open-in-app.choice`),替代手写 `localStorage` 访问。新存储没有平台特定的初始选择;用户首次选择前,组件使用主机提供的第一个可用条目。
|
||||
|
||||
@@ -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-16-browser-excel-preview.md
|
||||
2026-09-16-browser-excel-preview.md: 15db888330b2a1a6d6d78dad46689962466d531d
|
||||
2026-09-16-browser-excel-preview.zh.md: 6b3a6a617a0cd18dcd3da1682a31f26bd7e70386
|
||||
2026-09-16-browser-excel-preview.md: 8a4bbd5dc838bc79e61f9b412af43b912a8fe019
|
||||
2026-09-16-browser-excel-preview.zh.md: f04170b28fe4c6d267b12958f47a392638bfaadb
|
||||
@@ -24,7 +24,7 @@ The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@
|
||||
|
||||
The read-only formula bar takes formulas from raw `cell.f` and writes formulas and cell text through `textContent`. FortuneSheet's formula formatter emits HTML spans containing document text, and its HTML helper passes formulas and strings starting with `<span` through unchanged. Neither read-only mode nor the parser Worker isolates this DOM. The [core patch](../../../../patches/@[email protected]) HTML-escapes copied cell contents, including literal strings and saved formula results, while preserving the clipboard table and formatting.
|
||||
|
||||
The ESM and CommonJS entries receive the same patches. Recheck worksheet switching, complete initial selections, literal formula-bar text, and escaped table copying when upgrading FortuneSheet.
|
||||
The core patch also skips drawing freeze dividers when `allowEdit === false`, retaining the frozen-pane calculations and editable-mode dividers. These dividers are Canvas strokes, so scoped CSS cannot change them. Preview-scoped CSS hides the row and column freeze drag handles. The ESM and CommonJS entries receive the same patches. Recheck worksheet switching, complete initial selections, literal formula-bar text, escaped table copying, and frozen-pane rendering and scrolling when upgrading FortuneSheet.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。
|
||||
|
||||
只读公式栏直接读取原始 `cell.f`,通过 `textContent` 写入公式和单元格文本。FortuneSheet 的公式格式化器会生成含有文档文本的 HTML span,其 HTML 辅助函数会原样放行公式和以 `<span` 开头的字符串。只读模式和解析 Worker 都不能隔离这部分 DOM。[core 补丁](../../../../patches/@[email protected])会对复制的单元格内容进行 HTML 转义,包括字面字符串和已保存的公式结果,同时保留剪贴板表格及其格式。
|
||||
|
||||
ESM 和 CommonJS 入口应用相同补丁。升级 FortuneSheet 时需要重新检查工作表切换、完整初始选区、公式栏字面文本以及经过转义的表格复制。
|
||||
core 补丁还在 `allowEdit === false` 时跳过冻结分隔线的绘制,保留冻结窗格计算和编辑模式下的分隔线。这些分隔线由 Canvas 绘制,因此局部 CSS 无法修改。预览区域内的 CSS 隐藏行列冻结拖拽条。ESM 和 CommonJS 入口应用相同补丁。升级 FortuneSheet 时需要重新检查工作表切换、完整初始选区、公式栏字面文本、经过转义的表格复制,以及冻结窗格的绘制和滚动。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -109,6 +109,9 @@ jobs:
|
||||
- name: Build (lib for the e2e example bins)
|
||||
run: pnpm run build:official
|
||||
|
||||
- name: Prepare Office runtime
|
||||
run: pnpm run prepare:primary-runtime --target linux-x64 --output .cache/e2e-office
|
||||
|
||||
# Real-API end-to-end tests only. The keyless gates (lint/typecheck/
|
||||
# coverage/snapshot/etc.) already run in ci.yml (pull requests) and
|
||||
# ci-master.yml (master push standby).
|
||||
@@ -118,6 +121,7 @@ jobs:
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
|
||||
DEEPSEEK_BASE_URL: https://api.deepseek.com/anthropic
|
||||
DSH_PRIMARY_RUNTIME: ${{ github.workspace }}/.cache/e2e-office/primary-runtime
|
||||
DSH_E2E_MAX_WORKERS: 4
|
||||
DSH_EXAMPLE_MODE: lib
|
||||
run: pnpm run test:e2e
|
||||
@@ -432,11 +432,11 @@ async function officeFixture(root: string, pythonOnly: boolean) {
|
||||
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) => {
|
||||
it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli', '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')
|
||||
const { source, paths } = await officeFixture(root, mode.startsWith('python-only'))
|
||||
if (mode === 'missing-assets') await rm(join(root, 'resources', 'office-skills'), { recursive: true })
|
||||
if (mode === 'wrong-type') {
|
||||
await rm(paths.python)
|
||||
@@ -460,9 +460,11 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', '
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('model fixture did not bind')
|
||||
const home = join(root, 'home')
|
||||
const cliPatch = join(root, 'cli.patch.yml')
|
||||
await writeFile(cliPatch, JSON.stringify(mode === 'python-only-no-cli' ? [{ id: 'skill-office', config: { assetRoot: join(root, 'resources', 'office-skills'), cli: false } }] : []))
|
||||
const officeLaunch = resolveExampleLaunch({
|
||||
srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib',
|
||||
configArgs: ['--profile', 'sdk'],
|
||||
configArgs: ['--profile', 'sdk', '--patch', cliPatch],
|
||||
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',
|
||||
@@ -492,7 +494,7 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', '
|
||||
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')
|
||||
expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets' && mode !== 'python-only')
|
||||
}
|
||||
if (mode === 'wrong-type') {
|
||||
expect(requests).toHaveLength(2)
|
||||
@@ -521,6 +523,7 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', '
|
||||
expect.objectContaining({ role: 'user', content }),
|
||||
]))
|
||||
}
|
||||
if (mode === 'python-only') expect(stderr).toContain('node')
|
||||
if (mode === 'missing-assets') expect(stderr).toContain('check_office.py')
|
||||
await send(3, 'shutdown')
|
||||
const exit = await child
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Agent discovery of the installed Office CLI through the shipped SDK profile.
|
||||
* Defaults to built Node packages. DSH_OFFICE_TEST_EXECUTABLE/ENTRY select a
|
||||
* relocated Node or Electron carrier; CARRIER=sdk selects the standalone executable.
|
||||
* NODE/CLI/ASSETS under the same prefix supply Desktop resources outside ASAR.
|
||||
* API=openai-completions selects a development gateway instead of the default Messages API.
|
||||
*/
|
||||
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile, chmod } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { execa } from 'execa'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
const repo = fileURLToPath(new URL('../../../../../', import.meta.url))
|
||||
const carrier = process.env.DSH_OFFICE_TEST_CARRIER ?? 'npm'
|
||||
const executable = process.env.DSH_OFFICE_TEST_EXECUTABLE ?? process.execPath
|
||||
const entry = process.env.DSH_OFFICE_TEST_ENTRY ?? join(repo, 'apps/cli/lib/bin.js')
|
||||
|
||||
it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent discovers and executes the ${carrier} Office CLI without Node on PATH`, async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh office discovery '))
|
||||
try {
|
||||
await writeFile(join(root, 'input.docx'), zipSync({
|
||||
'[Content_Types].xml': strToU8('<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'),
|
||||
'_rels/.rels': strToU8('<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>'),
|
||||
'word/document.xml': strToU8('<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Office CLI discovery</w:t></w:r></w:p><w:sectPr/></w:body></w:document>'),
|
||||
}))
|
||||
const patch = join(root, 'office.patch.yml')
|
||||
await writeFile(patch, JSON.stringify([
|
||||
...(process.env.DSH_OFFICE_TEST_API === 'openai-completions' ? [{ id: 'llm-deepseek', disabled: true }, { id: 'llm-pi-ai', config: { providers: { 'office-test': { api: 'openai-completions', apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: process.env.DEEPSEEK_BASE_URL, compat: { thinkingFormat: 'deepseek' }, models: [{ id: process.env.MODEL_NAME ?? 'deepseek-v4-flash', contextWindow: 128000, maxTokens: 8000 }] } } } }] : []),
|
||||
{ id: 'session-persistence-jsonl', config: { root: join(root, 'sessions'), compression: 'none' } },
|
||||
...carrier === 'sdk' ? [] : [{ insert: [{
|
||||
id: 'office-cli-discovery', name: '@deepseek-ai/dsh-skill-office', config: {
|
||||
...(process.env.DSH_OFFICE_TEST_NODE ? { node: process.env.DSH_OFFICE_TEST_NODE } : {}),
|
||||
...(process.env.DSH_OFFICE_TEST_CLI ? { cli: process.env.DSH_OFFICE_TEST_CLI } : {}),
|
||||
...(process.env.DSH_OFFICE_TEST_ASSETS ? { assetRoot: process.env.DSH_OFFICE_TEST_ASSETS } : {}),
|
||||
},
|
||||
}] }],
|
||||
]))
|
||||
// A private POSIX tool directory also excludes /usr/bin/node on Linux.
|
||||
const path = join(root, 'bin')
|
||||
await mkdir(path)
|
||||
for (const name of ['bash', 'sh', 'cat', 'ls', 'head', 'tail', 'mkdir']) {
|
||||
const binary = (existsSync('/bin/' + name) ? '/bin/' : '/usr/bin/') + name
|
||||
await access(binary)
|
||||
await symlink(binary, join(path, name))
|
||||
}
|
||||
const decoyMarker = join(root, 'system-office-invoked')
|
||||
for (const name of ['libreoffice', 'soffice']) {
|
||||
const decoy = join(path, name)
|
||||
await writeFile(decoy, '#!/bin/sh\nprintf invoked > ' + "'" + decoyMarker.replaceAll("'", "'\\''") + "'" + '\nexit 86\n')
|
||||
await chmod(decoy, 0o755)
|
||||
}
|
||||
const lookup = await execa('/bin/sh', ['-c', 'command -v node || command -v libreoffice-kit'], { env: { PATH: path }, reject: false })
|
||||
expect(lookup.exitCode, 'the fixture must not expose Node or the Office CLI through PATH').not.toBe(0)
|
||||
const child = execa(executable, [
|
||||
...carrier === 'sdk' ? [] : [entry], '--profile', 'sdk', '--patch', patch,
|
||||
], {
|
||||
cwd: root, env: {
|
||||
PATH: path, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'),
|
||||
DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native',
|
||||
DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : '',
|
||||
},
|
||||
timeout: 110_000, killSignal: 'SIGKILL', reject: false,
|
||||
})
|
||||
const lines = createInterface({ input: child.stdout })
|
||||
const records: Record<string, unknown>[] = []
|
||||
let protocolError: Error | undefined
|
||||
const send = (id: number, method: string, params?: object): void => {
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n')
|
||||
}
|
||||
lines.on('line', (line) => {
|
||||
try {
|
||||
const value = JSON.parse(line) as Record<string, unknown>
|
||||
records.push(value)
|
||||
if (value.id === 1) send(2, 'session/prompt', { sessionId: 'main', contentBlocks: [{ type: 'text', text:
|
||||
'Load the appropriate Office skill and convert input.docx to result.pdf. Also save the installed LibreOffice Kit capabilities command output verbatim to capabilities.json. Use the supplied runtime; do not install anything. Report briefly after the files exist.',
|
||||
}] })
|
||||
const params = value.params as { event?: { type?: string } } | undefined
|
||||
if (value.error !== undefined || params?.event?.type === 'turn/end') send(3, 'shutdown')
|
||||
} catch (error) {
|
||||
protocolError = error instanceof Error ? error : new Error(String(error))
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
})
|
||||
try {
|
||||
send(1, 'initialize', { cwd: root, provider: process.env.DSH_OFFICE_TEST_API === 'openai-completions' ? 'office-test' : 'deepseek-official', model: process.env.MODEL_NAME ?? 'deepseek-v4-flash', maxTokens: 8000 })
|
||||
const result = await child
|
||||
const diagnostic = `${result.stderr}\n${result.stdout}`
|
||||
expect(protocolError, diagnostic).toBeUndefined()
|
||||
expect(result.timedOut, diagnostic).toBe(false)
|
||||
expect(result.signal, diagnostic).toBeUndefined()
|
||||
expect(result.exitCode, diagnostic).toBe(0)
|
||||
expect(records.filter(record => record.error !== undefined), diagnostic).toEqual([])
|
||||
const calls = records.flatMap((record) => {
|
||||
const params = record.params as { event?: { type: string; data: { name?: string; arguments?: unknown } } } | undefined
|
||||
return params?.event?.type === 'tool/call' ? [params.event.data] : []
|
||||
})
|
||||
expect(calls.some(call => call.name === 'skill'), diagnostic).toBe(true)
|
||||
const commands = calls.filter(call => call.name === 'bash')
|
||||
expect(commands.length, diagnostic).toBeGreaterThan(0)
|
||||
expect(calls.findIndex(call => call.name === 'skill'), diagnostic).toBeLessThan(calls.findIndex(call => call.name === 'bash'))
|
||||
const commandText = JSON.stringify(commands)
|
||||
for (const fragment of ['cli.js', 'capabilities', 'convert']) expect(commandText, diagnostic).toContain(fragment)
|
||||
expect(existsSync(decoyMarker), 'the agent must not invoke system Office when the bundled CLI is supplied').toBe(false)
|
||||
expect(commandText, 'the skill supplies an entry; searching for other Office binaries is unnecessary').not.toMatch(/(?:which|whereis|command -v|find).{0,100}(?:libreoffice|soffice)/iu)
|
||||
const capabilities = JSON.parse(await readFile(join(root, 'capabilities.json'), 'utf8')) as { runtime: { version: string; cliPath: string } }
|
||||
expect(capabilities.runtime.version).toBe('0.1.0')
|
||||
expect(capabilities.runtime.cliPath).toMatch(/lib[/\\]cli\.js$/u)
|
||||
const pdf = await readFile(join(root, 'result.pdf'))
|
||||
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-')
|
||||
expect(pdf.length).toBeGreaterThan(1000)
|
||||
} finally {
|
||||
lines.close()
|
||||
child.stdin.end()
|
||||
child.kill('SIGKILL')
|
||||
await child
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
@@ -10,6 +10,7 @@
|
||||
"lib/index.js"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/libreoffice-kit": "^0.1.0",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
|
||||
@@ -71,6 +71,7 @@ async function main(): Promise<void> {
|
||||
const { ctx } = await application
|
||||
control.updateTasks = installDesktopUpdateTaskControl(ctx)
|
||||
await ctx.plugin(desktopOffice, {
|
||||
runtimeDir,
|
||||
source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'),
|
||||
root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/** Desktop Office skills and bundled authoring dependencies. */
|
||||
|
||||
import { dirname, join } from 'node:path'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { dirname, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { runtimeArchivePath } from './office-engine.ts'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import * as officeSkills from '@deepseek-ai/dsh-skill-office'
|
||||
import * as workspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
@@ -13,6 +16,8 @@ export interface Config {
|
||||
readonly source: string
|
||||
/** Harness-home directory where workspace dependencies are installed. */
|
||||
readonly root: string
|
||||
/** Prepared or ASAR-contained application dependency directory. */
|
||||
readonly runtimeDir: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,5 +27,12 @@ export interface Config {
|
||||
*/
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
await ctx.plugin(workspaceDependencies, config)
|
||||
await ctx.plugin(officeSkills, { assetRoot: join(dirname(config.source), 'office-skills') })
|
||||
const archive = runtimeArchivePath(config.runtimeDir) === undefined ? undefined : dirname(realpathSync(config.runtimeDir))
|
||||
const manifest = fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/package.json'))
|
||||
const packageRoot = dirname(archive === undefined ? manifest : join(`${archive}.unpacked`, relative(archive, manifest)))
|
||||
await ctx.plugin(officeSkills, {
|
||||
assetRoot: join(dirname(config.source), 'office-skills'),
|
||||
node: join(config.source, 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node'),
|
||||
cli: join(packageRoot, 'lib', 'cli.js'),
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -12,12 +12,15 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import { expect, it } from 'vitest'
|
||||
import * as desktopOffice from '../src/office.ts'
|
||||
|
||||
it('loads Desktop Office skills without a document renderer and removes them on disposal', async () => {
|
||||
it('loads Desktop Office CLI paths and removes them on disposal', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'desktop-office-'))
|
||||
const ctx = new Context()
|
||||
try {
|
||||
const assets = join(root, 'runtime', 'office-skills')
|
||||
await cp(new URL('../../../packages/skill/skill-office/assets/', import.meta.url), assets, { recursive: true })
|
||||
const nodeDirectory = join(root, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin')
|
||||
await mkdir(nodeDirectory, { recursive: true })
|
||||
await cp(process.execPath, join(nodeDirectory, process.platform === 'win32' ? 'node.exe' : 'node'))
|
||||
ctx.baseUrl = pathToFileURL(root).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
@@ -38,13 +41,16 @@ it('loads Desktop Office skills without a document renderer and removes them on
|
||||
await writeFile(config, [
|
||||
'- name: agents', '- name: systemPrompt', '- name: tools', '- name: skills', '- name: office',
|
||||
' config:', ` source: ${JSON.stringify(join(root, 'runtime', 'primary-runtime'))}`,
|
||||
` root: ${JSON.stringify(join(root, 'installed'))}`, '',
|
||||
` root: ${JSON.stringify(join(root, 'installed'))}`,
|
||||
` runtimeDir: ${JSON.stringify(join(root, 'dsh'))}`, '',
|
||||
].join('\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()
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['office-docx', 'office-pptx', 'office-xlsx'])
|
||||
expect((await ctx.skills.get('office-pptx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-pptx') })
|
||||
expect((await ctx.skills.get('office-pptx'))?.content).toContain('libreofficeKit')
|
||||
expect((await ctx.skills.get('office-pptx'))?.content).toContain(JSON.stringify(nodeDirectory).slice(1, -1))
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['load_workspace_dependencies'])
|
||||
const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'office')
|
||||
expect(entry).toBeDefined()
|
||||
|
||||
@@ -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: 2b07b2b9d95c3d16a3aaa58b6d52f55cb1242e2e
|
||||
README.zh.md: 4ebae34332b1d622a35d0bf857f1f3252736512c
|
||||
README.md: 87b16ac8fd7ec269e8c02a34a32e6593b38aec0a
|
||||
README.zh.md: 895fdd972148c90bad00916a35b21cebff8842e8
|
||||
@@ -185,9 +185,9 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t
|
||||
|
||||
Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
|
||||
|
||||
The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Complete native engine packages, including executables, libraries, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/`. The Desktop Host resolves engine manifests to these physical directories so native subprocesses can read their resources; the JavaScript API and WASM engine remain in ASAR. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in).
|
||||
The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in).
|
||||
|
||||
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization.
|
||||
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization.
|
||||
|
||||
Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS
|
||||
|
||||
Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。
|
||||
|
||||
[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整原生引擎包(包括可执行文件、库、数据、许可证和 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/` 下。Desktop Host 将引擎清单解析到这些物理目录,使原生子进程能读取资源;JavaScript API 和 WASM 引擎保留在 ASAR 中。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。
|
||||
[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。
|
||||
|
||||
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和 Windows 未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { officePackageDirectories } from '../../../scripts/libreoffice-packages.mjs'
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
@@ -163,6 +164,10 @@ export function createElectronBuilderConfig(
|
||||
writeUpdateInfo: false,
|
||||
},
|
||||
beforePack: async context => {
|
||||
const office = await officePackageDirectories(buildPaths.dsh, { platform: resolvedPlatform, arch: resolvedArch })
|
||||
const patterns = office.map(directory => `**/${relative(buildPaths.dsh, directory).split(sep).join('/')}/**/*`)
|
||||
const existing = context.packager.config.asarUnpack ?? []
|
||||
context.packager.config.asarUnpack = [...(typeof existing === 'string' ? [existing] : existing), ...patterns]
|
||||
if (packagesWindows) windowsCode = await prepareWindowsAsarUnpack(context, buildPaths.dsh)
|
||||
if (windowsSigner !== undefined) {
|
||||
primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime')
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from './macos-runtime.ts'
|
||||
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts'
|
||||
import { selectOfficeEngine } from '../../../scripts/libreoffice-engine.ts'
|
||||
import { selectOfficeEngine } from '../../../scripts/libreoffice-packages.mjs'
|
||||
|
||||
const APP_ROOT = resolve(import.meta.dirname, '..')
|
||||
const BUILD_PATHS = resolveDesktopTargetBuildPaths()
|
||||
|
||||
@@ -49,11 +49,30 @@ export async function smokeDesktopRuntime(
|
||||
}))
|
||||
writeFileSync(join(plugin, 'index.js'), `
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { inspect } from 'node:util'
|
||||
import { inspect, promisify } from 'node:util'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
export function apply(ctx) {
|
||||
if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance')
|
||||
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke',
|
||||
handler(_request, response) { response.end('plugin route ready') } }))
|
||||
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office-cli',
|
||||
async handler(_request, response) {
|
||||
try {
|
||||
const skill = await ctx.skills.get('office-docx')
|
||||
const json = skill?.content.match(/\\n(\\{\\n[\\s\\S]+)$/u)?.[1]
|
||||
if (json === undefined) throw new Error('Office skill did not supply CLI paths')
|
||||
const { libreofficeKit: { node, cli } } = JSON.parse(json)
|
||||
const options = { cwd: ${JSON.stringify(home)}, env: { ...process.env, PATH: '' }, timeout: 120_000 }
|
||||
const capabilities = await promisify(execFile)(node, [cli, 'capabilities'], options)
|
||||
const output = ${JSON.stringify(join(home, 'cli.pdf'))}
|
||||
await promisify(execFile)(node, [cli, 'convert', '--input', ${JSON.stringify(join(home, 'input.docx'))}, '--output', output], options)
|
||||
response.end(JSON.stringify({ capabilities: JSON.parse(capabilities.stdout), pdf: (await readFile(output)).toString('base64') }))
|
||||
} catch (error) {
|
||||
response.statusCode = 500
|
||||
response.end(inspect(error, { depth: 5 }))
|
||||
}
|
||||
} }))
|
||||
for (const input of ${JSON.stringify(inputs)}) {
|
||||
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office/' + input.extension,
|
||||
async handler(_request, response) {
|
||||
@@ -71,7 +90,7 @@ export function apply(ctx) {
|
||||
}
|
||||
}
|
||||
`)
|
||||
writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf]\n')
|
||||
writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills]\n')
|
||||
const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as {
|
||||
dependencies: Record<string, string>
|
||||
dsh: { profile: { bundles: string[] } }
|
||||
@@ -103,7 +122,15 @@ export function apply(ctx) {
|
||||
throw new Error(`desktop runtime: invalid ${extension} PDF output`)
|
||||
}
|
||||
}
|
||||
console.log('desktop runtime: DOCX, XLSX, PPTX to PDF passed')
|
||||
const cliResponse = await fetch(new URL('/desktop-smoke-office-cli', ready.url), {
|
||||
headers: { cookie }, signal: AbortSignal.timeout(120_000),
|
||||
})
|
||||
if (!cliResponse.ok) throw new Error(`desktop runtime: skill CLI failed: ${await cliResponse.text()}`)
|
||||
const cliResult = await cliResponse.json() as { capabilities: { runtime: { cliPath: string } }; pdf: string }
|
||||
if (!cliResult.capabilities.runtime.cliPath.endsWith('cli.js') || Buffer.from(cliResult.pdf, 'base64').subarray(0, 5).toString() !== '%PDF-') {
|
||||
throw new Error('desktop runtime: skill CLI did not return capabilities and a PDF')
|
||||
}
|
||||
console.log('desktop runtime: DOCX, XLSX, PPTX to PDF and skill CLI discovery passed')
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
await host.stop()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import type { WriteStream } from 'node:fs'
|
||||
import { finished } from 'node:stream/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -30,8 +32,9 @@ afterEach(() => { external.archive.mockReset(); external.signature.mockReset();
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const builderRequire = createRequire(require.resolve('app-builder-lib/package.json'))
|
||||
const { createPackageWithOptions } = builderRequire('@electron/asar') as {
|
||||
createPackageWithOptions: (source: string, destination: string, options: { unpack: string }) => Promise<void>
|
||||
const { createPackageWithOptions, uncache } = builderRequire('@electron/asar') as {
|
||||
createPackageWithOptions: (source: string, destination: string, options: { unpack: string }) => Promise<WriteStream>
|
||||
uncache: (archive: string) => boolean
|
||||
}
|
||||
const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const
|
||||
const publisher = 'CN=Fixture,O=Fixture,C=CN'
|
||||
@@ -45,6 +48,7 @@ async function fixture(body: (context: {
|
||||
resealRuntime: () => Promise<void>
|
||||
}) => Promise<void>, version: string = versions[0]): Promise<void> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-package-content-'))
|
||||
const archive = join(root, 'payload/resources/app.asar')
|
||||
try {
|
||||
const run = await createInstalledUpdateRun(root, versions, { version: '0.1.5-rc.2', commit: 'a'.repeat(40), dirtyFiles: [] })
|
||||
const manifest = join(run.root, 'run.json')
|
||||
@@ -80,14 +84,20 @@ async function fixture(body: (context: {
|
||||
url: `${run.origin}/${run.feedKey.slice(0, -'nightly.yml'.length)}`, publisherName: [publisher],
|
||||
updaterCacheDirName: `dsh-update-test-${run.id}-updater` }))
|
||||
const seal = async () => {
|
||||
await createPackageWithOptions(source, join(payload, 'resources/app.asar'), { unpack: '**/*.exe' })
|
||||
// ASAR 3 returns the output stream after end(), before its writes finish.
|
||||
const output = await createPackageWithOptions(source, archive, { unpack: '**/*.exe' })
|
||||
await finished(output, { cleanup: true })
|
||||
uncache(archive)
|
||||
}
|
||||
await seal()
|
||||
await body({ manifest, source, payload, version, seal, resealRuntime: async () => {
|
||||
reseal(join(source, 'dsh'))
|
||||
await seal()
|
||||
} })
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
} finally {
|
||||
uncache(archive)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe('installed update archive contents', () => {
|
||||
|
||||
@@ -100,6 +100,9 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
|
||||
},
|
||||
})
|
||||
cpSync(join(repository, 'packages/skill/skill-office/assets'), join(root, 'runtime/office-skills'), { recursive: true })
|
||||
const nodeBin = join(root, 'runtime/primary-runtime/dependencies/node/bin')
|
||||
mkdirSync(nodeBin, { recursive: true })
|
||||
cpSync(process.execPath, join(nodeBin, process.platform === 'win32' ? 'node.exe' : 'node'))
|
||||
const paths = resolveDesktopPaths(home)
|
||||
const manager = new DesktopProjectManager(paths, {
|
||||
dsh: project,
|
||||
|
||||
@@ -74,6 +74,16 @@ async function fixture(external: boolean) {
|
||||
}
|
||||
await writeFile(join(source, 'node_modules', 'foo', 'companion.json'), '{}')
|
||||
await writeFile(join(source, 'node_modules', 'foo', '$xarchy.binary'), 'neighbor')
|
||||
for (const [name, manifest] of [
|
||||
['@deepseek-ai/libreoffice-kit', { name: '@deepseek-ai/libreoffice-kit', optionalDependencies: { '@deepseek-ai/libreoffice-kit-win32-x64': '0.0.4' }, dependencies: { 'office-codec': '1' } }],
|
||||
['@deepseek-ai/libreoffice-kit-win32-x64', { name: '@deepseek-ai/libreoffice-kit-win32-x64' }],
|
||||
['office-codec', { name: 'office-codec' }],
|
||||
] as const) {
|
||||
const directory = join(source, 'node_modules', name)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'package.json'), JSON.stringify(manifest))
|
||||
await writeFile(join(directory, 'cli.js'), 'export {}')
|
||||
}
|
||||
const config = {
|
||||
files: [{ from: source, to: 'dsh', filter: ['**/*'] },
|
||||
{ from: join(source, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }],
|
||||
@@ -235,6 +245,9 @@ it.each([false, true])('keeps the complete Office engine outside ASAR with exter
|
||||
await packageFixture(input)
|
||||
const archive = await readAsar(join(input.resources, 'app.asar'))
|
||||
expect(archive.getFile(join('dsh', 'node_modules', '@deepseek-ai', 'libreoffice-kit-wasm', 'package.json')).unpacked).not.toBe(true)
|
||||
for (const name of ['@deepseek-ai/libreoffice-kit', 'office-codec']) {
|
||||
expect(archive.getFile(join('dsh', 'node_modules', name, 'cli.js')).unpacked).toBe(true)
|
||||
}
|
||||
for (const file of files) {
|
||||
expect(archive.getFile(join('dsh', engine, file), false).unpacked).toBe(true)
|
||||
expect(await readFile(join(input.resources, 'app.asar.unpacked', 'dsh', engine, file), 'utf8')).toBe('{}')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** The preset settings page only selects a preset; creating one starts a Creator-mode task. */
|
||||
/** The preset settings page selects a preset and shows what it declares; creating one starts a Creator-mode task. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
@@ -27,13 +27,13 @@ describe('web e2e: preset roster guidance', () => {
|
||||
}, 120_000)
|
||||
afterAll(async () => { await browser?.close(); await scaffold?.close() })
|
||||
|
||||
it('shows the shipped roster with mode help and no editing actions', async () => {
|
||||
it('shows the shipped roster with mode help and a read-only view, and no editing actions', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'preset-roster-section'))
|
||||
await expect.poll(() => page.locator('[data-agent-preset-id]').count()).toBe(4)
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(join(EXPECTED, 'section.expected.md'), snapshot, mode)
|
||||
expect(snapshot).toContain('让 Agent 帮我创建预设模式')
|
||||
expect(snapshot).not.toContain('查看配置')
|
||||
expect(snapshot).toContain('查看配置: 标准模式')
|
||||
expect(snapshot).not.toContain('复制预设')
|
||||
expect(snapshot).not.toContain('编辑插件')
|
||||
expect(snapshot).not.toContain('打开目录')
|
||||
@@ -55,6 +55,27 @@ describe('web e2e: preset roster guidance', () => {
|
||||
expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('views a shipped composition read-only', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'preset-roster-view'))
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.getByRole('button', { name: '查看配置: PTC 模式', exact: true }).click()
|
||||
const viewer = page.getByRole('dialog', { name: '查看配置 · PTC 模式', exact: true })
|
||||
await viewer.waitFor({ timeout: 10_000 })
|
||||
// The real shipped declaration, not a golden: the viewer shows whatever
|
||||
// the deployment ships, and this lane only asserts it is shown read-only
|
||||
// in the Loader's own dialect.
|
||||
const shown = await viewer.locator('pre').textContent()
|
||||
expect(shown).toContain("- id: persona\n name: '@deepseek-ai/dsh-persona'\n")
|
||||
expect(shown).toContain('- id: workflow-ptc\n')
|
||||
expect(shown).toContain("disabled: !!js process.platform === 'win32'\n")
|
||||
expect(shown).not.toContain('__jsExpr')
|
||||
expect(await viewer.getByRole('textbox').count()).toBe(0)
|
||||
// The header X and the footer button share the 关闭 name; the footer one is last.
|
||||
await viewer.getByRole('button', { name: '关闭', exact: true }).last().click()
|
||||
await viewer.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('starts a Creator-mode task from the section entry', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'preset-roster-creator'))
|
||||
// The entry stages the self-referential preset and lands a new task on it,
|
||||
|
||||
@@ -343,6 +343,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
for (const element of elements) element.scrollLeft = 0
|
||||
})
|
||||
}
|
||||
const primaryTextColour = await review.evaluate(element => getComputedStyle(element).color)
|
||||
const addedLine = review.locator('[data-diff-side="right"] [data-diff-line="add"]')
|
||||
const rightContextLine = review.locator('[data-diff-side="right"] [data-diff-line="context"]')
|
||||
const addedRule = await addedLine.evaluate((line) => {
|
||||
@@ -351,7 +352,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
if (number === undefined || text === null) throw new Error('added diff line is incomplete')
|
||||
const left = line.getBoundingClientRect().left
|
||||
return {
|
||||
shadow: getComputedStyle(line).boxShadow,
|
||||
shadow: getComputedStyle(number).boxShadow,
|
||||
markerColour: getComputedStyle(number).color,
|
||||
textColour: getComputedStyle(text).color,
|
||||
numberLeft: number.getBoundingClientRect().left - left,
|
||||
textLeft: text.getBoundingClientRect().left - left,
|
||||
@@ -367,7 +369,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
textLeft: text.getBoundingClientRect().left - left,
|
||||
}
|
||||
})
|
||||
expect(addedRule.shadow).toContain(addedRule.textColour)
|
||||
expect(addedRule.shadow).toContain(addedRule.markerColour)
|
||||
expect(addedRule.textColour).toBe(primaryTextColour)
|
||||
expect(addedRule.numberLeft).toBeCloseTo(rightContextRule.numberLeft, 1)
|
||||
expect(addedRule.textLeft).toBeCloseTo(rightContextRule.textLeft, 1)
|
||||
// The ignored file has no snapshot; its comparison comes from the copies captured around the write call.
|
||||
@@ -402,7 +405,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
if (number === undefined || text === null) throw new Error('deleted diff line is incomplete')
|
||||
const left = line.getBoundingClientRect().left
|
||||
return {
|
||||
shadow: getComputedStyle(line).boxShadow,
|
||||
shadow: getComputedStyle(number).boxShadow,
|
||||
markerColour: getComputedStyle(number).color,
|
||||
textColour: getComputedStyle(text).color,
|
||||
numberLeft: number.getBoundingClientRect().left - left,
|
||||
textLeft: text.getBoundingClientRect().left - left,
|
||||
@@ -418,7 +422,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
textLeft: text.getBoundingClientRect().left - left,
|
||||
}
|
||||
})
|
||||
expect(deletedRule.shadow).toContain(deletedRule.textColour)
|
||||
expect(deletedRule.shadow).toContain(deletedRule.markerColour)
|
||||
expect(deletedRule.textColour).toBe(primaryTextColour)
|
||||
expect(deletedRule.numberLeft).toBeCloseTo(leftContextRule.numberLeft, 1)
|
||||
expect(deletedRule.textLeft).toBeCloseTo(leftContextRule.textLeft, 1)
|
||||
await compareTool.click()
|
||||
@@ -470,11 +475,17 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。'])
|
||||
const wrappedAddition = review.locator('[data-diff-line="del"] > span').nth(1)
|
||||
const wrappedAdditionRule = await wrappedAddition.evaluate((cell) => {
|
||||
const number = cell.children[0]
|
||||
const text = cell.querySelector<HTMLElement>('[data-diff-code]')
|
||||
if (text === null) throw new Error('wrapped addition has no text')
|
||||
return { shadow: getComputedStyle(cell).boxShadow, textColour: getComputedStyle(text).color }
|
||||
if (number === undefined || text === null) throw new Error('wrapped addition is incomplete')
|
||||
return {
|
||||
shadow: getComputedStyle(number).boxShadow,
|
||||
markerColour: getComputedStyle(number).color,
|
||||
textColour: getComputedStyle(text).color,
|
||||
}
|
||||
})
|
||||
expect(wrappedAdditionRule.shadow).toContain(wrappedAdditionRule.textColour)
|
||||
expect(wrappedAdditionRule.shadow).toContain(wrappedAdditionRule.markerColour)
|
||||
expect(wrappedAdditionRule.textColour).toBe(primaryTextColour)
|
||||
// No desktop, so the tools offer the sidebar file but no native open.
|
||||
expect(await review.locator('[data-review-tool="open-file"]').count()).toBe(1)
|
||||
expect(await review.locator('[data-review-tool="open-native"]').count()).toBe(0)
|
||||
|
||||
@@ -9,12 +9,12 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
|
||||
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import { realOfficeBytes } from './office-fixture.ts'
|
||||
import { excelFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts'
|
||||
import { excelFixture, excelFreezeFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts'
|
||||
import { excelDrawingFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-drawing-fixture.ts'
|
||||
import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts'
|
||||
import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
|
||||
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspace, connectFreshWorkspaceZh, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url))
|
||||
@@ -104,15 +104,39 @@ async function hideDocumentZoom(page: Page, preview: Locator): Promise<void> {
|
||||
await expect.poll(() => preview.locator('[data-document-zoom-controls]').getAttribute('data-document-zoom-visible')).toBeNull()
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while retaining the selected cell', async () => {
|
||||
/** The canvas fills the space between the formula bar and sheet tabs without a statistics row. */
|
||||
async function expectExcelLayout(excel: Locator): Promise<void> {
|
||||
await expect.poll(() => excel.locator('.fortune-stat-area').isVisible()).toBe(false)
|
||||
expect(await excel.locator('.luckysheet-sheets-item-function:visible').count()).toBe(0)
|
||||
await expect.poll(() => excel.evaluate((node) => {
|
||||
const pane = node.getBoundingClientRect()
|
||||
const canvas = node.querySelector('canvas')!.getBoundingClientRect()
|
||||
const formula = node.querySelector('.fortune-workarea')!.getBoundingClientRect()
|
||||
const tabs = node.querySelector('.luckysheet-sheet-area')!.getBoundingClientRect()
|
||||
const scroller = node.querySelector('.fortune-sheettab-container')!.getBoundingClientRect()
|
||||
const controls = [...node.querySelectorAll('.fortune-sheettab-scroll, .fortune-zoom-button')]
|
||||
.map(control => control.getBoundingClientRect())
|
||||
return Math.max(Math.abs(canvas.left - pane.left), Math.abs(canvas.width - pane.width),
|
||||
Math.abs(canvas.top - formula.bottom), Math.abs(canvas.bottom - tabs.top), Math.abs(tabs.bottom - pane.bottom),
|
||||
Math.abs(scroller.left - tabs.left), scroller.right - controls[0]!.left,
|
||||
...controls.map((control, index) => control.right - (controls[index + 1]?.left ?? tabs.right)))
|
||||
})).toBeLessThanOrEqual(1)
|
||||
expect(await excel.locator('.fortune-sheettab-scroll, .fortune-zoom-button').evaluateAll(controls => controls.every((control) => {
|
||||
const rect = control.getBoundingClientRect()
|
||||
return control.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2))
|
||||
}))).toBe(true)
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pane with read-only controls in %s', async (locale) => {
|
||||
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
const page = await newEnglishPage(browser)
|
||||
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${process.pid}`) })
|
||||
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale, timezoneId: 'Asia/Shanghai' })
|
||||
const consoleErrors = watchConsole(page)
|
||||
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${locale}-${process.pid}`) })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await (locale === 'zh-CN' ? connectFreshWorkspaceZh : connectFreshWorkspace)(page, scaffold.workspaceCwd)
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill(PROMPT)
|
||||
@@ -120,7 +144,14 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while
|
||||
const sessionId = await settled
|
||||
const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
|
||||
if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
|
||||
await writeFile(join(cwd, 'budget.xlsx'), await excelFixture())
|
||||
const longCellText = 'Monthly export/import matrix and category comparison. 各商品类别分月出口/进口矩阵与结构对比图(公式汇总) '.repeat(3)
|
||||
await Promise.all([
|
||||
writeFile(join(cwd, 'budget.xlsx'), await excelFixture()),
|
||||
writeFile(join(cwd, 'meeting.xlsx'), await meetingMinutesFixture()),
|
||||
writeFile(join(cwd, 'legacy.xls'), xlsFixture()),
|
||||
writeFile(join(cwd, 'values.csv'), `${longCellText},2\n3,4\n`),
|
||||
writeFile(join(cwd, 'values.tsv'), '1\t2\n3\t4\n'),
|
||||
])
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
@@ -132,6 +163,16 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 140, y: 30 } })
|
||||
const formula = excel.locator('.fortune-fx-input')
|
||||
await expect.poll(() => formula.innerText()).toBe('46281')
|
||||
await page.keyboard.press('Shift+ArrowLeft')
|
||||
const selection = excel.locator('.fortune-name-box')
|
||||
await expect.poll(() => selection.innerText()).toBe('A1:B1')
|
||||
await expectExcelLayout(excel)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
|
||||
await page.keyboard.press('ControlOrMeta+C')
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('42\t2026-09-16')
|
||||
const arrow = excel.locator('.luckysheet-sheets-item-function').first()
|
||||
await arrow.evaluate((node) => { (node as HTMLElement).focus() })
|
||||
expect(await arrow.evaluate(node => document.activeElement === node)).toBe(false)
|
||||
const canvas = excel.locator('canvas').first()
|
||||
const originalCanvas = await canvas.elementHandle()
|
||||
const original = await canvas.boundingBox()
|
||||
@@ -145,17 +186,227 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while
|
||||
await expect.poll(() => formula.innerText()).toBe('46281')
|
||||
expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(page.viewportSize()).toEqual(viewport)
|
||||
await successShot(page, 'excel-resize-wide')
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-resize-wide-${locale}`)
|
||||
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
|
||||
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(360)
|
||||
await expectExcelLayout(excel)
|
||||
expect(await excel.locator('.fortune-sheettab-container').evaluate((node) => {
|
||||
const selected = node.querySelector('.luckysheet-sheets-item-active')!.getBoundingClientRect()
|
||||
return selected.right - node.getBoundingClientRect().right
|
||||
})).toBeLessThanOrEqual(1)
|
||||
await page.setViewportSize({ width: 1680, height: 600 })
|
||||
await expect.poll(async () => Math.round(original.height - (await canvas.boundingBox())!.height)).toBe(400)
|
||||
await expectExcelLayout(excel)
|
||||
expect(await selection.innerText()).toBe('A1:B1')
|
||||
await successShot(page, `excel-resize-narrow-${locale}`)
|
||||
await page.setViewportSize(viewport!)
|
||||
await layout.evaluate((node) => { node.textContent = '' })
|
||||
await expect.poll(async () => Math.round((await canvas.boundingBox())!.width)).toBe(Math.round(original.width))
|
||||
expect(await formula.innerText()).toBe('46281')
|
||||
await successShot(page, 'excel-resize-restored')
|
||||
expect(await selection.innerText()).toBe('A1:B1')
|
||||
expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true)
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-resize-restored-${locale}`)
|
||||
const zoom = excel.locator('.fortune-zoom-ratio-current')
|
||||
await excel.locator('.fortune-zoom-button').first().click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('90%')
|
||||
await excel.locator('.fortune-zoom-button').last().click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('100%')
|
||||
await zoom.click()
|
||||
await excel.locator('.fortune-zoom-ratio-item').getByText('150%', { exact: true }).click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('150%')
|
||||
await zoom.click()
|
||||
await excel.locator('.fortune-zoom-ratio-item').getByText('100%', { exact: true }).click()
|
||||
const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText(locale === 'zh-CN' ? '文件' : 'Files', { exact: true }) })
|
||||
const preview = column.locator('[data-textpreview-url]')
|
||||
await openPreviewFile(column, filesTab, preview, 'meeting.xlsx')
|
||||
const tabScroller = excel.locator('.fortune-sheettab-container-c')
|
||||
const activeSheet = excel.locator('.luckysheet-sheets-item-active .luckysheet-sheets-item-name')
|
||||
await expect.poll(() => activeSheet.innerText()).toBe('会议信息')
|
||||
const meetingCanvas = await canvas.elementHandle()
|
||||
if (meetingCanvas === null) throw new Error('meeting spreadsheet canvas is unavailable')
|
||||
await expectExcelLayout(excel)
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 60, y: 40 } })
|
||||
await expect.poll(() => formula.innerText()).toBe('会议纪要')
|
||||
await expect.poll(() => selection.innerText()).toBe('A1')
|
||||
for (const width of [1000, 360, 1000, 360]) {
|
||||
await layout.evaluate((node, width) => { node.textContent = `[data-sidebar-right-panel] { width: ${width}px !important; }` }, width)
|
||||
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width)
|
||||
await expectExcelLayout(excel)
|
||||
expect(await meetingCanvas.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(await selection.innerText()).toBe('A1')
|
||||
await expect.poll(() => excel.locator('.fortune-sheettab-scroll').count()).toBe(width === 360 ? 2 : 0)
|
||||
}
|
||||
for (const direction of ['right', 'left'] as const) {
|
||||
const arrow = excel.locator(`#fortune-sheettab-${direction}scroll`)
|
||||
for (let step = 0; step < 8; step += 1) {
|
||||
const { offset, maximum } = await tabScroller.evaluate(node => ({
|
||||
offset: node.scrollLeft, maximum: node.scrollWidth - node.clientWidth,
|
||||
}))
|
||||
const target = direction === 'right' ? Math.min(offset + 150, maximum) : Math.max(offset - 150, 0)
|
||||
if (Math.abs(target - offset) <= 1) break
|
||||
await arrow.click()
|
||||
await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBeCloseTo(target, 0)
|
||||
}
|
||||
const sheetName = direction === 'right' ? '填写说明' : '会议信息'
|
||||
await excel.locator('.luckysheet-sheets-item-name').getByText(sheetName, { exact: true }).click()
|
||||
await expect.poll(() => activeSheet.innerText()).toBe(sheetName)
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-navigation-${direction}-${locale}`)
|
||||
}
|
||||
expect(await tabScroller.evaluate(node => Math.abs(
|
||||
node.firstElementChild!.getBoundingClientRect().left - node.getBoundingClientRect().left,
|
||||
))).toBeLessThanOrEqual(1)
|
||||
await excel.locator('.fortune-zoom-button').first().click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('90%')
|
||||
await excel.locator('.fortune-zoom-button').last().click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('100%')
|
||||
await zoom.click()
|
||||
await excel.locator('.fortune-zoom-ratio-item').getByText('150%', { exact: true }).click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('150%')
|
||||
expect(await activeSheet.innerText()).toBe('会议信息')
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-navigation-zoom-${locale}`)
|
||||
await layout.evaluate((node) => { node.textContent = '' })
|
||||
await meetingCanvas.dispose()
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
|
||||
for (const name of ['budget.xlsx', 'meeting.xlsx', 'legacy.xls', 'values.csv', 'values.tsv']) {
|
||||
await openPreviewFile(column, filesTab, preview, name)
|
||||
await excel.locator('.fortune-sheet-overlay').waitFor()
|
||||
await expectExcelLayout(excel)
|
||||
if (name === 'values.csv') {
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
|
||||
await expect.poll(() => formula.textContent()).toBe(longCellText)
|
||||
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
|
||||
await expectExcelLayout(excel)
|
||||
await expect.poll(() => formula.evaluate((node) => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(node)
|
||||
return range.getClientRects().length
|
||||
})).toBe(1)
|
||||
expect(await formula.evaluate(node => node.scrollHeight <= node.clientHeight)).toBe(true)
|
||||
expect(await formula.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true)
|
||||
await formula.hover()
|
||||
await page.mouse.wheel(400, 0)
|
||||
await expect.poll(() => formula.evaluate(node => node.scrollLeft)).toBeGreaterThan(0)
|
||||
await successShot(page, `excel-formula-single-line-${locale}-${colorScheme}`)
|
||||
await layout.evaluate((node) => { node.textContent = '' })
|
||||
await expectExcelLayout(excel)
|
||||
}
|
||||
await successShot(page, `excel-controls-${name}-${locale}-${colorScheme}`)
|
||||
}
|
||||
}
|
||||
expect(consoleErrors.pageErrors).toEqual([])
|
||||
await originalCanvas.dispose()
|
||||
} finally {
|
||||
try { await browser?.close() } finally { await scaffold.close() }
|
||||
}
|
||||
})
|
||||
|
||||
/** Sample the pixel band before the divider center, excluding the adjacent cell grid line. */
|
||||
async function freezeDividerInk(excel: Locator, axis: 'x' | 'y'): Promise<number> {
|
||||
return await excel.evaluate((node, direction) => {
|
||||
const canvas = node.querySelector('canvas')!
|
||||
const bounds = canvas.getBoundingClientRect()
|
||||
const scale = canvas.width / bounds.width
|
||||
const columnHeader = node.querySelector<HTMLElement>('.fortune-col-header')!
|
||||
const rowHeader = node.querySelector<HTMLElement>('.fortune-row-header')!
|
||||
const columnHandle = node.querySelector<HTMLElement>('.fortune-cols-freeze-handle')!
|
||||
const rowHandle = node.querySelector<HTMLElement>('.fortune-rows-freeze-handle')!
|
||||
// Canvas coordinates include the 1.5px omitted from FortuneSheet's header elements.
|
||||
const x = Number.parseFloat(rowHeader.style.width) + 1.5 + Number.parseFloat(columnHandle.style.left) - columnHeader.scrollLeft
|
||||
const y = Number.parseFloat(columnHeader.style.height) + 1.5 + Number.parseFloat(rowHandle.style.top) - rowHeader.scrollTop - 2
|
||||
const context = canvas.getContext('2d')!
|
||||
let ink = 0
|
||||
for (let offset = -Math.round(scale); offset < 0; offset += 1) {
|
||||
const px = direction === 'x' ? Math.floor(x * scale) + offset : Math.floor((bounds.width - 25) * scale)
|
||||
const py = direction === 'y' ? Math.floor(y * scale) + offset : Math.floor((y + 15) * scale)
|
||||
const pixel = context.getImageData(px, py, 1, 1).data
|
||||
ink += 255 - pixel[0]!
|
||||
}
|
||||
return ink / scale
|
||||
}, axis)
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record').each([1, 2])('keeps frozen headings without dividers at DPR %i', async (deviceScaleFactor) => {
|
||||
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, deviceScaleFactor, locale: 'en-US' })
|
||||
const consoleErrors = watchConsole(page)
|
||||
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-freeze-${deviceScaleFactor}-${process.pid}`) })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await page.locator('[data-composer-input]').first().fill(PROMPT)
|
||||
await page.locator('[data-composer-input]').first().press('Enter')
|
||||
const cwd = scaffold.ctx.agents.get(await settled)?.session.header.cwd
|
||||
if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
|
||||
await writeFile(join(cwd, 'freeze.xlsx'), await excelFreezeFixture())
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'freeze.xlsx', exact: true }).click()
|
||||
const excel = column.locator('[data-excel-preview]')
|
||||
const selectTopLeft = async () => {
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 80, y: 35 } })
|
||||
return await excel.locator('.fortune-fx-input').innerText()
|
||||
}
|
||||
for (const sheet of ['Both', 'Rows', 'Columns', 'None']) {
|
||||
await excel.locator('.luckysheet-sheets-item-name').getByText(sheet, { exact: true }).click()
|
||||
await expect.poll(selectTopLeft).toBe('R1C1')
|
||||
for (const handle of ['.fortune-cols-freeze-handle', '.fortune-rows-freeze-handle']) {
|
||||
expect(await excel.locator(handle).isVisible()).toBe(false)
|
||||
expect(await excel.locator(handle).evaluate(node => node.getClientRects().length)).toBe(0)
|
||||
}
|
||||
for (const ratio of ['100%', '150%']) {
|
||||
await excel.locator('.fortune-zoom-ratio-current').click()
|
||||
await excel.locator('.fortune-zoom-ratio-item').getByText(ratio, { exact: true }).click()
|
||||
await expect.poll(() => excel.locator('.fortune-zoom-ratio-current').innerText()).toBe(ratio)
|
||||
await excel.locator('.fortune-fx-input').click()
|
||||
for (const axis of ['x', 'y'] as const) {
|
||||
if (sheet === 'Both' || sheet === (axis === 'x' ? 'Columns' : 'Rows')) {
|
||||
await expect.poll(() => freezeDividerInk(excel, axis)).toBe(0)
|
||||
}
|
||||
}
|
||||
await successShot(page, `excel-freeze-${sheet}-${ratio}-${deviceScaleFactor}`)
|
||||
}
|
||||
await excel.locator('.luckysheet-scrollbar-x').evaluate((node) => { node.scrollLeft = 240 })
|
||||
await excel.locator('.luckysheet-scrollbar-y').evaluate((node) => { node.scrollTop = 240 })
|
||||
await expect.poll(async () => {
|
||||
const value = await selectTopLeft()
|
||||
const match = /^R(\d+)C(\d+)$/.exec(value)
|
||||
return match && { rowFixed: match[1] === '1', columnFixed: match[2] === '1' }
|
||||
}).toEqual({ rowFixed: sheet === 'Both' || sheet === 'Rows', columnFixed: sheet === 'Both' || sheet === 'Columns' })
|
||||
await successShot(page, `excel-freeze-scrolled-${sheet}-${deviceScaleFactor}`)
|
||||
}
|
||||
await excel.locator('.luckysheet-sheets-item-name').getByText('Both', { exact: true }).click()
|
||||
await expect.poll(selectTopLeft).toBe('R1C1')
|
||||
const canvas = await excel.locator('canvas').first().elementHandle()
|
||||
const selection = await excel.locator('.fortune-name-box').innerText()
|
||||
const layout = await page.addStyleTag({ content: '[data-sidebar-right-panel] { width: 360px !important; }' })
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-freeze-narrow-${colorScheme}-${deviceScaleFactor}`)
|
||||
}
|
||||
await layout.evaluate(node => node.parentNode!.removeChild(node))
|
||||
await expectExcelLayout(excel)
|
||||
expect(await canvas!.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(await excel.locator('.fortune-name-box').innerText()).toBe(selection)
|
||||
await canvas!.dispose()
|
||||
expect(consoleErrors.pageErrors).toEqual([])
|
||||
} finally {
|
||||
try { await browser?.close() } finally { await scaffold.close() }
|
||||
}
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
- text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。
|
||||
- 'button "模式说明: 标准模式"': 模式说明
|
||||
- 'button "如何使用: 标准模式"': 如何使用
|
||||
- 'button "查看配置: 标准模式"': 查看配置
|
||||
- listitem:
|
||||
- 'button "设为新任务默认: PTC 模式"':
|
||||
- text: PTC 模式 内置
|
||||
@@ -28,6 +29,7 @@
|
||||
- text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。
|
||||
- 'button "模式说明: PTC 模式"': 模式说明
|
||||
- 'button "如何使用: PTC 模式"': 如何使用
|
||||
- 'button "查看配置: PTC 模式"': 查看配置
|
||||
- listitem:
|
||||
- 'button "设为新任务默认: 极简模式"':
|
||||
- text: 极简模式 内置
|
||||
@@ -35,6 +37,7 @@
|
||||
- text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。
|
||||
- 'button "模式说明: 极简模式"': 模式说明
|
||||
- 'button "如何使用: 极简模式"': 如何使用
|
||||
- 'button "查看配置: 极简模式"': 查看配置
|
||||
- listitem:
|
||||
- 'button "设为新任务默认: 创造模式"':
|
||||
- text: 创造模式 内置
|
||||
@@ -42,5 +45,6 @@
|
||||
- text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。
|
||||
- 'button "模式说明: 创造模式"': 模式说明
|
||||
- 'button "如何使用: 创造模式"': 如何使用
|
||||
- 'button "查看配置: 创造模式"': 查看配置
|
||||
- heading "自定义" [level=3]
|
||||
- button "让 Agent 帮我创建预设模式"
|
||||
@@ -9,7 +9,7 @@
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- button "Edited files, called tools, searched code, etc." [expanded]
|
||||
- button "Wrote files, called tools, searched code, etc." [expanded]
|
||||
- button "Write site/report.html +1 -0":
|
||||
- text: Write
|
||||
- button "site/report.html"
|
||||
|
||||
@@ -17,6 +17,7 @@ const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.
|
||||
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url))
|
||||
const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url))
|
||||
const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url))
|
||||
const PREPARING_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/preparing.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// Elicits the successful and failed sub-rows this scenario asserts.
|
||||
@@ -29,6 +30,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
let releasePreparation: (() => void) | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
@@ -46,6 +48,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
releasePreparation?.()
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
@@ -58,9 +61,38 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
||||
let held = false
|
||||
for await (const chunk of next()) {
|
||||
yield chunk
|
||||
if (!held && chunk.type === 'tool-call-delta' && chunk.name) {
|
||||
held = true
|
||||
await gate.promise
|
||||
}
|
||||
}
|
||||
}, { prepend: true })
|
||||
releasePreparation = () => { gate.resolve(undefined); dispose() }
|
||||
const observePreparation = async () => {
|
||||
try {
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const row = page.locator('[data-chat-call-id] [data-state="preparing"]').first()
|
||||
await row.waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await expandOwningTurnProcess(page, row)
|
||||
await row.waitFor({ state: 'visible' })
|
||||
await page.getByRole('button', { name: 'Preparing to run code', exact: true }).waitFor()
|
||||
expect(sessionEvents.some(event => event.type === 'tool/call')).toBe(false)
|
||||
expect(await row.getByRole('button').count()).toBe(0)
|
||||
expect(await row.locator('pre').count()).toBe(0)
|
||||
const group = row.locator('xpath=ancestor::*[@data-chat-group-key][1]')
|
||||
await compareOrRefreshGolden(PREPARING_EXPECTED, await group.ariaSnapshot(), MODE)
|
||||
} finally {
|
||||
releasePreparation?.()
|
||||
}
|
||||
}
|
||||
const [sessionId] = await Promise.all([settled, observePreparation()])
|
||||
await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0)
|
||||
if (MODE === 'record') {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
@@ -118,6 +150,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0)
|
||||
}
|
||||
const nest = page.locator('[data-subcalls]').first()
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
|
||||
@@ -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: 49e6270d1fbe4448bc7798cc23f84eac660642fe
|
||||
config-catalog.zh.md: d96fd56531bf8d994ae982e287cd4c7ff7b61983
|
||||
config-catalog.md: 21f24e79b8ab6906807e612821d6cc8d1f704c12
|
||||
config-catalog.zh.md: 42c1d1528c8de305420baca69792ac60c6071773
|
||||
@@ -2213,6 +2213,8 @@ export interface Config {
|
||||
inspectTimeoutMs?: number
|
||||
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
|
||||
githubConnectionTimeoutMs?: number
|
||||
/** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */
|
||||
idleTimeoutMs?: number
|
||||
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
|
||||
registry?: string
|
||||
/**
|
||||
@@ -2276,7 +2278,7 @@ export interface LaunchConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:26`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
|
||||
Source: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:25`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-pwsh-local"></a>
|
||||
|
||||
@@ -2796,10 +2798,14 @@ Requires: `skills`
|
||||
export interface Config {
|
||||
/** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */
|
||||
assetRoot?: string
|
||||
/** Standalone Node executable; defaults to the current executable outside Electron and SEA. */
|
||||
node?: string
|
||||
/** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */
|
||||
cli?: string | false
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts)
|
||||
Source: [`packages/skill/skill-office/src/index.ts:16`](../packages/skill/skill-office/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-spill-local"></a>
|
||||
|
||||
|
||||
@@ -2215,6 +2215,8 @@ export interface Config {
|
||||
inspectTimeoutMs?: number
|
||||
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
|
||||
githubConnectionTimeoutMs?: number
|
||||
/** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */
|
||||
idleTimeoutMs?: number
|
||||
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
|
||||
registry?: string
|
||||
/**
|
||||
@@ -2278,7 +2280,7 @@ export interface LaunchConfig {
|
||||
}
|
||||
```
|
||||
|
||||
来源: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:26`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
|
||||
来源: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:25`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-pwsh-local"></a>
|
||||
|
||||
@@ -2798,10 +2800,14 @@ export interface Config {
|
||||
export interface Config {
|
||||
/** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */
|
||||
assetRoot?: string
|
||||
/** Standalone Node executable; defaults to the current executable outside Electron and SEA. */
|
||||
node?: string
|
||||
/** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */
|
||||
cli?: string | false
|
||||
}
|
||||
```
|
||||
|
||||
来源: [`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts)
|
||||
来源: [`packages/skill/skill-office/src/index.ts:16`](../packages/skill/skill-office/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-spill-local"></a>
|
||||
|
||||
|
||||
@@ -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/event-producer-consumer.md
|
||||
event-producer-consumer.md: 1d844013f1230d6dbe535c3942279683f03738ce
|
||||
event-producer-consumer.zh.md: 5961d9d623b753d0e2e478dabf3a26c7b566a26c
|
||||
event-producer-consumer.md: 54a5e36bb6d2c39606c2e0263cad89293e6bb613
|
||||
event-producer-consumer.zh.md: 3c0da089d7c99bd96dfe8bb45a4e9c07dcd9f2be
|
||||
@@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
|
||||
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:59`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` |
|
||||
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:71`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` |
|
||||
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
|
||||
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
@@ -52,9 +52,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:230`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
|
||||
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:59`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` |
|
||||
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:71`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` |
|
||||
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
|
||||
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
@@ -52,9 +52,9 @@
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:230`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
| spawn | spawn | | | |
|
||||
| steering | steering | steering(中途引导) | | |
|
||||
| job id | job id | | 任务 id | 保留英文 |
|
||||
| subagent | subagent | | | |
|
||||
| subagent | subagent | | | 文档正文保留英文;中文 UI 中译作「子智能体」,不使用「子代理」 |
|
||||
| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |
|
||||
| Typert | Typert | | TypeRT、typeRT、Type RT | DeepSeek Harness 类型图、生成器、loader 与运行时 registry 的产品拼写 |
|
||||
| waterfall | waterfall | waterfall(瀑布式事件) | | |
|
||||
|
||||
@@ -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/persistence-catalog.md
|
||||
persistence-catalog.md: 200e7156f65c4cba6fab6808a84326e85df69ddd
|
||||
persistence-catalog.zh.md: fa48684d4456c6338b67d58bc051c3672d6ee2c9
|
||||
persistence-catalog.md: cc41ab5b6acd977a680e92b0b509bc4edb672dc9
|
||||
persistence-catalog.zh.md: a4d5d4cb4ab2ac5d55de0d3f2ea0d2c46265c277
|
||||
@@ -6654,7 +6654,7 @@ Sources: [`packages/session/session-title/src/types.ts:30`](../packages/session/
|
||||
|
||||
SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd`
|
||||
|
||||
Sources: [`packages/core/tools/src/ptc.ts:14`](../packages/core/tools/src/ptc.ts)
|
||||
Sources: [`packages/core/tools/src/ptc.ts:15`](../packages/core/tools/src/ptc.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
|
||||
@@ -6656,7 +6656,7 @@ SHA-256: `b81e9de81a481b24e1e1f9adf038bfc59e57b4f35ce62f8f1e45284186f4f812`
|
||||
|
||||
SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd`
|
||||
|
||||
来源:[`packages/core/tools/src/ptc.ts:14`](../packages/core/tools/src/ptc.ts)
|
||||
来源:[`packages/core/tools/src/ptc.ts:15`](../packages/core/tools/src/ptc.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
|
||||
Generated
+1
-1
@@ -78437,7 +78437,7 @@
|
||||
},
|
||||
"names": [],
|
||||
"sources": [
|
||||
"packages/core/tools/src/ptc.ts:14"
|
||||
"packages/core/tools/src/ptc.ts:15"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/boot.md
|
||||
boot.md: 6ec2204ffe04fe91acb89fe16fa8f8d3332299e7
|
||||
boot.zh.md: f5fde00f0cd858450ecd7c75ca42b64e2f4348e4
|
||||
boot.md: 5faf482b0532058e480adf13bf7fee8bb882498d
|
||||
boot.zh.md: 1f50732fd36a996c6bc15b406f7e9a4ac83bcb26
|
||||
@@ -16,7 +16,7 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p
|
||||
|
||||
`PluginRegistries` carries the configured first registry, `null` for the one pnpm's own configuration names, the fallbacks asked after it, and `resolved`, the URL pnpm's own configuration names or `null` while unread. `InspectOptions.registry` names the registry a lookup asks first.
|
||||
|
||||
`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from.
|
||||
`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path, plus `timedOut` when the manager terminated a run that stopped printing. A terminated run is classified `timeout` whatever exit status the signal left behind, so installation and removal report failure instead of success and no further registry is asked. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
`PluginRegistries` 携带配置的第一个注册表(`null` 即 pnpm 自身配置指定的那个)、随后依次询问的备选注册表,以及 `resolved`——pnpm 自身配置指向的 URL,未读到时为 `null`。`InspectOptions.registry` 指定一次查询首先询问的注册表。
|
||||
|
||||
`ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。
|
||||
`ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径;当管理器终止了一个停止打印的运行,还记录 `timedOut`。被终止的运行不论信号留下什么退出状态都归类为 `timeout`,因此安装与删除都报告失败而非成功,也不会再询问下一个注册表。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/conversation.md
|
||||
conversation.md: 9455858acac16584b4d300dc8cd2634eb2a20310
|
||||
conversation.zh.md: 2e7c48ab5f65935c2fcb4de05c4484c965622746
|
||||
conversation.md: dca3d513398cb1e29a8fe16d00fd1bac9c21ce58
|
||||
conversation.zh.md: 18d2a2b282f0ccb02cc903855cf65d542e4160da
|
||||
@@ -13,7 +13,7 @@ The Session Controller owns the contiguous loaded logical-event window. Each `Se
|
||||
| Concept | Owner and purpose |
|
||||
|---|---|
|
||||
| Event Definition | A business package matches one durable or Client-only transient event at a time, correlates it by stable `(kind, id)`, folds deterministic State, and optionally materializes one target node. |
|
||||
| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. A transient event occupies one update Match; update-only evidence may remain pending until pagination supplies its unique durable start. |
|
||||
| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. Durable and transient events may be starts. The earliest loaded start initializes State; later Matches update it. Update-only evidence stays pending until its start is loaded. |
|
||||
| Location | The engine-owned Session, Turn, or Step coordinates derived from durable boundary events. Definitions may publish typed data onto one Turn or Step. |
|
||||
| View Definition | A target package creates one incremental builder per Session and owns the final snapshot type for that target. |
|
||||
| Group Definition | A business package derives one target's root references and group snapshots from materialized Nodes; it owns membership, segmentation, summaries, and incremental caches. |
|
||||
@@ -69,7 +69,7 @@ Use the producer-owned branded id type across the process boundary. Put the `Ses
|
||||
|
||||
Incremental events are supported. Prefer whole-value checkpoints when the producer can emit them cheaply, because they remain useful when the start is outside the loaded window. Each delta must carry the stable id and produce deterministic State when replayed in ascending log `seq`; it must not depend on live-only memory. If the current history window contains only updates, the assembler keeps a pending Context and builds no State until an older page supplies the start. If the product must render before the start is loaded, a terminal or checkpoint event must carry enough whole fallback state for the Definition to build that result directly; do not recover it by scanning unrelated events.
|
||||
|
||||
Live Assistant deltas arrive as Client-only `assistant/live-chunk` updates. Reconnect baselines expand the active process-local compact stream into the same transient events, while durable `assistant/message` and `assistant/attempt` events embed complete compact streams for history replay. Transient events can only be updates; `start()` receives a standard `SessionEvent`. A Definition that consumes Assistant output handles live chunks and durable settlements in the same `match()` and `update()` methods, while unrelated Definitions return `null` without expanding a stream.
|
||||
Live Assistant deltas arrive as Client-only `assistant/live-chunk` events. Reconnect baselines expand the active process-local compact stream into the same transient events, while durable `assistant/message` and `assistant/attempt` events embed complete compact streams for history replay. A transient event may initialize a Context. A named tool delta and its later tool/call can both match as start under the same callId; only the earliest calls start(), and later Matches call update(). Historical pages do not expand settled messages into live deltas. A Definition that consumes Assistant output handles live chunks and durable settlements in the same `match()` and `update()` methods, while unrelated Definitions return `null` without expanding a stream.
|
||||
|
||||
## Definition and typed Chat payload
|
||||
|
||||
@@ -243,11 +243,11 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
```
|
||||
|
||||
`match(event)` is an identity extractor, not a fold: it receives only the current `SessionEventLike` and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and calls `start` once for a standard event or `update` for a standard or packed event. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics.
|
||||
`match(event)` is an identity extractor, not a fold: it receives only the current `SessionEventLike` and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and initializes from its earliest loaded start, whether durable or transient. Every later Match calls `update`, including another start. Removing transient Matches reselects the start from remaining evidence and recomputes State; no remaining start leaves State undefined. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics.
|
||||
|
||||
`buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`.
|
||||
|
||||
`target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`.
|
||||
`target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from the matched event ordering, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`.
|
||||
|
||||
## Predecessor reads
|
||||
|
||||
@@ -274,7 +274,7 @@ With `D` registered Definitions, one incoming scalar event or packed run perform
|
||||
Add focused tests that establish these outcomes:
|
||||
|
||||
1. A complete window passed through replace produces the expected final State, Location data, Node payload, and `anchorSeq`.
|
||||
2. An update-only tail stays pending; prepending the unique start produces the same result as a complete replace.
|
||||
2. An update-only tail stays pending; prepending its start produces the same result as a complete replace.
|
||||
3. Initial history followed by live append produces the same result as replaying the combined window.
|
||||
4. Prepending an older page adds earlier rows without replacing existing keyed Node values whose data did not change.
|
||||
5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested.
|
||||
|
||||
@@ -13,7 +13,7 @@ Session Controller 拥有连续的已加载逻辑 event window。每个 `Session
|
||||
| 概念 | Owner 与用途 |
|
||||
|---|---|
|
||||
| Event Definition | 业务包一次匹配一个持久 event 或 Client-only 瞬态 event,以稳定 `(kind, id)` 关联输入、折叠确定性 State,并可选择 materialize 一个 target node。 |
|
||||
| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。一个瞬态 event 只占一个 update Match;只有 update 的证据可以保持 pending,直到分页补齐其唯一持久 start。 |
|
||||
| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。持久和瞬态事件都可以作为 start。当前最早的 start 初始化 State,后续 Match 更新它。只有 update 的证据保持 pending,直到其 start 加载。 |
|
||||
| Location | Engine 根据持久 boundary event 推导的 Session、Turn 或 Step 坐标。Definition 可以向一个 Turn 或 Step 发布类型化数据。 |
|
||||
| View Definition | Target 包为每个 Session 创建一个增量 builder,并拥有该 target 的最终 snapshot 类型。 |
|
||||
| Group Definition | 业务包从物化后的 Node 派生一个目标的根引用和组快照,拥有成员归属、分段、摘要及增量缓存。 |
|
||||
@@ -69,7 +69,7 @@ Group 存储在安装前校验完整提交结果:根 Group 引用与记录一
|
||||
|
||||
系统支持增量事件。如果生产方能以较低成本发出 whole-value checkpoint,应优先采用,因为 start 位于已加载窗口之外时它仍可直接使用。每条 delta 都必须携带稳定 id,并且按照日志 `seq` 升序回放时能够确定性地产生 State;它不能依赖只存在于实时内存中的状态。如果当前历史窗口只有 update,Assembler 会保留一个 pending Context,并在更早分页补齐 start 前不构造 State。如果产品必须在 start 尚未加载时渲染,terminal 或 checkpoint 事件就必须携带足够的完整 fallback 状态,让 Definition 能直接构造结果;不要通过扫描无关事件恢复它。
|
||||
|
||||
实时 Assistant delta 作为 Client-only `assistant/live-chunk` update 到达。重连 baseline 会把活跃的进程内紧凑 stream 展开为相同的瞬态 event,持久 `assistant/message` 与 `assistant/attempt` event 则嵌入完整紧凑 stream 供历史回放。瞬态 event 只能充当 update;`start()` 只接收标准 `SessionEvent`。消费 Assistant 输出的 Definition 在同一组 `match()` 与 `update()` 方法里处理 live chunk 与持久 settlement,其他 Definition 直接返回 `null`,无需展开 stream。
|
||||
实时 Assistant delta 作为 Client-only `assistant/live-chunk` event 到达。重连 baseline 会把活跃的进程内紧凑 stream 展开为相同的瞬态 event,持久 `assistant/message` 与 `assistant/attempt` event 则嵌入完整紧凑 stream 供历史回放。瞬态 event 可以初始化 Context。具名工具 delta 和后续 tool/call 可以按同一个 callId 匹配为 start;只有最早的 Match 调用 start(),后续 Match 调用 update()。历史分页不把已结束消息展开为实时 delta。消费 Assistant 输出的 Definition 在同一组 `match()` 与 `update()` 方法里处理 live chunk 与持久 settlement,其他 Definition 直接返回 `null`,无需展开 stream。
|
||||
|
||||
## Definition 与类型化 Chat payload
|
||||
|
||||
@@ -243,7 +243,7 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
```
|
||||
|
||||
`match(event)` 是身份提取器,不是 fold:它只能收到当前 `SessionEventLike`,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context;标准 event 可触发一次 `start`,标准或 packed event 可把当前 State 交给 `update`。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。
|
||||
`match(event)` 是身份提取器,不是 fold:它只能收到当前 `SessionEventLike`,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context;当前最早的 start 初始化 State,不论它是持久事件还是瞬态事件。后续所有 Match,包括其他 start,都调用 `update`。移除瞬态 Match 后,从剩余事件重新选择 start 并重算 State;没有剩余 start 时 State 为 undefined。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。
|
||||
|
||||
`buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。
|
||||
|
||||
@@ -274,7 +274,7 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的
|
||||
添加聚焦测试,证明以下结果:
|
||||
|
||||
1. 完整窗口通过 replace 后产生预期的最终 State、Location data、Node payload 与 `anchorSeq`。
|
||||
2. 只有 update 的尾部窗口保持 pending;prepend 唯一 start 后,结果与完整 replace 相同。
|
||||
2. 只有 update 的尾部窗口保持 pending;prepend 其 start 后,结果与完整 replace 相同。
|
||||
3. 初始历史后继续实时 append,与回放合并后的完整窗口得到相同结果。
|
||||
4. prepend 更早分页只增加更早的行;数据未变化的既有 keyed Node value 不被替换。
|
||||
5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/core.md
|
||||
core.md: aa9af309c03ebedae353139c4017d19f2607dd7d
|
||||
core.zh.md: b18ff5bba1c0309e9b9dcdf1f3e97adb5895caf6
|
||||
core.md: 64344148708b29fc186a2e33ef187025c4bc5300
|
||||
core.zh.md: 0b85519b916490d6692a31f59ed89527ce72fa43
|
||||
@@ -510,6 +510,12 @@ async list(): Promise<AgentPreset[]>
|
||||
*/
|
||||
async resolve(id?: string): Promise<AgentPreset>
|
||||
|
||||
/** Read one declaration's child plugin list as YAML, for viewing only.
|
||||
* @param agentPreset Preset identity.
|
||||
* @returns The declared composition beside its published metadata.
|
||||
*/
|
||||
@Remote('read') readDocument(agentPreset: string): Promise<AgentPresetDocument>
|
||||
|
||||
/** Bind an unpublished Agent to the current preset revision.
|
||||
* @param ctx Agent context from its setup callback.
|
||||
* @param id Requested preset, or the default.
|
||||
|
||||
@@ -520,6 +520,12 @@ async list(): Promise<AgentPreset[]>
|
||||
*/
|
||||
async resolve(id?: string): Promise<AgentPreset>
|
||||
|
||||
/** Read one declaration's child plugin list as YAML, for viewing only.
|
||||
* @param agentPreset Preset identity.
|
||||
* @returns The declared composition beside its published metadata.
|
||||
*/
|
||||
@Remote('read') readDocument(agentPreset: string): Promise<AgentPresetDocument>
|
||||
|
||||
/** Bind an unpublished Agent to the current preset revision.
|
||||
* @param ctx Agent context from its setup callback.
|
||||
* @param id Requested preset, or the default.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/ptc-runtime.md
|
||||
ptc-runtime.md: efab5bcefa0aaec50c29c73f981f6a6b717cd087
|
||||
ptc-runtime.zh.md: f35c5bd84d79ddaccc40653707d792f5672e73d9
|
||||
ptc-runtime.md: 194431e3b6320eaf245244c6dfcb8b8120eeed7a
|
||||
ptc-runtime.zh.md: 9f1d67fb842c024bb74f213133fbcae6d262d428
|
||||
@@ -18,11 +18,18 @@ Source: [`packages/ptc-runtime/ptc-runtime/src/types.ts`](../../packages/ptc-run
|
||||
interface PtcRunRequest {
|
||||
/**
|
||||
* The program source, in the runtime's {@link ../index.ts | language}. It
|
||||
* runs as the body of an async function: top-level `await` and `return`
|
||||
* are available, and the completion value becomes
|
||||
* follows the provider's programFormat: an async function body supports
|
||||
* top-level `await` and `return`; a REPL supports top-level declarations,
|
||||
* `await`, and a final expression. The completion value becomes
|
||||
* {@link PtcRunResult.value}.
|
||||
*/
|
||||
program: string
|
||||
/**
|
||||
* Object-identity owner for optional in-memory state. Omission requests a fresh
|
||||
* environment. Consumers must await runtime.release(owner) when the owner ends.
|
||||
* Stateless providers ignore this field; retained state is never durable.
|
||||
*/
|
||||
stateOwner?: object
|
||||
/** Host functions exposed to the program, one global object per namespace. */
|
||||
bindings: PtcBindingNamespace[]
|
||||
/** Working directory in the mounted filesystem and subprocess execution world. */
|
||||
@@ -77,7 +84,7 @@ interface PtcRunResult {
|
||||
/** Applied file policy and observed denial, when the provider enforces file policy. */
|
||||
sandbox?: PtcRunSandbox
|
||||
/**
|
||||
* The program's completion value (its top-level `return`), when it ran to
|
||||
* The program's completion value (a return or final REPL expression), when it ran to
|
||||
* completion and the value crossed the runtime's lossless-JSON boundary.
|
||||
* Invalid or over-limit completions fail the run instead of substituting a
|
||||
* rendered string; a failed or value-less run leaves this absent.
|
||||
@@ -94,6 +101,8 @@ interface PtcRunResult {
|
||||
}
|
||||
```
|
||||
|
||||
`programFormat` distinguishes async function bodies from REPL statements. `stateOwner` is an in-process object identity for optional retained memory; distinct objects remain isolated. Consumers await `release(owner)` when that owner ends. Omitted owners are one-shot, and stateless providers ignore ownership. The [persistent Node provider](../../packages/ptc-runtime/ptc-runtime-node/README.md#persistent-repl) defines reset and recovery behavior; no retained object enters the durable Session format.
|
||||
|
||||
## Bindings: host functions as program globals
|
||||
|
||||
Each `PtcBindingNamespace` becomes a global object of async callables; PTC passes `tools`. Arguments and resolutions must be lossless JSON. Providers enforce their own transport caps; the seam sets no uniform binding-byte limit. An optional error-class descriptor creates program-visible typed rejections without naming a consumer inside the runtime. Binding names are own properties, so `__proto__` cannot traverse a prototype:
|
||||
@@ -209,9 +218,16 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.ptcRuntime` — `PtcRuntime` (abstract seam)
|
||||
|
||||
Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal.
|
||||
Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate distinct state owners, and terminate and await in-flight runs during disposal.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Release an owner's retained environment and await its execution resources.
|
||||
* Stateless providers have no retained resources to release.
|
||||
* @param owner - The exact object supplied as the request's state owner.
|
||||
*/
|
||||
release(owner: object): Promise<void>
|
||||
|
||||
/**
|
||||
* Resolve supported options and provider defaults before execution.
|
||||
* @param request - Program, bindings, cancellation and optional execution choices.
|
||||
|
||||
@@ -18,11 +18,18 @@ PTC 执行[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-
|
||||
interface PtcRunRequest {
|
||||
/**
|
||||
* The program source, in the runtime's {@link ../index.ts | language}. It
|
||||
* runs as the body of an async function: top-level `await` and `return`
|
||||
* are available, and the completion value becomes
|
||||
* follows the provider's programFormat: an async function body supports
|
||||
* top-level `await` and `return`; a REPL supports top-level declarations,
|
||||
* `await`, and a final expression. The completion value becomes
|
||||
* {@link PtcRunResult.value}.
|
||||
*/
|
||||
program: string
|
||||
/**
|
||||
* Object-identity owner for optional in-memory state. Omission requests a fresh
|
||||
* environment. Consumers must await runtime.release(owner) when the owner ends.
|
||||
* Stateless providers ignore this field; retained state is never durable.
|
||||
*/
|
||||
stateOwner?: object
|
||||
/** Host functions exposed to the program, one global object per namespace. */
|
||||
bindings: PtcBindingNamespace[]
|
||||
/** Working directory in the mounted filesystem and subprocess execution world. */
|
||||
@@ -77,7 +84,7 @@ interface PtcRunResult {
|
||||
/** Applied file policy and observed denial, when the provider enforces file policy. */
|
||||
sandbox?: PtcRunSandbox
|
||||
/**
|
||||
* The program's completion value (its top-level `return`), when it ran to
|
||||
* The program's completion value (a return or final REPL expression), when it ran to
|
||||
* completion and the value crossed the runtime's lossless-JSON boundary.
|
||||
* Invalid or over-limit completions fail the run instead of substituting a
|
||||
* rendered string; a failed or value-less run leaves this absent.
|
||||
@@ -94,6 +101,8 @@ interface PtcRunResult {
|
||||
}
|
||||
```
|
||||
|
||||
`programFormat` 区分异步函数体与 REPL 语句。`stateOwner` 是标识可选保留内存的进程内对象身份;不同对象相互隔离。消费者在所有者结束时等待 `release(owner)`。省略所有者时仅执行一次,无状态提供方忽略所有权。[持久 Node 提供方](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md#persistent-repl)定义重置和恢复行为;保留对象不会进入持久 Session 格式。
|
||||
|
||||
## 绑定:宿主函数作为程序全局变量
|
||||
|
||||
每个 `PtcBindingNamespace` 成为一个异步可调用函数的全局对象;PTC 传入 `tools`。参数与返回值必须是无损 JSON。提供方强制各自的传输上限;seam 不设统一的绑定字节上限。可选错误类描述符创建程序可见的类型化拒绝,无需在运行时内点名消费方。绑定名是自有属性,因此 `__proto__` 不能遍历原型:
|
||||
@@ -209,9 +218,16 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.ptcRuntime` — `PtcRuntime` (abstract seam)
|
||||
|
||||
Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal.
|
||||
Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate failures resolve in PtcRunResult; only Service Definition contract misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate distinct state owners, and terminate and await in-flight runs during disposal.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Release an owner's retained environment and await its execution resources.
|
||||
* Stateless providers have no retained resources to release.
|
||||
* @param owner - The exact object supplied as the request's state owner.
|
||||
*/
|
||||
release(owner: object): Promise<void>
|
||||
|
||||
/**
|
||||
* Resolve supported options and provider defaults before execution.
|
||||
* @param request - Program, bindings, cancellation and optional execution choices.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
|
||||
README.md: 803566c8650df7186c5344945ea0fe2359e8e70c
|
||||
README.zh.md: 657e4587e2d813dd53b44fdb15a23f905f2c0dca
|
||||
README.md: 24936df8b1be4e2997c2382e0d84fddd2dfc5345
|
||||
README.zh.md: 122ccc9a2796caf891b19a38c3c90e9d32a22fdc
|
||||
@@ -68,6 +68,7 @@ When pnpm 11 blocks dependency scripts, the failed installation reports every pe
|
||||
| `fallbackRegistries` | `['https://registry.npmmirror.com/']` | Registries asked in turn, as http(s) URLs, while the one before is unreachable or holds no copy of the package; pnpm's own registry joins the order only while it names npm's own registry or one of these. |
|
||||
| `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. |
|
||||
| `lockWaitMs` | `120000` | Maximum time in milliseconds to acquire the profile write lock. |
|
||||
| `idleTimeoutMs` | `600000` | Maximum time in milliseconds a service package run may capture no output before the manager terminates it; a run with inherited descriptors (`dsh plugin`) is never bound. |
|
||||
|
||||
-----
|
||||
|
||||
@@ -77,7 +78,7 @@ When pnpm 11 blocks dependency scripts, the failed installation reports every pe
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. Dependency-only changes do not trigger configuration reloads.
|
||||
The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Dependency-only changes do not trigger configuration reloads.
|
||||
|
||||
Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled installation restores the manifest and lockfile it snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); a failed removal retains its partial changes and diagnostics. Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection.
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro
|
||||
| `fallbackRegistries` | `['https://registry.npmmirror.com/']` | 前一个注册表不可达或没有该包副本时依次询问的注册表,http(s) URL;pnpm 自身的注册表只在它指向 npm 官方源或这里的某一个时才进入顺序。 |
|
||||
| `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 |
|
||||
| `lockWaitMs` | `120000` | 获取 profile 写锁的最长等待毫秒数。 |
|
||||
| `idleTimeoutMs` | `600000` | service 包操作允许持续无捕获输出的最长毫秒数,达到即被管理器终止;继承描述符运行的 `dsh plugin` 不受此上界约束。 |
|
||||
|
||||
-----
|
||||
|
||||
@@ -77,7 +78,7 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。仅依赖字段变化不会触发配置重载。
|
||||
服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。仅依赖字段变化不会触发配置重载。
|
||||
|
||||
结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。失败或被取消的安装会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));失败的删除保留部分改动和诊断。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface Config {
|
||||
inspectTimeoutMs?: number
|
||||
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
|
||||
githubConnectionTimeoutMs?: number
|
||||
/** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */
|
||||
idleTimeoutMs?: number
|
||||
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
|
||||
registry?: string
|
||||
/**
|
||||
@@ -176,6 +178,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
lockWaitMs: z.number().step(1).min(0).default(120000),
|
||||
inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
|
||||
githubConnectionTimeoutMs: z.number().step(1).min(1000).default(5000),
|
||||
idleTimeoutMs: z.number().step(1).min(1000).default(600000),
|
||||
registry: z.string().pattern(REGISTRY_URL),
|
||||
fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]),
|
||||
})
|
||||
@@ -188,6 +191,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
private readonly lockWaitMs: number
|
||||
private readonly inspectTimeoutMs: number
|
||||
private readonly githubConnectionTimeoutMs: number
|
||||
private readonly idleTimeoutMs: number
|
||||
private readonly pnpmCommand: string
|
||||
private readonly configuredRegistries: Omit<PluginRegistries, 'resolved'>
|
||||
private readonly ownerContext: Context
|
||||
@@ -205,6 +209,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
this.lockWaitMs = (config as Required<Config>).lockWaitMs
|
||||
this.inspectTimeoutMs = (config as Required<Config>).inspectTimeoutMs
|
||||
this.githubConnectionTimeoutMs = (config as Required<Config>).githubConnectionTimeoutMs
|
||||
this.idleTimeoutMs = (config as Required<Config>).idleTimeoutMs
|
||||
this.pnpmCommand = (config as Required<Config>).pnpmCommand
|
||||
this.configuredRegistries = {
|
||||
registry: config.registry === undefined ? null : normalizeRegistry(config.registry),
|
||||
@@ -470,18 +475,24 @@ export class PluginManager extends TypertRemoteService {
|
||||
run = await this.runPnpm(['add', spec, ...registryArguments(registry)], control.abort.signal, requestId)
|
||||
result.packageResult = run
|
||||
if (stopped()) throw new InstallCancelledError()
|
||||
/* v8 ignore next 2 -- runPnpm classifies every failed run, so kind is never absent here */
|
||||
if (run.exitCode === 0 || run.kind === undefined) break
|
||||
const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec))
|
||||
// A run this manager terminated is not a success, even when pnpm trapped the signal and exited 0.
|
||||
if (run.exitCode === 0 && run.timedOut !== true) break
|
||||
/* v8 ignore next 2 -- runPnpm classifies every run it does not report as succeeded */
|
||||
if (run.kind === undefined) break
|
||||
// What the last failed run could not reach; a later run that succeeds leaves nothing to say.
|
||||
delete result.failedAt
|
||||
// A run this manager terminated got no answer from the registry at all, so no registry explains it.
|
||||
if (run.timedOut === true) break
|
||||
const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec))
|
||||
if (failedAt !== 'other') result.failedAt = failedAt
|
||||
if (failedAt !== 'registry' || index === plan.length - 1) break
|
||||
}
|
||||
/* v8 ignore next -- the plan is never empty, so a run always settled */
|
||||
if (run === undefined) throw new Error('no registry was asked')
|
||||
if (run.exitCode === 0) delete result.failedAt
|
||||
if (run.exitCode !== 0) {
|
||||
// A terminated run reports no usable exit status, so neither its files nor its bundle are trusted.
|
||||
const succeeded = run.exitCode === 0 && run.timedOut !== true
|
||||
if (succeeded) delete result.failedAt
|
||||
if (!succeeded) {
|
||||
// pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval.
|
||||
try { result.pendingBuilds = await readPendingBuilds(this.profile.dir) }
|
||||
catch (error) {
|
||||
@@ -573,7 +584,9 @@ export class PluginManager extends TypertRemoteService {
|
||||
}
|
||||
})
|
||||
result.packageResult = await this.runPnpm(['remove', name])
|
||||
if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output)
|
||||
if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) {
|
||||
throw new Error(result.packageResult.output)
|
||||
}
|
||||
}, { stage: 'remove', target: name }, 'remove')
|
||||
}
|
||||
|
||||
@@ -620,7 +633,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
const task = runProfilePnpm({ ...this.profile, profile: this.profile.name }, args, {
|
||||
execution: 'service', ...this.profile.packageManager ?? { command: this.pnpmCommand },
|
||||
signal: signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]),
|
||||
outputBytes: this.outputBytes, activateNewBundles: false,
|
||||
outputBytes: this.outputBytes, activateNewBundles: false, idleTimeoutMs: this.idleTimeoutMs,
|
||||
onOutput: (text, stream) => {
|
||||
this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream, text })
|
||||
},
|
||||
@@ -631,7 +644,12 @@ export class PluginManager extends TypertRemoteService {
|
||||
this.ownerContext.emit('plugin-manager/install-log', {
|
||||
...identity, jobId, argv, cwd, stream: 'stdout', text: '', exitCode: signal?.aborted === true ? null : result.exitCode,
|
||||
})
|
||||
return result.exitCode === 0 ? result : { ...result, kind: classifyInstallFailure({ log: result.output }) }
|
||||
// A terminated run keeps its own kind even when pnpm trapped the signal and exited 0.
|
||||
if (result.exitCode === 0 && result.timedOut !== true) return result
|
||||
return {
|
||||
...result,
|
||||
kind: classifyInstallFailure({ log: result.output, ...result.timedOut === true ? { timedOut: true } : {} }),
|
||||
}
|
||||
} catch (error) {
|
||||
this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream: 'stderr', text: messageOf(error), exitCode: null })
|
||||
throw error
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Shared profile package operations used by dsh plugin and the running manager. */
|
||||
import { once } from 'node:events'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, open } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
resolveBundleDir, resolveProfileDir, loadOverlayPatches, type ProfileManifest,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import { awaitTreeGone, leadsOwnGroup } from './run-tree.ts'
|
||||
import type { PackageResult, Registry } from './types.ts'
|
||||
|
||||
/** Profile and invocation locations supplied by the launcher. */
|
||||
@@ -36,6 +38,13 @@ export interface PackageOperationOptions {
|
||||
onOutput?: (text: string, stream: 'stdout' | 'stderr') => void
|
||||
activateNewBundles?: boolean
|
||||
lockWaitMs?: number
|
||||
/**
|
||||
* Terminate the run once its captured output has been silent for this long, in
|
||||
* milliseconds. A run stopped this way reports `timedOut`; without a bound, a
|
||||
* child that stops progressing without exiting holds its caller forever. A run
|
||||
* with inherited descriptors captures nothing and is never bound.
|
||||
*/
|
||||
idleTimeoutMs?: number
|
||||
}
|
||||
|
||||
/** Resolve relative package specs against the caller's directory.
|
||||
@@ -96,11 +105,39 @@ async function reconcile(before: ProfileManifest, dir: string, anchor: string, o
|
||||
await saveManifest(dir, after)
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the pipes keep draining after their process exited, as a fixed part of
|
||||
* finishing a run rather than a deployment knob: a descendant that inherited them
|
||||
* holds them open, and the tail a failure classification reads is written by then.
|
||||
*/
|
||||
const DRAIN_AFTER_EXIT_MS = 2_000
|
||||
|
||||
/** Whether every collector finished within `ms`.
|
||||
* @param collectors The pipe readers racing the bound.
|
||||
* @param ms The longest wait, in milliseconds.
|
||||
* @returns True when all collectors settled in time.
|
||||
*/
|
||||
async function drainWithin(collectors: readonly Promise<void>[], ms: number): Promise<boolean> {
|
||||
if (collectors.length === 0) return true
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.allSettled(collectors).then(() => true),
|
||||
new Promise<false>((resolve) => {
|
||||
timer = setTimeout(() => { resolve(false) }, ms)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute pnpm inside a profile whose caller already holds the profile write lock.
|
||||
* @param context Launcher-owned profile and resolution locations.
|
||||
* @param args Pnpm arguments, before relative path anchoring.
|
||||
* @param options Output, activation and cancellation policy.
|
||||
* @returns Exit status and diagnostic path; service output is bounded, CLI output uses inherited descriptors.
|
||||
* @returns Exit status, whether the silence bound stopped the run, and the diagnostic path.
|
||||
* Service output is bounded; CLI output uses inherited descriptors.
|
||||
*/
|
||||
export async function runProfilePnpm(
|
||||
context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions,
|
||||
@@ -115,53 +152,131 @@ export async function runProfilePnpm(
|
||||
let output = Buffer.alloc(0)
|
||||
let truncated = false
|
||||
const cancellation = new AbortController()
|
||||
// A service run captures output, so execa terminates the tree it leads when the
|
||||
// run is killed: a lifecycle script outlives the pnpm process that started it.
|
||||
// The CLI keeps the caller's process group, so an interrupt still reaches it.
|
||||
const grouped = leadsOwnGroup(options.execution)
|
||||
const child = execa(options.command ?? 'pnpm', [...options.args ?? [], ...args.map(arg => anchorPathSpec(arg, context.cwd))], {
|
||||
cwd: dir, env: { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env }, extendEnv: false, reject: false,
|
||||
stdout: options.execution === 'cli' ? 'inherit' : 'pipe',
|
||||
stderr: options.execution === 'cli' ? 'inherit' : 'pipe',
|
||||
killDescendants: options.execution === 'service',
|
||||
buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
|
||||
? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
|
||||
})
|
||||
const append = (bytes: Buffer): void => {
|
||||
output = Buffer.concat([output, bytes])
|
||||
if (output.length > options.outputBytes) {
|
||||
truncated = true
|
||||
output = output.subarray(output.length - options.outputBytes)
|
||||
}
|
||||
}
|
||||
let writes = Promise.resolve()
|
||||
/** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
|
||||
const control = { settled: false, stalled: false }
|
||||
/** Set once this call cuts the reading short itself, so the close it causes is not read as a run failure. */
|
||||
let cut = false
|
||||
/** The first failure a reading hit before that cut, which the run still reports. */
|
||||
let failure: Error | undefined
|
||||
let idleTimer: NodeJS.Timeout | undefined
|
||||
/** The silence bound: a captured run that stops printing without exiting is terminated, never awaited. */
|
||||
const armIdle = (): void => {
|
||||
if (control.settled || options.idleTimeoutMs === undefined) return
|
||||
clearTimeout(idleTimer)
|
||||
idleTimer = setTimeout(() => {
|
||||
control.stalled = true
|
||||
// execa's kill reaches the whole tree of a service run, and escalates on its own.
|
||||
child.kill()
|
||||
}, options.idleTimeoutMs)
|
||||
}
|
||||
const collect = async (stream: AsyncIterable<Buffer | string>, kind: 'stdout' | 'stderr') => {
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
armIdle()
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
writes = writes.then(async () => { await log.write(bytes) })
|
||||
await writes
|
||||
options.onOutput?.(bytes.toString('utf8'), kind)
|
||||
output = Buffer.concat([output, bytes])
|
||||
if (output.length > options.outputBytes) {
|
||||
truncated = true
|
||||
output = output.subarray(output.length - options.outputBytes)
|
||||
}
|
||||
append(bytes)
|
||||
}
|
||||
} catch (error) {
|
||||
cancellation.abort()
|
||||
// A reading this call cut short is not a failure the run hit, and the run has
|
||||
// already exited, so there is nothing left for the cancellation to stop.
|
||||
if (!cut) {
|
||||
failure ??= error instanceof Error ? error : new Error(String(error))
|
||||
cancellation.abort()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const collectors = [
|
||||
...child.stdout === null ? [] : [collect(child.stdout, 'stdout')],
|
||||
...child.stderr === null ? [] : [collect(child.stderr, 'stderr')],
|
||||
]
|
||||
// The drain decides whether a failure surfaces, so each reading is claimed now:
|
||||
// an unclaimed rejection would be reported as unhandled while the pipes drain.
|
||||
for (const collector of collectors) void collector.catch(() => { /* reported through `failure` and the settled readings */ })
|
||||
// Inherited descriptors hand pnpm the terminal, so there is no captured output
|
||||
// for a silence bound to observe: it would fire on a healthy run.
|
||||
if (collectors.length > 0) armIdle()
|
||||
let exitCode: number
|
||||
try {
|
||||
const [completion, ...streams] = await Promise.allSettled([child,
|
||||
...child.stdout === null ? [] : [collect(child.stdout, 'stdout')],
|
||||
...child.stderr === null ? [] : [collect(child.stderr, 'stderr')],
|
||||
])
|
||||
for (const stream of streams) if (stream.status === 'rejected') throw stream.reason
|
||||
// execa resolves its promise only once the piped stdio has ended, so the
|
||||
// process's own exit — the run's completion — is read from the raw child. A
|
||||
// spawn failure settles without one.
|
||||
const settled = Promise.allSettled([child])
|
||||
await Promise.race([once(child.nodeChildProcess, 'exit').catch(() => undefined), settled])
|
||||
control.settled = true
|
||||
clearTimeout(idleTimer)
|
||||
// A stalled run stops its whole tree first, so the caller's rollback and lock
|
||||
// release happen only after the scripts it started stopped writing.
|
||||
if (control.stalled) await awaitTreeGone({ pid: child.pid, grouped })
|
||||
// A descendant that inherited the pipes can hold them open past the process;
|
||||
// the tail drains under a bound instead of being awaited forever.
|
||||
const drained = await drainWithin(collectors, DRAIN_AFTER_EXIT_MS)
|
||||
if (drained) {
|
||||
for (const stream of await Promise.allSettled(collectors)) if (stream.status === 'rejected') throw stream.reason
|
||||
} else {
|
||||
// Cutting the tail short is this call's own end, not a failure the run hit;
|
||||
// a failure from before the cut still surfaces, and the cut leaves a notice
|
||||
// in the log because a classification may read an incomplete tail.
|
||||
cut = true
|
||||
child.stdout?.destroy()
|
||||
child.stderr?.destroy()
|
||||
const notice = 'dsh: pnpm output was cut short after its process exited\n'
|
||||
await log.write(notice)
|
||||
// A failure from before the cut is the run's own and replaces the notice a
|
||||
// caller would otherwise read; a rejection the cut itself causes is its end.
|
||||
if (failure !== undefined) throw failure
|
||||
options.onOutput?.(notice, 'stderr')
|
||||
append(Buffer.from(notice))
|
||||
}
|
||||
const [completion] = await settled
|
||||
if (completion.status === 'rejected') throw completion.reason
|
||||
const result = completion.value
|
||||
exitCode = result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1)
|
||||
if (control.stalled) {
|
||||
const notice = `dsh: pnpm printed nothing for ${String(options.idleTimeoutMs)}ms and was terminated\n`
|
||||
await log.write(notice)
|
||||
options.onOutput?.(notice, 'stderr')
|
||||
append(Buffer.from(notice))
|
||||
}
|
||||
if (result.failed && output.length === 0) {
|
||||
const diagnostic = result.shortMessage ?? 'pnpm failed'
|
||||
await log.write(diagnostic)
|
||||
truncated = Buffer.byteLength(diagnostic) > options.outputBytes
|
||||
output = Buffer.from(diagnostic).subarray(0, options.outputBytes)
|
||||
}
|
||||
if (exitCode === 0 && options.activateNewBundles !== false) await reconcile(before, dir, context.installAnchor, options)
|
||||
// A terminated run's exit status says nothing about what it wrote, so it never reconciles the selection.
|
||||
if (exitCode === 0 && !control.stalled && options.activateNewBundles !== false) {
|
||||
await reconcile(before, dir, context.installAnchor, options)
|
||||
}
|
||||
} finally {
|
||||
control.settled = true
|
||||
clearTimeout(idleTimer)
|
||||
await log.close()
|
||||
}
|
||||
return { exitCode, output: output.toString('utf8'), truncated, logPath }
|
||||
return { exitCode, output: output.toString('utf8'), truncated, logPath, ...control.stalled ? { timedOut: true } : {} }
|
||||
}
|
||||
|
||||
/** Initialize and run the dsh plugin command with the same write lock as the service.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** Waiting for one package run's process tree to disappear before its caller touches the profile. */
|
||||
|
||||
/** How long a terminated tree may take to disappear before the caller stops waiting. */
|
||||
const TREE_WAIT_MS = 5_000
|
||||
|
||||
/** Poll cadence while waiting for a terminated tree to disappear. */
|
||||
const TREE_POLL_MS = 15
|
||||
|
||||
/** The process (or process group) one run occupies. */
|
||||
export interface RunTree {
|
||||
/** The run's process id, when its spawn published one. */
|
||||
pid: number | undefined
|
||||
/** Whether the run leads its own process group, which is what a POSIX group probe addresses. */
|
||||
grouped: boolean
|
||||
}
|
||||
|
||||
/** Injectable process operations, so every platform's probing is testable on any host. */
|
||||
export interface RunTreeInternals {
|
||||
/** Host platform override for target decisions. */
|
||||
platform?: NodeJS.Platform
|
||||
/** Liveness probe override, defaulting to `process.kill(target, 0)`. */
|
||||
alive?: (target: number) => boolean
|
||||
/** Wait bound override, defaulting to the tree wait constant. */
|
||||
waitMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one run leads its own process group, which is the target a POSIX
|
||||
* liveness probe addresses for the whole tree. A run that captures output is
|
||||
* spawned as its own group leader, because its tree is terminated as a unit; a
|
||||
* run that inherits the caller's descriptors keeps the caller's group, so an
|
||||
* interrupt still reaches it.
|
||||
* @param execution Whether the run captures output or inherits the caller's descriptors.
|
||||
* @param platform Host platform deciding how a tree is addressed.
|
||||
* @returns True when the run leads its own process group.
|
||||
*/
|
||||
export function leadsOwnGroup(execution: 'cli' | 'service', platform: NodeJS.Platform = process.platform): boolean {
|
||||
return execution === 'service' && platform !== 'win32'
|
||||
}
|
||||
|
||||
/** The target a tree probe addresses: a POSIX group when the run leads one, else the process itself. */
|
||||
function targetOf(pid: number, grouped: boolean, platform: NodeJS.Platform): number {
|
||||
return platform !== 'win32' && grouped ? -pid : pid
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a terminated run's tree is gone, so the caller restores and
|
||||
* unlocks the profile only after the scripts it started stopped writing.
|
||||
* @param tree The run's process id and whether it leads its own group.
|
||||
* @param internals Injectable process operations.
|
||||
* @returns Fulfillment once no member remains, or the wait bound elapsed.
|
||||
*/
|
||||
export async function awaitTreeGone(tree: RunTree, internals: RunTreeInternals = {}): Promise<void> {
|
||||
const pid = tree.pid
|
||||
if (pid === undefined) return
|
||||
const platform = internals.platform ?? process.platform
|
||||
const alive = internals.alive ?? ((target: number) => { process.kill(target, 0); return true })
|
||||
const remaining = (): boolean => {
|
||||
try {
|
||||
return alive(targetOf(pid, tree.grouped, platform))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS)
|
||||
while (remaining()) {
|
||||
if (Date.now() >= deadline) return
|
||||
await new Promise(resolve => setTimeout(resolve, TREE_POLL_MS))
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,8 @@ export interface PackageResult {
|
||||
logPath: string
|
||||
/** Present when the run failed: what kind of failure its exit and output describe. */
|
||||
kind?: PluginInstallFailureKind
|
||||
/** The manager terminated the run after it printed nothing for its silence bound; `exitCode` still reports how it ended. */
|
||||
timedOut?: boolean
|
||||
}
|
||||
|
||||
/** Persisted change and independently observed application outcome. */
|
||||
|
||||
@@ -582,6 +582,38 @@ it('unloads before removing packages and retries inactive dependencies whose fil
|
||||
expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a terminated run that trapped the signal and exited zero as a failure', async () => {
|
||||
const { manager, dir } = await fixture()
|
||||
const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
|
||||
// pnpm rewrote the manifest before it trapped the manager's signal.
|
||||
const manifest = readProfileManifest('test', dir)
|
||||
manifest.dependencies = { ...manifest.dependencies, half: '1.0.0' }
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
||||
return { exitCode: 0, output: 'installing\n', truncated: false, logPath: join(dir, 'pnpm.log'), timedOut: true }
|
||||
})
|
||||
onTestFinished(() => { install.mockRestore() })
|
||||
const result = await manager.installBundle('half', { enabled: false })
|
||||
expect(result).toMatchObject({ application: 'failed', packageResult: { exitCode: 0, timedOut: true, kind: 'timeout' } })
|
||||
// The half-written dependency goes back, and nothing is activated.
|
||||
expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
|
||||
expect((await manager.listBundles()).some(row => row.name === 'half')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports a terminated removal as failed even when pnpm exited zero', async () => {
|
||||
const { manager, dir } = await fixture()
|
||||
const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
|
||||
// The unload already happened, so the removal is reported through its own run.
|
||||
const manifest = readProfileManifest('test', dir)
|
||||
delete manifest.dependencies?.extra
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
||||
return { exitCode: 0, output: 'removing\n', truncated: false, logPath: join(dir, 'pnpm.log'), timedOut: true }
|
||||
})
|
||||
onTestFinished(() => { remove.mockRestore() })
|
||||
expect(await manager.removeBundle('extra')).toMatchObject({
|
||||
application: 'failed', packageResult: { exitCode: 0, timedOut: true, kind: 'timeout' },
|
||||
})
|
||||
})
|
||||
|
||||
it('restores the manifest and lockfile after a failed package run, classifying the failure', async () => {
|
||||
const { manager, dir } = await fixture()
|
||||
const lockPath = join(dir, 'pnpm-lock.yaml')
|
||||
@@ -1202,6 +1234,46 @@ it('installs from the next registry after one is unreachable, restoring the file
|
||||
expect(chunks.at(-1)).toMatchObject({ requestId, argv: ['pnpm', 'add', 'fallen', `--registry=${MIRROR}`], exitCode: 0 })
|
||||
})
|
||||
|
||||
it('does not ask the next registry after the manager terminated a silent run', async () => {
|
||||
const { manager } = await fixture()
|
||||
const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({
|
||||
exitCode: 1, output: 'installing\ndsh: pnpm printed nothing for 600000ms and was terminated\n',
|
||||
truncated: false, logPath: '/dev/null', timedOut: true,
|
||||
})
|
||||
onTestFinished(() => { install.mockRestore() })
|
||||
const stalled = await manager.installBundle('silent')
|
||||
expect(stalled).toMatchObject({ application: 'failed', registries: [null], packageResult: { kind: 'timeout', timedOut: true } })
|
||||
expect(stalled.failedAt).toBeUndefined()
|
||||
expect(install).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops the earlier registry failure once a later attempt is terminated for silence', async () => {
|
||||
const { manager } = await fixture()
|
||||
const install = vi.spyOn(operations, 'runProfilePnpm')
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 1, output: 'ERR_PNPM_META_FETCH_FAIL GET https://registry.npmjs.org/flaky: ETIMEDOUT',
|
||||
truncated: false, logPath: '/dev/null',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 1, output: 'installing\ndsh: pnpm printed nothing for 600000ms and was terminated\n',
|
||||
truncated: false, logPath: '/dev/null', timedOut: true,
|
||||
})
|
||||
onTestFinished(() => { install.mockRestore() })
|
||||
const stalled = await manager.installBundle('flaky')
|
||||
expect(stalled).toMatchObject({ application: 'failed', registries: [null, MIRROR], packageResult: { kind: 'timeout', timedOut: true } })
|
||||
expect(stalled.failedAt).toBeUndefined()
|
||||
expect(install).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('bounds each package run with the configured silence timeout', async () => {
|
||||
const { manager } = await fixture('live', false, undefined, { idleTimeoutMs: 1234 })
|
||||
const install = vi.spyOn(operations, 'runProfilePnpm')
|
||||
.mockResolvedValue({ exitCode: 1, output: 'plain failure', truncated: false, logPath: '/dev/null' })
|
||||
onTestFinished(() => { install.mockRestore() })
|
||||
await manager.installBundle('quiet')
|
||||
expect(install.mock.calls[0]?.[2]).toMatchObject({ idleTimeoutMs: 1234 })
|
||||
})
|
||||
|
||||
it('stops at a failure no registry changes, at a host the spec itself is fetched from, and after a registry outside the configured set', async () => {
|
||||
const { manager } = await fixture()
|
||||
const failing = (output: string) => ({ exitCode: 1, output, truncated: false, logPath: '/dev/null' })
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/** The bounded drain and the tree stop are measured against real child processes. */
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { initProfile } from '@deepseek-ai/dsh-app-boot'
|
||||
import { runProfilePnpm, type PackageOperationOptions } from '../src/operations.ts'
|
||||
|
||||
/** Holds the inherited pipes until its stop file appears, and ends on its own after a minute. */
|
||||
const HOLDER_SCRIPT = `
|
||||
const { existsSync } = require('node:fs')
|
||||
setInterval(() => { if (existsSync(process.env.DSH_DESCENDANT_STOP_FILE)) process.exit(0) }, 50)
|
||||
setTimeout(() => process.exit(0), 60000)
|
||||
`
|
||||
|
||||
/** Writes a marker after the run is over, which only a stopped tree can prevent. */
|
||||
const LATE_SCRIPT = `
|
||||
setTimeout(() => { require('node:fs').writeFileSync(process.env.DSH_LATE_FILE, 'late') }, 1500)
|
||||
setTimeout(() => process.exit(0), 60000)
|
||||
`
|
||||
|
||||
/**
|
||||
* A child that exits immediately while its descendant inherits both pipes and
|
||||
* outlives it, so execa's promise cannot settle before the bounded drain. It
|
||||
* runs from a file because Windows cannot carry a nested `-e` argument.
|
||||
*/
|
||||
const DRAIN_CHILD = `
|
||||
const { spawn } = require('node:child_process')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const descendant = spawn(process.execPath, [process.env.DSH_DESCENDANT_SCRIPT], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
})
|
||||
writeFileSync(process.env.DSH_DESCENDANT_PID_FILE, String(descendant.pid))
|
||||
console.log('installed')
|
||||
setTimeout(() => process.exit(0), 50)
|
||||
`
|
||||
|
||||
/** A child that starts a descendant and then never exits, so only the silence bound ends the run. */
|
||||
const STALLED_CHILD = `
|
||||
const { spawn } = require('node:child_process')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const descendant = spawn(process.execPath, [process.env.DSH_DESCENDANT_SCRIPT], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
})
|
||||
writeFileSync(process.env.DSH_DESCENDANT_PID_FILE, String(descendant.pid))
|
||||
console.log('installing')
|
||||
setTimeout(() => process.exit(0), 60000)
|
||||
`
|
||||
|
||||
/** Read the descendant's published pid, waiting for the write a loaded runner can delay. */
|
||||
async function readPid(pidFile: string): Promise<number> {
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
if (existsSync(pidFile)) {
|
||||
const pid = Number(readFileSync(pidFile, 'utf8'))
|
||||
if (pid > 0) return pid
|
||||
}
|
||||
await sleep(25)
|
||||
}
|
||||
throw new Error('the descendant published no process id')
|
||||
}
|
||||
|
||||
/** Wait until a descendant this spec killed is really gone, so its directory can be removed. */
|
||||
async function awaitGone(pid: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
try { process.kill(pid, 0) } catch { return /* the descendant is gone */ }
|
||||
await sleep(25)
|
||||
}
|
||||
}
|
||||
|
||||
/** A profile whose package run is `child`, with its descendant stopped during teardown. */
|
||||
function fixture(child: string, descendant: string) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'manager-run-'))
|
||||
const dir = join(home, 'profiles', 'test')
|
||||
const installAnchor = join(home, 'package.json')
|
||||
writeFileSync(installAnchor, '{}\n')
|
||||
initProfile(dir, [])
|
||||
const pidFile = join(home, 'descendant.pid')
|
||||
const stopFile = join(home, 'descendant.stop')
|
||||
const scriptFile = join(home, 'descendant.cjs')
|
||||
const lateFile = join(home, 'late.marker')
|
||||
writeFileSync(scriptFile, descendant)
|
||||
onTestFinished(async () => {
|
||||
// The stop file ends a surviving descendant on its own; the kill only shortens the wait.
|
||||
writeFileSync(stopFile, '')
|
||||
if (existsSync(pidFile)) {
|
||||
const pid = Number(readFileSync(pidFile, 'utf8'))
|
||||
try { process.kill(pid, 'SIGKILL') } catch { /* the descendant exited with its own timer */ }
|
||||
await awaitGone(pid)
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
const options: PackageOperationOptions = {
|
||||
command: process.execPath, args: ['-e', child], execution: 'service', outputBytes: 1000,
|
||||
activateNewBundles: false,
|
||||
env: {
|
||||
DSH_DESCENDANT_PID_FILE: pidFile, DSH_DESCENDANT_STOP_FILE: stopFile,
|
||||
DSH_DESCENDANT_SCRIPT: scriptFile, DSH_LATE_FILE: lateFile,
|
||||
},
|
||||
}
|
||||
return { context: { home, profile: 'test', installAnchor, cwd: home }, pidFile, lateFile, options }
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows cannot stage these fixtures: there a descendant neither keeps the
|
||||
* inherited pipes open past the direct child's exit nor leads a group the run
|
||||
* can stop, so the drain's premise holds on POSIX alone. The drain itself is
|
||||
* platform-independent, and the mocked-child cases in operations.spec.ts cover
|
||||
* it everywhere.
|
||||
*/
|
||||
describe.skipIf(process.platform === 'win32')('a run with descendants', () => {
|
||||
it('settles a run whose pipes a descendant keeps open', async () => {
|
||||
const { context, pidFile, options } = fixture(DRAIN_CHILD, HOLDER_SCRIPT)
|
||||
const outcome = await runProfilePnpm(context, ['add', 'held'], options)
|
||||
const descendant = await readPid(pidFile)
|
||||
expect(outcome).toMatchObject({ exitCode: 0 })
|
||||
expect(outcome.output).toContain('dsh: pnpm output was cut short after its process exited')
|
||||
// The descendant still holds the pipes, so the run settled under its drain bound instead of waiting for their end.
|
||||
expect(() => process.kill(descendant, 0)).not.toThrow()
|
||||
})
|
||||
|
||||
it('surfaces an output consumer failure that the bounded drain cuts short', async () => {
|
||||
const { context, options } = fixture(DRAIN_CHILD, HOLDER_SCRIPT)
|
||||
const run = runProfilePnpm(context, ['add', 'held'], {
|
||||
...options,
|
||||
onOutput() { throw new Error('output destination closed') },
|
||||
})
|
||||
await expect(run).rejects.toThrow('output destination closed')
|
||||
})
|
||||
|
||||
it('stops the whole tree of a stalled run before returning', async () => {
|
||||
const { context, pidFile, lateFile, options } = fixture(STALLED_CHILD, LATE_SCRIPT)
|
||||
const outcome = await runProfilePnpm(context, ['add', 'stalled'], { ...options, idleTimeoutMs: 300 })
|
||||
const descendant = await readPid(pidFile)
|
||||
expect(outcome).toMatchObject({ timedOut: true })
|
||||
// The descendant is gone, so the marker it would have written 1.5s in never appears.
|
||||
expect(() => process.kill(descendant, 0)).toThrow()
|
||||
await sleep(2_000)
|
||||
expect(existsSync(lateFile)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
/** The CLI and manager share package reconciliation, path anchoring and diagnostics. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -7,7 +8,23 @@ import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { initProfile, readProfileManifest } from '@deepseek-ai/dsh-app-boot'
|
||||
import { anchorPathSpec, readProfileRegistry, runPluginCommand, runProfilePnpm, viewProfilePackage } from '../src/operations.ts'
|
||||
|
||||
const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => ReturnType<typeof result>>() }))
|
||||
/** What execa resolves for a run that settled. */
|
||||
interface FakeOutcome {
|
||||
exitCode: number | undefined
|
||||
failed: boolean
|
||||
code?: string
|
||||
shortMessage?: string
|
||||
}
|
||||
|
||||
/** The child execa hands back, including the descriptors an inherited CLI run reports as null. */
|
||||
interface FakeChild {
|
||||
stdout: PassThrough | null
|
||||
stderr: PassThrough | null
|
||||
nodeChildProcess: EventEmitter
|
||||
kill?: () => boolean
|
||||
}
|
||||
|
||||
const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => Promise<FakeOutcome> & FakeChild>() }))
|
||||
vi.mock('execa', () => ({ execa: (...args: unknown[]) => command.run(...args) }))
|
||||
|
||||
function fixture() {
|
||||
@@ -26,13 +43,68 @@ function result(
|
||||
) {
|
||||
const stdout = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
const raw = new EventEmitter()
|
||||
const done = Promise.resolve().then(() => {
|
||||
mutate()
|
||||
stdout.end(output)
|
||||
stderr.end()
|
||||
return { exitCode, failed: exitCode !== 0, ...details }
|
||||
})
|
||||
return Object.assign(done, { stdout, stderr })
|
||||
// A launch failure reports on the raw child, which never emits an exit; anything else exits once execa's promise settles.
|
||||
// Both arrive on a macrotask, so a fixture built before the run still reaches the listener the run attaches.
|
||||
if (details.code === undefined) {
|
||||
void done.then(() => { setTimeout(() => { raw.emit('exit', exitCode, null) }, 0) }, () => {})
|
||||
} else {
|
||||
setTimeout(() => { raw.emit('error', Object.assign(new Error('pnpm failed to launch'), { code: details.code })) }, 0)
|
||||
}
|
||||
return Object.assign(done, { stdout, stderr, nodeChildProcess: raw })
|
||||
}
|
||||
|
||||
/** A child that printed once and never exits on its own: only the silence bound can end it. */
|
||||
function silentChild(output: string, exitCode?: number) {
|
||||
const stdout = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
const raw = new EventEmitter()
|
||||
const exit = Promise.withResolvers<{ exitCode: number | undefined; failed: boolean }>()
|
||||
const kill = () => {
|
||||
stdout.end()
|
||||
stderr.end()
|
||||
raw.emit('exit', exitCode ?? null, 'SIGTERM')
|
||||
exit.resolve({ exitCode, failed: exitCode !== 0 })
|
||||
return true
|
||||
}
|
||||
stdout.write(output)
|
||||
return Object.assign(exit.promise, { stdout, stderr, kill, nodeChildProcess: raw })
|
||||
}
|
||||
|
||||
/** A child that exited while its pipes stay open, as a descendant that inherited them leaves them. */
|
||||
function heldPipeChild(output: string) {
|
||||
const stdout = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
const raw = new EventEmitter()
|
||||
const exit = Promise.withResolvers<{ exitCode: number; failed: boolean }>()
|
||||
let ended = 0
|
||||
// execa settles only once both pipes end, which the bounded drain forces by destroying them.
|
||||
for (const stream of [stdout, stderr]) {
|
||||
stream.on('close', () => {
|
||||
ended += 1
|
||||
if (ended === 2) exit.resolve({ exitCode: 0, failed: false })
|
||||
})
|
||||
}
|
||||
// The process exits as soon as the run watches the raw child, so a fixture the
|
||||
// test built before the run cannot emit past the listener the run attaches.
|
||||
let watched = false
|
||||
const watch = (): void => {
|
||||
if (watched) return
|
||||
watched = true
|
||||
setTimeout(() => { raw.emit('exit', 0, null) }, 0)
|
||||
}
|
||||
const on = raw.on.bind(raw)
|
||||
const once = raw.once.bind(raw)
|
||||
raw.on = (event, listener) => { const holder = on(event, listener); if (event === 'exit') watch(); return holder }
|
||||
raw.once = (event, listener) => { const holder = once(event, listener); if (event === 'exit') watch(); return holder }
|
||||
stdout.write(output)
|
||||
return Object.assign(exit.promise, { stdout, stderr, nodeChildProcess: raw })
|
||||
}
|
||||
|
||||
function install(dir: string, name: string) {
|
||||
@@ -107,6 +179,72 @@ it('initializes missing profiles under the same lock and reports initialization'
|
||||
expect(messages.filter(text => text.includes('initialized profile'))).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('terminates a run that stopped printing and reports the silence bound', async () => {
|
||||
const { context } = fixture()
|
||||
command.run.mockImplementationOnce(() => silentChild('installing\n'))
|
||||
const outcome = await runProfilePnpm(context, ['add', 'silent'], { execution: 'service', outputBytes: 100, idleTimeoutMs: 20 })
|
||||
expect(outcome).toMatchObject({ exitCode: 1, timedOut: true })
|
||||
expect(outcome.output).toBe('installing\ndsh: pnpm printed nothing for 20ms and was terminated\n')
|
||||
expect(readFileSync(outcome.logPath, 'utf8')).toBe(outcome.output)
|
||||
})
|
||||
|
||||
it('settles a run whose pipes stay open past the process exit', async () => {
|
||||
const { context } = fixture()
|
||||
const child = heldPipeChild('installed\n')
|
||||
command.run.mockImplementationOnce(() => child)
|
||||
const outcome = await runProfilePnpm(context, ['add', 'held'], {
|
||||
execution: 'service', outputBytes: 100, activateNewBundles: false,
|
||||
})
|
||||
expect(outcome).toMatchObject({ exitCode: 0 })
|
||||
expect(outcome.output).toBe('installed\ndsh: pnpm output was cut short after its process exited\n')
|
||||
// The pipes never ended on their own, so the run settled by cutting them here.
|
||||
expect(child.stdout.destroyed).toBe(true)
|
||||
expect(child.stderr.destroyed).toBe(true)
|
||||
expect(outcome.timedOut).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a terminated run that trapped the signal and exited zero', async () => {
|
||||
const { context } = fixture()
|
||||
command.run.mockImplementationOnce(() => silentChild('installing\n', 0))
|
||||
const outcome = await runProfilePnpm(context, ['add', 'trapped'], { execution: 'service', outputBytes: 100, idleTimeoutMs: 20 })
|
||||
// The independent facts stay separate: the signal was trapped, so the exit status is still zero.
|
||||
expect(outcome).toMatchObject({ exitCode: 0, timedOut: true })
|
||||
expect(outcome.output).toContain('printed nothing for 20ms and was terminated')
|
||||
})
|
||||
|
||||
it('surfaces an output consumer failure the bounded drain cuts short', async () => {
|
||||
const { context } = fixture()
|
||||
command.run.mockImplementationOnce(() => heldPipeChild('installed\n'))
|
||||
await expect(runProfilePnpm(context, ['add', 'held'], {
|
||||
execution: 'service', outputBytes: 100, activateNewBundles: false,
|
||||
onOutput() { throw new Error('output destination closed') },
|
||||
})).rejects.toThrow('output destination closed')
|
||||
})
|
||||
|
||||
it('reports a reading that fails with something other than an Error by its text', async () => {
|
||||
const { context } = fixture()
|
||||
const child = heldPipeChild('installed\n')
|
||||
command.run.mockImplementationOnce(() => child)
|
||||
await expect(runProfilePnpm(context, ['add', 'held'], {
|
||||
execution: 'service', outputBytes: 100, activateNewBundles: false,
|
||||
// A reading can reject with any value; its text is what the failure reports.
|
||||
onOutput() { throw 'pipe broke' },
|
||||
})).rejects.toThrow('pipe broke')
|
||||
})
|
||||
|
||||
it('terminates a service run as a tree and leaves the CLI in the caller group', async () => {
|
||||
const { context } = fixture()
|
||||
command.run.mockImplementation(() => result(0, ''))
|
||||
await runProfilePnpm(context, ['add', 'tree'], { execution: 'service', outputBytes: 100, activateNewBundles: false })
|
||||
await runProfilePnpm(context, ['add', 'tree'], { execution: 'cli', outputBytes: 100, activateNewBundles: false })
|
||||
expect(command.run).toHaveBeenCalledWith(
|
||||
expect.anything(), expect.anything(), expect.objectContaining({ killDescendants: true }),
|
||||
)
|
||||
expect(command.run).toHaveBeenLastCalledWith(
|
||||
expect.anything(), expect.anything(), expect.objectContaining({ killDescendants: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('retains built-in layers, removes deleted dependencies and warns about plain packages', async () => {
|
||||
const { context, dir } = fixture()
|
||||
install(dir, 'removed')
|
||||
@@ -172,7 +310,9 @@ it('preserves an unexpected subprocess rejection after both streams settle', asy
|
||||
const stderr = new PassThrough()
|
||||
stdout.end()
|
||||
stderr.end()
|
||||
command.run.mockImplementationOnce(() => Object.assign(Promise.reject(new Error('subprocess failed')), { stdout, stderr }))
|
||||
command.run.mockImplementationOnce(() => Object.assign(Promise.reject(new Error('subprocess failed')), {
|
||||
stdout, stderr, nodeChildProcess: new EventEmitter(),
|
||||
}))
|
||||
await expect(runProfilePnpm(context, ['root'], { execution: 'service', outputBytes: 100 })).rejects.toThrow('subprocess failed')
|
||||
})
|
||||
|
||||
@@ -207,8 +347,8 @@ it.each(['cli', 'service'] as const)('uses the %s environment and interaction po
|
||||
it('settles inherited CLI descriptors without requiring captured streams', async () => {
|
||||
const { context } = fixture()
|
||||
command.run.mockImplementationOnce(() => Object.assign(
|
||||
Promise.resolve({ exitCode: 0, failed: false }), { stdout: null, stderr: null },
|
||||
) as unknown as ReturnType<typeof result>)
|
||||
Promise.resolve({ exitCode: 0, failed: false }), { stdout: null, stderr: null, nodeChildProcess: new EventEmitter() },
|
||||
))
|
||||
expect(await runPluginCommand(context, ['approve-builds'], { execution: 'cli', outputBytes: 100 })).toMatchObject({ exitCode: 0, output: '' })
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Waiting for one run's tree is decided by platform and target, without starting a real process. */
|
||||
import { expect, it } from 'vitest'
|
||||
import { awaitTreeGone, leadsOwnGroup, type RunTree } from '../src/run-tree.ts'
|
||||
|
||||
/** One run's process or process group. */
|
||||
function tree(pid: number | undefined, grouped = true): RunTree {
|
||||
return { pid, grouped }
|
||||
}
|
||||
|
||||
it.each([
|
||||
['service', 'linux', true],
|
||||
['service', 'darwin', true],
|
||||
['service', 'win32', false],
|
||||
['cli', 'linux', false],
|
||||
['cli', 'win32', false],
|
||||
] as const)('decides group leadership for %s on %s', (execution, platform, expected) => {
|
||||
expect(leadsOwnGroup(execution, platform)).toBe(expected)
|
||||
})
|
||||
|
||||
it('waits for a tree that disappears on its own', async () => {
|
||||
let alive = true
|
||||
await expect(awaitTreeGone(tree(42), {
|
||||
platform: 'linux',
|
||||
alive: () => { const first = alive; alive = false; return first },
|
||||
})).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('probes the group a run leads and the process it does not', async () => {
|
||||
const probes: number[] = []
|
||||
const gone = { alive: (target: number) => { probes.push(target); return false } }
|
||||
await expect(awaitTreeGone(tree(42), { ...gone, platform: 'linux' })).resolves.toBeUndefined()
|
||||
await expect(awaitTreeGone(tree(42, false), { ...gone, platform: 'linux' })).resolves.toBeUndefined()
|
||||
await expect(awaitTreeGone(tree(42), { ...gone, platform: 'win32' })).resolves.toBeUndefined()
|
||||
expect(probes).toEqual([-42, 42, 42])
|
||||
})
|
||||
|
||||
it('returns at once for a tree with no process id or one already gone', async () => {
|
||||
await expect(awaitTreeGone(tree(undefined), { alive: () => { throw new Error('never probed') } })).resolves.toBeUndefined()
|
||||
// The host platform answers for a run that names none, and the default probe reports a vanished process.
|
||||
await expect(awaitTreeGone(tree(99_999_999, false))).resolves.toBeUndefined()
|
||||
await expect(awaitTreeGone(tree(99_999_999, false), { platform: 'linux' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('gives up on a tree that never disappears', async () => {
|
||||
// The wait bound is an input, so this needs no global clock: fake timers leak into the suites sharing the worker.
|
||||
await expect(awaitTreeGone(tree(42), { platform: 'linux', alive: () => true, waitMs: 0 })).resolves.toBeUndefined()
|
||||
})
|
||||
@@ -38,3 +38,4 @@
|
||||
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')"
|
||||
node: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')"
|
||||
@@ -192,6 +192,6 @@
|
||||
"@deepseek-ai/dsh-shell-env": "workspace:*",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:*",
|
||||
"@deepseek-ai/libreoffice-kit": "0.0.1"
|
||||
"@deepseek-ai/libreoffice-kit": "^0.1.0"
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,12 @@ it('loads the shipped Office rows with separately patched settings and authorize
|
||||
await writeFile(outputPath, pdf)
|
||||
return { backend: 'native', missingFonts: ['Missing Serif'] }
|
||||
})
|
||||
kit.create.mockReset().mockResolvedValue({ backend: 'native', render, dispose: async () => {} })
|
||||
kit.create.mockReset().mockResolvedValue({
|
||||
backend: 'native', render, dispose: async () => {},
|
||||
renderImages: vi.fn<Converter['renderImages']>().mockRejectedValue(new Error('Unexpected Converter.renderImages call')),
|
||||
convert: vi.fn<Converter['convert']>().mockRejectedValue(new Error('Unexpected Converter.convert call')),
|
||||
recalculate: vi.fn<Converter['recalculate']>().mockRejectedValue(new Error('Unexpected Converter.recalculate call')),
|
||||
})
|
||||
ctx.baseUrl = pathToFileURL(directory).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
|
||||
@@ -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-agent-preset/README.md
|
||||
README.md: 176957e32695debf6a8aedd98368ec0d985d9182
|
||||
README.zh.md: a9496780ce5ea48ae95420d11d9f182134518b93
|
||||
README.md: f529c62f16873ce7b10fce70907f56630b133bdb
|
||||
README.zh.md: 281a667980c66ca90c4180b5ae6bd176d34568f4
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Choose Agent presets and the new-task default in Web, and read what each mode does. Authoring is guided to Creator mode."
|
||||
description: "Choose Agent presets and the new-task default in Web, read what each mode does and what it declares. Authoring is guided to Creator mode."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Choose Agent presets and the new-task default in Web, and read what each mode does. Authoring is guided to Creator mode.
|
||||
Choose Agent presets and the new-task default in Web, read what each mode does and what it declares. Authoring is guided to Creator mode.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,7 +25,7 @@ Choose Agent presets and the new-task default in Web, and read what each mode do
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. The page edits nothing: that entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists.
|
||||
Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists.
|
||||
|
||||
The “Choose a mode for new tasks” switch controls whether the saved user default is active. Hiding selection uses the deployment default; showing it restores the user preference. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset. The new-session picker additionally requires Developer tools in General Settings.
|
||||
|
||||
@@ -37,7 +37,7 @@ Known shipped presets offer mode details and usage examples in a read-only dialo
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
`agentPresets/list` supplies the roster and the chooser policy; default and visibility changes write the `agent-presets` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster.
|
||||
`agentPresets/list` supplies the roster and the chooser policy, and `agentPresets/read` one declaration's YAML for the viewer; default and visibility changes write the `agent-presets` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -61,7 +61,7 @@ Selection changes affect only later tasks; existing plugins and prompts remain u
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Web creates and edits no preset: a bundle installed through Creator mode declares a new preset or overrides a shipped one by row id, replacing its complete child list.
|
||||
- Web creates and edits no preset: the configuration viewer is read-only, and a bundle installed through Creator mode declares a new preset or overrides a shipped one by row id, replacing its complete child list.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "在 Web 中选择 Agent preset 和新任务默认值,并查看各模式的说明。创建与修改引导至创造模式。"
|
||||
description: "在 Web 中选择 Agent preset 和新任务默认值,查看各模式的说明与声明的配置。创建与修改引导至创造模式。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
在 Web 中选择 Agent preset 和新任务默认值,并查看各模式的说明。创建与修改引导至创造模式。
|
||||
在 Web 中选择 Agent preset 和新任务默认值,查看各模式的说明与声明的配置。创建与修改引导至创造模式。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用此包
|
||||
|
||||
设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。本页不编辑任何内容:该入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。
|
||||
设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。
|
||||
|
||||
「新任务可选择模式」开关控制保存的用户默认值是否生效。关闭时使用部署默认值,重新开启则恢复用户偏好。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。新会话选择器还要求通用设置开启开发者工具。
|
||||
|
||||
@@ -37,7 +37,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节 — 点击展开</summary>
|
||||
|
||||
`agentPresets/list` 提供列表和选择器策略;默认值与可见性的修改写入 `agent-presets` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。
|
||||
`agentPresets/list` 提供列表和选择器策略,`agentPresets/read` 为查看器提供一条声明的 YAML;默认值与可见性的修改写入 `agent-presets` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -61,7 +61,7 @@ kind: "package-reference"
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Web 不创建也不编辑 preset:通过创造模式安装的 bundle 声明新 preset,或按行 id 覆盖内置 preset,覆盖会替换整个子插件列表。
|
||||
- Web 不创建也不编辑 preset:配置查看器是只读的;通过创造模式安装的 bundle 声明新 preset,或按行 id 覆盖内置 preset,覆盖会替换整个子插件列表。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -317,6 +317,77 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Icon-only actions: the label rides `data-tip` so the row stays quiet until
|
||||
someone reaches for it. */
|
||||
.iconButton {
|
||||
position: relative;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
padding: 6px;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.iconButton:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.iconButton::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .12s;
|
||||
}
|
||||
|
||||
.iconButton:hover::after,
|
||||
.iconButton:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(720px, 100%);
|
||||
}
|
||||
|
||||
/* A declared composition can be long; the dialog scrolls it rather than grow. */
|
||||
.viewerCode {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
max-height: min(60vh, 560px);
|
||||
overflow: auto;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
tab-size: 2;
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/** Preset selection settings: the roster, its default, mode help and the Creator-mode entry. */
|
||||
/** Preset selection settings: the roster, its default, mode help, a read-only view of each composition, and the Creator-mode entry. */
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Button, IconPlusOutlineRegular, Switch, Tag, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
Button, IconBrowseOutlineRegular, IconPlusOutlineRegular, Modal, Switch, Tag, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { AgentPresetSectionState } from './section-store.ts'
|
||||
import { isBuiltInPreset, presetDisplayText } from './locales.ts'
|
||||
import { PresetGuideDialog, presetGuide, type PresetGuidePage } from './PresetGuideDialog.tsx'
|
||||
import { PresetGuideDialog, presetGuide, trapPresetReaderTab, type PresetGuidePage } from './PresetGuideDialog.tsx'
|
||||
import css from './AgentPresetSection.module.css'
|
||||
|
||||
/** Settings actions and their shared controller state. */
|
||||
@@ -19,6 +21,10 @@ export interface AgentPresetSectionInjected {
|
||||
/** Stage the `cordis` preset and start a Creator-mode task; absent without a conversation flow. */
|
||||
startCreatorDraft?: () => void
|
||||
load: () => Promise<void>
|
||||
/** Open one preset's declared composition in the read-only viewer. */
|
||||
view: (id: string) => Promise<void>
|
||||
/** Close the read-only viewer. */
|
||||
closeView: () => void
|
||||
makeDefault: (id: string) => Promise<void>
|
||||
setPickerVisible: (visible: boolean) => Promise<void>
|
||||
}
|
||||
@@ -51,12 +57,13 @@ function CardDescription({ text }: { text: string }): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
/** Render the roster with its default, mode help, and the guidance to Creator mode.
|
||||
/** Render the roster with its default, mode help, composition viewer, and the guidance to Creator mode.
|
||||
* @param props Settings actions, snapshot hooks and localized text.
|
||||
* @returns The preset settings section.
|
||||
*/
|
||||
export function AgentPresetSection({
|
||||
useAgentPresetSection, load, makeDefault, setPickerVisible, startCreatorDraft, close: closeSettings, useDeveloperTools, t,
|
||||
useAgentPresetSection, load, view, closeView, makeDefault, setPickerVisible, startCreatorDraft,
|
||||
close: closeSettings, useDeveloperTools, t,
|
||||
}: AgentPresetSectionProps) {
|
||||
const state = useAgentPresetSection(value => value)
|
||||
const developerTools = useDeveloperTools(enabled => enabled)
|
||||
@@ -64,7 +71,15 @@ export function AgentPresetSection({
|
||||
content: NonNullable<ReturnType<typeof presetGuide>>
|
||||
page: PresetGuidePage
|
||||
} | null>(null)
|
||||
const viewTrigger = useRef<HTMLButtonElement | null>(null)
|
||||
const closeViewOnUnmount = useRef(closeView)
|
||||
useEffect(() => { void load() }, [load])
|
||||
useLayoutEffect(() => { closeViewOnUnmount.current = closeView }, [closeView])
|
||||
useEffect(() => () => { closeViewOnUnmount.current() }, [])
|
||||
const closeViewer = () => { closeView(); viewTrigger.current?.focus() }
|
||||
const viewed = state.view
|
||||
const viewedRow = viewed === null ? undefined : state.rows.find(row => row.id === viewed.id)
|
||||
const viewedTitle = viewed === null ? '' : viewedRow === undefined ? viewed.title : presetDisplayText(viewedRow, t).name
|
||||
// Creator mode authors presets in conversation; it needs the flow to land a
|
||||
// session in and the self-referential preset on the roster.
|
||||
const creator = startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') ? startCreatorDraft : undefined
|
||||
@@ -138,14 +153,21 @@ export function AgentPresetSection({
|
||||
<CardDescription text={display.description ?? t('noDescription')} />
|
||||
{row.broken === undefined ? null : <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
</button>
|
||||
{help === undefined ? null : <div className={css.cardFoot}>
|
||||
<div className={css.cardHelp}>
|
||||
<div className={css.cardFoot}>
|
||||
{help === undefined ? null : <div className={css.cardHelp}>
|
||||
<Button variant="ghost" className={css.helpButton} aria-label={`${t('modeExplanation')}: ${display.name}`}
|
||||
onClick={() => { setGuide({ content: help, page: 'explanation' }) }}>{t('modeExplanation')}</Button>
|
||||
<Button variant="ghost" className={css.helpButton} aria-label={`${t('howToUse')}: ${display.name}`}
|
||||
onClick={() => { setGuide({ content: help, page: 'usage' }) }}>{t('howToUse')}</Button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>}
|
||||
{/* Reading the declaration is the one thing this page offers
|
||||
beyond choosing: a broken preset's YAML is also where its
|
||||
diagnostic points, so the viewer stays available for it. */}
|
||||
<button type="button" className={css.iconButton} data-tip={t('view')} aria-label={`${t('view')}: ${display.name}`}
|
||||
onClick={(event) => { viewTrigger.current = event.currentTarget; void view(row.id) }}>
|
||||
<IconBrowseOutlineRegular />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
})}
|
||||
</ul>}
|
||||
@@ -153,5 +175,17 @@ export function AgentPresetSection({
|
||||
</section>
|
||||
})}
|
||||
{guide === null ? null : <PresetGuideDialog guide={guide.content} initialPage={guide.page} t={t} onClose={() => { setGuide(null) }} />}
|
||||
<Modal open={viewed !== null} onClose={closeViewer} closeLabel={t('close')}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeViewer()
|
||||
} else trapPresetReaderTab(event)
|
||||
}}
|
||||
title={viewed === null ? '' : `${t('view')} · ${viewedTitle}`} className={css.dialog as string}
|
||||
footer={<Button variant="outline" autoFocus onClick={closeViewer}>{t('close')}</Button>}>
|
||||
{viewed === null ? null : <pre className={css.viewerCode}>{viewed.content}</pre>}
|
||||
</Modal>
|
||||
</section>
|
||||
}
|
||||
@@ -33,6 +33,25 @@ export function presetGuide(id: string, trust: string): PresetGuide | undefined
|
||||
return trust === 'system' ? guides.get(id) : undefined
|
||||
}
|
||||
|
||||
/** Keep keyboard focus inside a preset reader while Tab moves through its controls.
|
||||
* @param event Keyboard event from the active reader.
|
||||
*/
|
||||
export function trapPresetReaderTab(event: KeyboardEvent<HTMLDivElement>): void {
|
||||
if (event.key !== 'Tab') return
|
||||
const targets = Array.from(event.currentTarget.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]',
|
||||
)).filter(element => !element.closest('[hidden]'))
|
||||
const first = targets[0]
|
||||
const last = targets[targets.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last?.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
/** Curated usage dictionaries contain only level-three example sections. */
|
||||
function GuideUsage({ text, t }: {
|
||||
text: string
|
||||
@@ -90,20 +109,7 @@ export function PresetGuideDialog({ guide, initialPage, t, onClose }: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
} else if (event.key === 'Tab') {
|
||||
const targets = Array.from(event.currentTarget.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]',
|
||||
)).filter(element => !element.closest('[hidden]'))
|
||||
const first = targets[0]
|
||||
const last = targets[targets.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last?.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first?.focus()
|
||||
}
|
||||
}
|
||||
} else trapPresetReaderTab(event)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Agent-preset surface plugin, browser half — three surfaces over one roster:
|
||||
* a chip on the new-session screen for the session about to start, a
|
||||
* read-only label in the session header, and a settings section that lists
|
||||
* the roster (selection, the new-task default, and the way into Creator mode).
|
||||
* the roster (selection, the new-task default, a read-only view of each
|
||||
* declared composition, and the way into Creator mode).
|
||||
*
|
||||
* A running session keeps the composition it began with (the host refuses to
|
||||
* adopt an existing session under a different preset). That is what splits
|
||||
@@ -49,7 +50,7 @@ export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPre
|
||||
export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
|
||||
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
|
||||
export type { AgentPresetSeatState } from './seat-store.ts'
|
||||
export type { AgentPresetSectionState } from './section-store.ts'
|
||||
export type { AgentPresetSectionState, PresetView } from './section-store.ts'
|
||||
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
|
||||
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
|
||||
|
||||
@@ -199,6 +200,8 @@ export function apply(ctx: ClientContext): void {
|
||||
const sectionInjected = (): AgentPresetSectionInjected => ({
|
||||
hooks: { agentPresetSection: section.store, developerTools: ctx.configForms.developerTools.enabled },
|
||||
load: () => section.load(),
|
||||
view: (id: string) => section.view(id),
|
||||
closeView: () => { section.closeView() },
|
||||
...creatorDraft === undefined ? {} : { startCreatorDraft: creatorDraft },
|
||||
makeDefault: (id: string) => section.makeDefault(id, captureBlankSessionSync()),
|
||||
setPickerVisible: (showPicker: boolean) => section.setPickerVisible(showPicker, captureBlankSessionSync()),
|
||||
|
||||
@@ -12,6 +12,7 @@ export type AgentPresetSettingsKey =
|
||||
| 'nav'
|
||||
| 'sectionIntro'
|
||||
| 'setDefault'
|
||||
| 'view'
|
||||
| 'presetStandardName'
|
||||
| 'presetStandardDescription'
|
||||
| 'presetPtcName'
|
||||
@@ -44,6 +45,7 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
nav: 'Agent presets',
|
||||
|
||||
setDefault: 'Set as new task default',
|
||||
view: 'View configuration',
|
||||
|
||||
presetStandardName: 'Standard mode',
|
||||
presetStandardDescription:
|
||||
@@ -89,6 +91,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
nav: 'Agent 预设',
|
||||
|
||||
setDefault: '设为新任务默认',
|
||||
view: '查看配置',
|
||||
|
||||
presetStandardName: '标准模式',
|
||||
presetStandardDescription: '处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。',
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
/** Preset roster and selection policy for the settings section. */
|
||||
/** Preset roster, selection policy and the read-only composition viewer for the settings section. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { AgentPresetRow } from '@deepseek-ai/dsh-agent-preset-registry/types'
|
||||
import { writeDefaultPreset, writeModeSelectionEnabled } from './settings-store.ts'
|
||||
|
||||
/** The read-only composition viewer over one preset. */
|
||||
export interface PresetView {
|
||||
/** The preset being read. */
|
||||
id: string
|
||||
/** Display name the preset published, or its id. */
|
||||
title: string
|
||||
/** The declared child plugin list as YAML. */
|
||||
content: string
|
||||
}
|
||||
/** Settings page state. */
|
||||
export interface AgentPresetSectionState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'error'
|
||||
@@ -12,15 +21,18 @@ export interface AgentPresetSectionState {
|
||||
showPicker: boolean
|
||||
policySaving: boolean
|
||||
rows: readonly AgentPresetRow[]
|
||||
/** The open viewer, or null. */
|
||||
view: PresetView | null
|
||||
}
|
||||
const INITIAL: AgentPresetSectionState = { status: 'idle', error: null, showPicker: true, policySaving: false, rows: [] }
|
||||
const INITIAL: AgentPresetSectionState = { status: 'idle', error: null, showPicker: true, policySaving: false, rows: [], view: null }
|
||||
const message = (error: unknown): string => error instanceof Error ? error.message : String(error)
|
||||
|
||||
/** Loads the roster and writes the default and chooser policy. */
|
||||
/** Loads the roster, writes the default and chooser policy, and reads one composition at a time. */
|
||||
export class AgentPresetSectionController {
|
||||
/** Observable roster and selection state. */
|
||||
/** Observable roster, selection and viewer state. */
|
||||
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
|
||||
private loading: Promise<void> | undefined
|
||||
private viewRequest = 0
|
||||
constructor(private readonly ctx: Context) {}
|
||||
|
||||
private set(patch: Partial<AgentPresetSectionState>): void { this.store.set({ ...this.store.getSnapshot(), ...patch }) }
|
||||
@@ -39,6 +51,24 @@ export class AgentPresetSectionController {
|
||||
} catch (error) { this.set({ status: 'error', error: message(error) }) }
|
||||
}
|
||||
|
||||
/** Open one preset's declared composition in the viewer.
|
||||
* @param id Preset to read.
|
||||
* @returns Once the read settles; a current failure lands in `error`, while a read superseded by close or another read is ignored.
|
||||
*/
|
||||
async view(id: string): Promise<void> {
|
||||
const request = ++this.viewRequest
|
||||
this.set({ error: null, view: null })
|
||||
try {
|
||||
const result = await this.ctx.remote.agentPresets.read(id)
|
||||
if (request !== this.viewRequest) return
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
const { name, content } = result.value
|
||||
this.set({ view: { id, title: name ?? id, content } })
|
||||
} catch (error) { if (request === this.viewRequest) this.set({ error: message(error) }) }
|
||||
}
|
||||
/** Close the viewer. */
|
||||
closeView(): void { this.viewRequest++; this.set({ view: null }) }
|
||||
|
||||
/** Set the default and synchronize the current blank task when supplied.
|
||||
* @param id Selected default.
|
||||
* @param sync Blank-session synchronization callback.
|
||||
|
||||
@@ -131,9 +131,9 @@ async function bench(options: {
|
||||
}
|
||||
return Promise.resolve(ROSTER)
|
||||
},
|
||||
read: () => Promise.resolve({
|
||||
read: (id: string) => Promise.resolve({
|
||||
ok: true as const,
|
||||
value: { agentPreset: 'standard', content: '', revision: 'rev' },
|
||||
value: { agentPreset: id, content: `# ${id}\n[]\n` },
|
||||
}),
|
||||
save: (id: string) => {
|
||||
calls.push(`save:${id}`)
|
||||
@@ -350,6 +350,11 @@ describe('ui-agent-preset apply', () => {
|
||||
await section.makeDefault('standard')
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().rows)
|
||||
.toEqual([{ id: 'standard', isDefault: true }])
|
||||
await section.view('standard')
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().view)
|
||||
.toEqual({ id: 'standard', title: 'standard', content: '# standard\n[]\n' })
|
||||
section.closeView()
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().view).toBeNull()
|
||||
})
|
||||
|
||||
it('refreshes a showing surface when its namespace changes, and ignores others', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AgentPresetSectionController } from '../src/client/section-store.ts'
|
||||
function fixture() {
|
||||
const remote = { agentPresets: {
|
||||
list: vi.fn(async () => ({ ok: true as const, value: { presets: [{ id: 'standard', isDefault: true }], modeSelectionEnabled: true } })),
|
||||
read: vi.fn(async (id: string) => ({ ok: true as const, value: { agentPreset: id, name: 'Standard', content: '- name: fs\n' } })),
|
||||
}, settings: { update: vi.fn(async () => ({ ok: true as const, value: {} })) } }
|
||||
const controller = new AgentPresetSectionController({ remote } as never)
|
||||
return { remote, controller, state: () => controller.store.getSnapshot() }
|
||||
@@ -14,7 +15,7 @@ describe('the preset roster', () => {
|
||||
const { controller, remote, state } = fixture()
|
||||
await Promise.all([controller.load(), controller.load()])
|
||||
expect(remote.agentPresets.list).toHaveBeenCalledOnce()
|
||||
expect(state()).toMatchObject({ status: 'ready', rows: [{ id: 'standard', isDefault: true }], showPicker: true })
|
||||
expect(state()).toMatchObject({ status: 'ready', rows: [{ id: 'standard', isDefault: true }], showPicker: true, view: null })
|
||||
remote.agentPresets.list.mockRejectedValueOnce(new Error('offline'))
|
||||
await controller.load()
|
||||
expect(state()).toMatchObject({ status: 'error', error: 'offline' })
|
||||
@@ -26,6 +27,43 @@ describe('the preset roster', () => {
|
||||
expect(state().error).toBe('gone')
|
||||
})
|
||||
|
||||
it('opens one declared composition for reading and keeps a failed read out of the viewer', async () => {
|
||||
const { controller, remote, state } = fixture()
|
||||
await controller.view('standard')
|
||||
expect(remote.agentPresets.read).toHaveBeenCalledWith('standard')
|
||||
expect(state().view).toEqual({ id: 'standard', title: 'Standard', content: '- name: fs\n' })
|
||||
controller.closeView()
|
||||
expect(state().view).toBeNull()
|
||||
remote.agentPresets.read.mockResolvedValueOnce({ ok: true, value: { agentPreset: 'mine', content: '[]\n' } } as never)
|
||||
await controller.view('mine')
|
||||
expect(state().view).toEqual({ id: 'mine', title: 'mine', content: '[]\n' })
|
||||
remote.agentPresets.read.mockResolvedValueOnce({ ok: false, error: { message: 'Unknown agent preset: gone' } } as never)
|
||||
await controller.view('gone')
|
||||
expect(state()).toMatchObject({ view: null, error: 'Unknown agent preset: gone' })
|
||||
remote.agentPresets.read.mockRejectedValueOnce(new Error('offline'))
|
||||
await controller.view('standard')
|
||||
expect(state()).toMatchObject({ view: null, error: 'offline' })
|
||||
})
|
||||
|
||||
it('ignores a read that settles after the viewer closes or a newer read opens', async () => {
|
||||
const { controller, remote, state } = fixture()
|
||||
const late = Promise.withResolvers<Awaited<ReturnType<typeof remote.agentPresets.read>>>()
|
||||
remote.agentPresets.read.mockImplementationOnce(() => late.promise)
|
||||
const first = controller.view('standard')
|
||||
controller.closeView()
|
||||
late.resolve({ ok: true, value: { agentPreset: 'standard', name: 'Standard', content: 'old' } })
|
||||
await first
|
||||
expect(state().view).toBeNull()
|
||||
|
||||
const failed = Promise.withResolvers<Awaited<ReturnType<typeof remote.agentPresets.read>>>()
|
||||
remote.agentPresets.read.mockImplementationOnce(() => failed.promise)
|
||||
const stale = controller.view('standard')
|
||||
await controller.view('mine')
|
||||
failed.reject(new Error('stale read'))
|
||||
await stale
|
||||
expect(state()).toMatchObject({ error: null, view: { id: 'mine', content: '- name: fs\n' } })
|
||||
})
|
||||
|
||||
it('keeps default selection and blank-session synchronization on their existing settings path', async () => {
|
||||
const { controller, remote, state } = fixture()
|
||||
const sync = vi.fn(async () => undefined)
|
||||
|
||||
Loaded 100 of 353 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user